Files
rollcall/cmd/rollcall/firmware_test.go
T
Ilker ManapandClaude Opus 5.5 b01d8e2b4d
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 39s
Firmware updates over Redfish, per vendor
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>
2026-10-29 19:22:01 +01:00

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)
}
}