A firmware repository on the region (firmware_dir, served under /firmware/<id>/<file>, cached by rack controllers for BMCs to pull), each machine's firmware inventory (FirmwareInventory, classified into bios/bmc/nic/raid/cpld/psu/drive), and update jobs (kind "firmware") that the region drives with short rack commands: fw-start hands the package over, fw-poll watches the task (tolerating a BMC that restarts), fw-finish releases what was claimed, fw-inventory verifies the version. Running updates resume after a region restart; they cannot be cancelled once the BMC has the package. Standard methods (multipart, SimpleUpdate pull, HttpPushUri) with known Content-Length, plus vendor plans from their documentation or their own tools: Dell MultipartUpload (OnReset), HPE SimpleUpdate with .fwpkg extracted at import, Lenovo /mfwupdate with HttpPushUriTargetsBusy, Supermicro /upload with targets and preserve options, GIGABYTE/AMI/ ASRock Rack /upload with OemParameters ImageType, OpenBMC update-multipart, xFusion upload to /tmp/web then SimpleUpdate, KAYTUS upload then OEM SimpleUpdate with fixed tasks, Fujitsu iRMC OEM actions; Cisco CIMC is refused clearly (SCP only). fw_params and fw_method on the BMC record override a plan. CLI rollcall firmware list|import|rm|show|update, API, a Firmware tab and a repository card in the UI. The lab Redfish emulator gains an UpdateService (BMC applies at once, BIOS at the next reset). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
10 KiB
Go
290 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fwBMC is an UpdateService: it takes packages the ways the flags allow and
|
|
// keeps a task per upload.
|
|
type fwBMC struct {
|
|
mu sync.Mutex
|
|
multipart, simple, push bool
|
|
protocols []string
|
|
got []string // method:file-content or method:ImageURI
|
|
params []obj
|
|
chunked bool
|
|
polls int
|
|
}
|
|
|
|
func (f *fwBMC) handler(t *testing.T) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if u, p, ok := r.BasicAuth(); !ok || u != "admin" || p != "secret" {
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
task := func() {
|
|
w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/3")
|
|
w.WriteHeader(202)
|
|
}
|
|
switch {
|
|
case r.URL.Path == "/redfish/v1/UpdateService":
|
|
us := obj{}
|
|
if f.multipart {
|
|
us["MultipartHttpPushUri"] = "/redfish/v1/UpdateService/upload"
|
|
}
|
|
if f.push {
|
|
us["HttpPushUri"] = "/redfish/v1/UpdateService/push"
|
|
}
|
|
if f.simple {
|
|
act := obj{"target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"}
|
|
if f.protocols != nil {
|
|
act["TransferProtocol@Redfish.AllowableValues"] = f.protocols
|
|
}
|
|
us["Actions"] = obj{"#UpdateService.SimpleUpdate": act}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(us)
|
|
case r.URL.Path == "/redfish/v1/UpdateService/upload":
|
|
f.chunked = f.chunked || r.ContentLength < 0 || len(r.TransferEncoding) > 0
|
|
_, ps, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
w.WriteHeader(400)
|
|
return
|
|
}
|
|
mr := multipart.NewReader(r.Body, ps["boundary"])
|
|
for {
|
|
p, err := mr.NextPart()
|
|
if err != nil {
|
|
break
|
|
}
|
|
b, _ := io.ReadAll(p)
|
|
switch p.FormName() {
|
|
case "UpdateParameters":
|
|
var o obj
|
|
_ = json.Unmarshal(b, &o)
|
|
f.params = append(f.params, o)
|
|
case "UpdateFile":
|
|
f.got = append(f.got, "multipart:"+p.FileName()+":"+string(b))
|
|
}
|
|
}
|
|
task()
|
|
case r.URL.Path == "/redfish/v1/UpdateService/push":
|
|
f.chunked = f.chunked || r.ContentLength < 0
|
|
b, _ := io.ReadAll(r.Body)
|
|
f.got = append(f.got, "push:"+string(b))
|
|
task()
|
|
case r.URL.Path == "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate":
|
|
var o obj
|
|
_ = json.NewDecoder(r.Body).Decode(&o)
|
|
f.got = append(f.got, "simple:"+str(o["ImageURI"])+":"+str(o["TransferProtocol"]))
|
|
task()
|
|
case r.URL.Path == "/redfish/v1/TaskService/Tasks/3":
|
|
f.polls++
|
|
st := obj{"TaskState": "Running", "PercentComplete": 40}
|
|
if f.polls > 1 {
|
|
st = obj{"TaskState": "Completed", "PercentComplete": 100, "Messages": []obj{{"Message": "Firmware update completed"}}}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(st)
|
|
case r.URL.Path == "/redfish/v1/UpdateService/FirmwareInventory":
|
|
_ = json.NewEncoder(w).Encode(obj{"Members": []obj{
|
|
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Installed-159-2.19.1"},
|
|
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Previous-159-2.18.0"},
|
|
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Installed-25227-7.10.30.00"},
|
|
}})
|
|
case strings.HasPrefix(r.URL.Path, "/redfish/v1/UpdateService/FirmwareInventory/Installed-159"):
|
|
_ = json.NewEncoder(w).Encode(obj{"Id": "Installed-159-2.19.1", "Name": "BIOS", "Version": "2.19.1", "Updateable": true})
|
|
case strings.HasPrefix(r.URL.Path, "/redfish/v1/UpdateService/FirmwareInventory/Installed-25227"):
|
|
_ = json.NewEncoder(w).Encode(obj{"Id": "Installed-25227-7.10.30.00", "Name": "Integrated Dell Remote Access Controller", "Version": "7.10.30.00", "Updateable": true})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
func runFW(t *testing.T, f *fwBMC, op string, arg any) string {
|
|
t.Helper()
|
|
srv := httptest.NewTLSServer(f.handler(t))
|
|
defer srv.Close()
|
|
pkg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("FIRMWARE-BYTES")) }))
|
|
defer pkg.Close()
|
|
a := ""
|
|
if s, ok := arg.(string); ok {
|
|
a = s
|
|
} else if arg != nil {
|
|
sa := arg.(fwStartArg)
|
|
if sa.URL == "" {
|
|
sa.URL = pkg.URL + "/firmware/p/BIOS_X.EXE"
|
|
}
|
|
b, _ := json.Marshal(sa)
|
|
a = string(b)
|
|
}
|
|
out, err := powerRun(context.Background(), "", creds(srv.URL, "redfish"), op, a)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", op, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestFirmwareMultipart(t *testing.T) {
|
|
f := &fwBMC{multipart: true, simple: true}
|
|
var res fwStartResult
|
|
_ = json.Unmarshal([]byte(runFW(t, f, "fw-start", fwStartArg{File: "BIOS_X.EXE", Apply: "OnReset"})), &res)
|
|
if res.Method != "multipart" || res.Task != "/redfish/v1/TaskService/Tasks/3" {
|
|
t.Errorf("result %+v", res)
|
|
}
|
|
if len(f.got) != 1 || f.got[0] != "multipart:BIOS_X.EXE:FIRMWARE-BYTES" || f.chunked {
|
|
t.Errorf("got %v chunked %v", f.got, f.chunked)
|
|
}
|
|
if f.params[0]["@Redfish.OperationApplyTime"] != "OnReset" {
|
|
t.Errorf("params %v", f.params)
|
|
}
|
|
}
|
|
|
|
func TestFirmwareSimpleAndPush(t *testing.T) {
|
|
f := &fwBMC{simple: true, protocols: []string{"HTTP", "HTTPS", "NFS"}}
|
|
_ = runFW(t, f, "fw-start", fwStartArg{File: "x.bin"})
|
|
if len(f.got) != 1 || !strings.HasPrefix(f.got[0], "simple:http://") || !strings.HasSuffix(f.got[0], ":HTTP") {
|
|
t.Errorf("simple %v", f.got)
|
|
}
|
|
g := &fwBMC{push: true}
|
|
_ = runFW(t, g, "fw-start", fwStartArg{File: "x.bin"})
|
|
if len(g.got) != 1 || g.got[0] != "push:FIRMWARE-BYTES" || g.chunked {
|
|
t.Errorf("push %v chunked %v", g.got, g.chunked)
|
|
}
|
|
// a service that pulls only over NFS cannot take our HTTP URL: refused clearly
|
|
h := &fwBMC{simple: true, protocols: []string{"NFS"}}
|
|
srv := httptest.NewTLSServer(h.handler(t))
|
|
defer srv.Close()
|
|
b, _ := json.Marshal(fwStartArg{URL: "http://rack/firmware/p/x.bin", File: "x.bin"})
|
|
if _, err := powerRun(context.Background(), "", creds(srv.URL, "redfish"), "fw-start", string(b)); err == nil || !strings.Contains(err.Error(), "not HTTP") {
|
|
t.Errorf("nfs-only: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFirmwarePollAndInventory(t *testing.T) {
|
|
f := &fwBMC{}
|
|
var p fwPollResult
|
|
_ = json.Unmarshal([]byte(runFW(t, f, "fw-poll", "/redfish/v1/TaskService/Tasks/3")), &p)
|
|
if p.State != "running" || p.Pct != 40 {
|
|
t.Errorf("first poll %+v", p)
|
|
}
|
|
var inv []fwComponent
|
|
_ = json.Unmarshal([]byte(runFW(t, f, "fw-inventory", nil)), &inv)
|
|
if len(inv) != 2 || inv[0].Component != "bios" || inv[1].Component != "bmc" || inv[0].Version != "2.19.1" {
|
|
t.Errorf("inventory %+v", inv)
|
|
}
|
|
pkgs := []FWPackage{{ID: "a", Vendor: "idrac", Component: "bios", Version: "2.19.1"}, {ID: "b", Vendor: "idrac", Component: "bmc", Version: "7.20.10.50"}}
|
|
offers := fwOffers(pkgs, inv)
|
|
if !offers[0].Same || offers[1].Same || offers[1].Installed != "7.10.30.00" {
|
|
t.Errorf("offers %+v", offers)
|
|
}
|
|
}
|
|
|
|
func TestFirmwareNeedsRedfish(t *testing.T) {
|
|
if _, err := powerRun(context.Background(), "", &bmcCreds{Driver: "ipmi"}, "fw-inventory", ""); err == nil || !strings.Contains(err.Error(), "Redfish") {
|
|
t.Errorf("ipmi: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFirmwareRepository(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(t.TempDir(), "BIOS_ABC_WN64_2.19.1.EXE")
|
|
_ = os.WriteFile(src, []byte("dup"), 0o644)
|
|
p, err := fwImport(context.Background(), dir, src, FWPackage{Vendor: "idrac", Component: "bios", Version: "2.19.1", Models: []string{"R650"}}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !fwIDRe.MatchString(p.ID) || p.Size != 3 || p.File != "BIOS_ABC_WN64_2.19.1.EXE" {
|
|
t.Errorf("package %+v", p)
|
|
}
|
|
if _, err := fwImport(context.Background(), dir, src, FWPackage{Vendor: "idrac", Component: "bios", Version: "x"}, "00"); err == nil {
|
|
t.Error("a wrong sha256 was accepted")
|
|
}
|
|
for _, bad := range []FWPackage{{Vendor: "ipmi", Component: "bios", Version: "1"}, {Vendor: "idrac", Component: "toaster", Version: "1"}, {Vendor: "idrac", Component: "bios"}} {
|
|
if _, err := fwImport(context.Background(), dir, src, bad, ""); err == nil {
|
|
t.Errorf("accepted %+v", bad)
|
|
}
|
|
}
|
|
list, _ := fwList(dir)
|
|
if len(list) != 1 || len(fwFor(list, "idrac", "PowerEdge R650")) != 1 || len(fwFor(list, "idrac", "PowerEdge R760")) != 0 || len(fwFor(list, "ilo", "R650")) != 0 {
|
|
t.Errorf("list %+v", list)
|
|
}
|
|
if _, err := fwGet(dir, "../etc"); err == nil {
|
|
t.Error("a path as package id")
|
|
}
|
|
if err := fwRemove(dir, p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range [][2]string{{"2.19.1", "2.19.1"}, {"v2.19.1", "2.19.1"}, {"2.19.1 (Build 4)", "2.19.1"}} {
|
|
if !fwSameVersion(c[0], c[1]) {
|
|
t.Errorf("%q vs %q", c[0], c[1])
|
|
}
|
|
}
|
|
if fwSameVersion("2.19.10", "2.19.1") {
|
|
t.Error("2.19.10 is not 2.19.1")
|
|
}
|
|
}
|
|
|
|
func TestFirmwareKinds(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"Installed-159-2.19.1 BIOS": "bios",
|
|
"Integrated Dell Remote Access Controller": "bmc",
|
|
"PERC H755 Front": "raid",
|
|
"RAID Controller": "raid",
|
|
"iLO 6": "bmc",
|
|
"Broadcom Adv. Dual 25Gb Ethernet": "nic",
|
|
"Mellanox ConnectX-6": "nic",
|
|
"PSU.Slot.1 Power Supply": "psu",
|
|
"System CPLD": "cpld",
|
|
"Dell Ent NVMe P5600": "drive",
|
|
"Lifecycle Controller": "other",
|
|
"OS Collector": "other",
|
|
} {
|
|
if got := fwKind("", in); got != want {
|
|
t.Errorf("%q: %s, want %s", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFirmwareFwpkg(t *testing.T) {
|
|
mk := func(files ...string) string {
|
|
p := filepath.Join(t.TempDir(), "cp056789.fwpkg")
|
|
f, _ := os.Create(p)
|
|
zw := zip.NewWriter(f)
|
|
for _, n := range files {
|
|
w, _ := zw.Create(n)
|
|
_, _ = w.Write([]byte("payload of " + n))
|
|
}
|
|
_ = zw.Close()
|
|
_ = f.Close()
|
|
return p
|
|
}
|
|
dir := t.TempDir()
|
|
got, err := fwImport(context.Background(), dir, mk("payload.json", "ilo6_166.bin"), FWPackage{Vendor: "ilo", Component: "bmc", Version: "1.66"}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.File != "ilo6_166.bin" || got.Size != int64(len("payload of ilo6_166.bin")) || !strings.Contains(got.Notes, "cp056789.fwpkg") {
|
|
t.Errorf("extracted %+v", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, got.ID, "ilo6_166.bin")); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if _, err := fwImport(context.Background(), dir, mk("payload.json", "setup.exe"), FWPackage{Vendor: "ilo", Component: "nic", Version: "1"}, ""); err == nil || !strings.Contains(err.Error(), "0 files") {
|
|
t.Errorf("agent-only component: %v", err)
|
|
}
|
|
}
|