Replaces separate IPMI FRU and SAA DMI cards with a single FRU / Elabel card that reads all available sources in parallel and shows each field with a color-coded source chip (IPMI FRU / Huawei iBMC / SAA DMI). Huawei elabel fields are read/written via OEM IPMI raw commands (NetFn 0x30, cmd 0x90) with 19-byte chunking protocol, matching the FusionServer ElabelTool V511 wire format. Covers DeviceName, DeviceSerialNumber, ProductName, ProductSerialNumber, ProductAssetTag, ProductManufacturer, MainboardManufacturer, BoardProductName, ChassisPartnumber, ChassisType (read-only), IOChassisSerial, IOChassisAssetTag, and GUID (read-only via standard 0x06 0x08). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
5.9 KiB
Go
205 lines
5.9 KiB
Go
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 — vendor doc names and ipmitool abbreviated names
|
|
"Chassis Part Number": {"c", 0},
|
|
"Chassis Serial Number": {"c", 1},
|
|
"Chassis Serial": {"c", 1},
|
|
"Chassis Extra": {"c", 2},
|
|
// Board — vendor doc names and ipmitool abbreviated names
|
|
"Board Manufacturer": {"b", 0},
|
|
"Board Mfg": {"b", 0},
|
|
"Board Product Name": {"b", 1},
|
|
"Board Product": {"b", 1},
|
|
"Board Serial Number": {"b", 2},
|
|
"Board Serial": {"b", 2},
|
|
"Board Part Number": {"b", 3},
|
|
// Product — vendor doc names and ipmitool abbreviated names
|
|
"Product Manufacturer": {"p", 0},
|
|
"Product Name": {"p", 1},
|
|
"Product Part Number": {"p", 2},
|
|
"Product Version": {"p", 3},
|
|
"Product Serial Number": {"p", 4},
|
|
"Product Serial": {"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
|
|
}
|
|
// All fields are shown as editable; server will reject unknown fields.
|
|
return true, "", 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 i, c := range req.Changes {
|
|
if c.Area == "" {
|
|
e, ok := fruEditableFields[c.Name]
|
|
if !ok {
|
|
writeError(w, http.StatusUnprocessableEntity, "field not writable via ipmitool: "+c.Name)
|
|
return
|
|
}
|
|
req.Changes[i].Area = e.Area
|
|
req.Changes[i].Index = e.Index
|
|
c = req.Changes[i]
|
|
}
|
|
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
|
|
}
|
|
|