From b49c71a98083a4c5fa3d86f3febe089be34f6d0e Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 19 Jun 2026 08:13:35 +0300 Subject: [PATCH] Add IPMI FRU editor to Tools page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New card "IPMI — FRU" on Tools page (device 0, in-band) - Read: GET /api/tools/ipmi-fru → ipmitool fru print 0 → editable table - Editable fields: chassis (part#, serial, extra), board (mfr, product, serial, part#), product (mfr, name, part#, version, serial); read-only fields displayed as text - Write: POST /api/tools/ipmi-fru/write → task → backup to fru-backups/ → ipmitool fru edit per field - Dirty tracking + Save (N changed) button, same UX as Supermicro DMI card Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/webui/ipmi_fru.go | 293 ++++++++++++++++++++++ audit/internal/webui/page_export_tools.go | 4 +- audit/internal/webui/server.go | 2 + audit/internal/webui/task_runner.go | 6 + audit/internal/webui/tasks.go | 3 +- 5 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 audit/internal/webui/ipmi_fru.go diff --git a/audit/internal/webui/ipmi_fru.go b/audit/internal/webui/ipmi_fru.go new file mode 100644 index 0000000..5e4ebbd --- /dev/null +++ b/audit/internal/webui/ipmi_fru.go @@ -0,0 +1,293 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode" +) + +type fruField struct { + Name string `json:"name"` + Value string `json:"value"` + Editable bool `json:"editable"` + Area string `json:"area,omitempty"` + Index int `json:"index,omitempty"` +} + +type fruChange struct { + Area string `json:"area"` + Index int `json:"index"` + Name string `json:"name"` + Value string `json:"value"` +} + +// fruEditableFields maps display name → area + index for ipmitool fru edit. +var fruEditableFields = map[string]struct { + Area string + Index int +}{ + "Chassis Part Number": {"c", 0}, + "Chassis Serial Number": {"c", 1}, + "Chassis Extra": {"c", 2}, + "Board Manufacturer": {"b", 0}, + "Board Product Name": {"b", 1}, + "Board Serial Number": {"b", 2}, + "Board Part Number": {"b", 3}, + "Product Manufacturer": {"p", 0}, + "Product Name": {"p", 1}, + "Product Part Number": {"p", 2}, + "Product Version": {"p", 3}, + "Product Serial Number": {"p", 4}, +} + +func parseFRUOutput(output string) []fruField { + var fields []fruField + for _, line := range strings.Split(output, "\n") { + // Lines look like: " Field Name : value" + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + colon := strings.Index(trimmed, " : ") + if colon < 0 { + // try ": " with no leading space before colon + colon = strings.Index(trimmed, ": ") + if colon < 0 { + continue + } + name := strings.TrimSpace(trimmed[:colon]) + value := strings.TrimSpace(trimmed[colon+2:]) + if name == "" { + continue + } + editable, area, idx := fruFieldMeta(name) + fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx}) + continue + } + name := strings.TrimSpace(trimmed[:colon]) + value := strings.TrimSpace(trimmed[colon+3:]) + if name == "" { + continue + } + editable, area, idx := fruFieldMeta(name) + fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx}) + } + return fields +} + +func fruFieldMeta(name string) (editable bool, area string, index int) { + if e, ok := fruEditableFields[name]; ok { + return true, e.Area, e.Index + } + return false, "", 0 +} + +func (h *handler) handleAPIIPMIFRURead(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + writeError(w, http.StatusInternalServerError, "ipmitool fru print: "+msg) + return + } + + fields := parseFRUOutput(string(out)) + writeJSON(w, fields) +} + +func (h *handler) handleAPIIPMIFRUWrite(w http.ResponseWriter, r *http.Request) { + var req struct { + Changes []fruChange `json:"changes"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if len(req.Changes) == 0 { + writeError(w, http.StatusUnprocessableEntity, "no changes provided") + return + } + validAreas := map[string]bool{"c": true, "b": true, "p": true} + for _, c := range req.Changes { + if !validAreas[c.Area] { + writeError(w, http.StatusUnprocessableEntity, "invalid area: "+c.Area) + return + } + if c.Index < 0 || c.Index > 9 { + writeError(w, http.StatusUnprocessableEntity, fmt.Sprintf("invalid index %d", c.Index)) + return + } + if len(c.Value) > 64 { + writeError(w, http.StatusUnprocessableEntity, "value too long (max 64 chars)") + return + } + for _, ch := range c.Value { + if ch > unicode.MaxASCII || (ch < 0x20 && ch != 0) { + writeError(w, http.StatusUnprocessableEntity, "value contains non-printable characters") + return + } + } + } + + t := &Task{ + ID: newJobID("ipmi-fru-write"), + Name: fmt.Sprintf("IPMI FRU Write (%d field(s))", len(req.Changes)), + Target: "ipmi-fru-write", + Priority: defaultTaskPriority("ipmi-fru-write", taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{FRUChanges: req.Changes}, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + +func runIPMIFRUWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error { + // Backup current FRU state + backupDir := filepath.Join(exportDir, "fru-backups") + if err := os.MkdirAll(backupDir, 0755); err != nil { + return fmt.Errorf("mkdir fru-backups: %w", err) + } + stamp := time.Now().Format("20060102150405") + backupPath := filepath.Join(backupDir, "fru-"+stamp+".txt") + + backupOut, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput() + if err != nil { + return fmt.Errorf("backup fru print: %w", err) + } + if err := os.WriteFile(backupPath, backupOut, 0644); err != nil { + return fmt.Errorf("write backup: %w", err) + } + j.append("Backup saved to " + backupPath) + + // Apply changes + for _, c := range p.FRUChanges { + j.append(fmt.Sprintf("Setting %s (%s %d) = %q", c.Name, c.Area, c.Index, c.Value)) + cmd := exec.CommandContext(ctx, "ipmitool", "fru", "edit", "0", "field", c.Area, fmt.Sprintf("%d", c.Index), c.Value) + if err := streamCmdJob(j, cmd); err != nil { + return fmt.Errorf("fru edit %s %d: %w", c.Area, c.Index, err) + } + } + return nil +} + +func renderIPMIFRUCard() string { + return `
IPMI — FRU
+

Reads and edits FRU fields via ipmitool (In-Band, device 0). Works on any server with IPMI support.

+
+
+ +
+` +} diff --git a/audit/internal/webui/page_export_tools.go b/audit/internal/webui/page_export_tools.go index e8558ed..9ce2f51 100644 --- a/audit/internal/webui/page_export_tools.go +++ b/audit/internal/webui/page_export_tools.go @@ -404,7 +404,9 @@ loadNvidiaSelfHeal(); func renderTools() string { return renderNVMeFormatCard() + ` -` + renderSAADMICard() +` + renderSAADMICard() + ` + +` + renderIPMIFRUCard() } func renderExportIndex(exportDir string) (string, error) { diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 58c1666..737c401 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -316,6 +316,8 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.HandleFunc("POST /api/tools/nvme-format/run", h.handleAPINVMeFormatRun) mux.HandleFunc("GET /api/tools/saa-dmi", h.handleAPISAADMIRead) mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite) + mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead) + mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite) // GPU presence / tools mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence) diff --git a/audit/internal/webui/task_runner.go b/audit/internal/webui/task_runner.go index db597e1..1200616 100644 --- a/audit/internal/webui/task_runner.go +++ b/audit/internal/webui/task_runner.go @@ -388,6 +388,12 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont break } err = runSAADMIWriteTask(ctx, j, opts.ExportDir, t.params) + case "ipmi-fru-write": + if len(t.params.FRUChanges) == 0 { + err = fmt.Errorf("no changes provided") + break + } + err = runIPMIFRUWriteTask(ctx, j, opts.ExportDir, t.params) default: j.append("ERROR: unknown target: " + t.Target) j.finish("unknown target") diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 7b2bce7..44af697 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -140,7 +140,8 @@ type taskParams struct { Device string `json:"device,omitempty"` // for install LBAF int `json:"lbaf,omitempty"` PlatformComponents []string `json:"platform_components,omitempty"` - SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"` + SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"` + FRUChanges []fruChange `json:"fru_changes,omitempty"` } type persistedTask struct {