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>
215 lines
6.2 KiB
Go
215 lines
6.2 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type dmiField struct {
|
|
Name string `json:"name"`
|
|
Shn string `json:"shn"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type saaChange struct {
|
|
Shn string `json:"shn"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
var (
|
|
shnRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`)
|
|
dmiSectionRE = regexp.MustCompile(`^\[(.+?)\]$`)
|
|
// Item Name {SHN} = value // comment
|
|
// SHN may contain parentheses, e.g. {PS(4)LC} for power supply fields
|
|
dmiItemRE = regexp.MustCompile(`^(.+?)\s+\{([A-Za-z0-9_()\-]{1,24})\}\s*=\s*(.*)$`)
|
|
dmiVersionRE = regexp.MustCompile(`(?i)^version\s*=`)
|
|
)
|
|
|
|
|
|
// parseDMIFile parses the DMI.txt produced by "saa GetDmiInfo".
|
|
// Real format (from SAA User Guide 4.8.1):
|
|
//
|
|
// [System]
|
|
// Version {SYVS} = "A Version" // string value
|
|
// Serial Number {SYSN} = $DEFAULT$ // string value
|
|
// UUID {SYUU} = 00112233-... // hex value
|
|
func parseDMIFile(content string) []dmiField {
|
|
var fields []dmiField
|
|
currentSection := ""
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if dmiVersionRE.MatchString(line) {
|
|
continue
|
|
}
|
|
if m := dmiSectionRE.FindStringSubmatch(line); m != nil {
|
|
currentSection = strings.TrimSpace(m[1])
|
|
continue
|
|
}
|
|
m := dmiItemRE.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
itemName := strings.TrimSpace(m[1])
|
|
shn := m[2]
|
|
rawValue := strings.TrimSpace(m[3])
|
|
// strip trailing comment (space + //)
|
|
if idx := strings.LastIndex(rawValue, " //"); idx >= 0 {
|
|
rawValue = strings.TrimSpace(rawValue[:idx])
|
|
}
|
|
// strip surrounding double quotes from string values
|
|
if len(rawValue) >= 2 && rawValue[0] == '"' && rawValue[len(rawValue)-1] == '"' {
|
|
rawValue = rawValue[1 : len(rawValue)-1]
|
|
}
|
|
displayName := itemName
|
|
if currentSection != "" {
|
|
displayName = currentSection + " / " + itemName
|
|
}
|
|
fields = append(fields, dmiField{Name: displayName, Shn: shn, Value: rawValue})
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func (h *handler) handleAPISAADMIRead(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "bee-saa-*")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "create temp dir: "+err.Error())
|
|
return
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dmiFile := filepath.Join(tmpDir, "DMI.txt")
|
|
cmd := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")
|
|
cmd.Dir = "/usr/local/bin"
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "saa GetDmiInfo: "+msg)
|
|
return
|
|
}
|
|
|
|
raw, err := os.ReadFile(dmiFile)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "read DMI file: "+err.Error())
|
|
return
|
|
}
|
|
|
|
fields := parseDMIFile(string(raw))
|
|
if len(fields) == 0 {
|
|
writeError(w, http.StatusInternalServerError, "no DMI fields found (file may be empty — reboot the server and try again)")
|
|
return
|
|
}
|
|
writeJSON(w, fields)
|
|
}
|
|
|
|
func (h *handler) handleAPISAADMIWrite(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Changes []saaChange `json:"changes"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.Changes) == 0 {
|
|
writeError(w, http.StatusUnprocessableEntity, "no changes provided")
|
|
return
|
|
}
|
|
for _, c := range req.Changes {
|
|
if !shnRE.MatchString(c.Shn) {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid shn: "+c.Shn)
|
|
return
|
|
}
|
|
if len(c.Value) == 0 || len(c.Value) > 64 {
|
|
writeError(w, http.StatusUnprocessableEntity, "value length out of range for shn: "+c.Shn)
|
|
return
|
|
}
|
|
for _, ch := range c.Value {
|
|
if ch < 0x20 || ch > 0x7E {
|
|
writeError(w, http.StatusUnprocessableEntity, "value contains non-printable character for shn: "+c.Shn)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
t := &Task{
|
|
ID: newJobID("saa-dmi-write"),
|
|
Name: fmt.Sprintf("SAA DMI Write (%d field(s))", len(req.Changes)),
|
|
Target: "saa-dmi-write",
|
|
Priority: defaultTaskPriority("saa-dmi-write", taskParams{}),
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{
|
|
SAADmiChanges: req.Changes,
|
|
},
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID})
|
|
}
|
|
|
|
func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error {
|
|
tmpDir, err := os.MkdirTemp("", "bee-saa-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
dmiFile := filepath.Join(tmpDir, "DMI.txt")
|
|
|
|
j.append("Reading current DMI configuration...")
|
|
getCmd := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")
|
|
getCmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, getCmd); err != nil {
|
|
return fmt.Errorf("GetDmiInfo: %w", err)
|
|
}
|
|
|
|
backupDir := filepath.Join(exportDir, "dmi-backups")
|
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
|
return fmt.Errorf("create backup dir: %w", err)
|
|
}
|
|
backupName := "dmi-" + time.Now().UTC().Format("20060102-150405") + ".txt"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
raw, err := os.ReadFile(dmiFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read DMI file: %w", err)
|
|
}
|
|
if err := os.WriteFile(backupPath, raw, 0o644); err != nil {
|
|
return fmt.Errorf("write backup: %w", err)
|
|
}
|
|
j.append("Backup saved: dmi-backups/" + backupName)
|
|
|
|
for _, c := range p.SAADmiChanges {
|
|
j.append("Setting " + c.Shn + " = " + c.Value)
|
|
cmd := exec.CommandContext(ctx, "saa", "-c", "EditDmiInfo", "--file", dmiFile, "--shn", c.Shn, "--value", c.Value)
|
|
cmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, cmd); err != nil {
|
|
return fmt.Errorf("EditDmiInfo %s: %w", c.Shn, err)
|
|
}
|
|
}
|
|
|
|
j.append("Applying changes to hardware...")
|
|
changeCmd := exec.CommandContext(ctx, "saa", "-c", "ChangeDmiInfo", "--file", dmiFile)
|
|
changeCmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, changeCmd); err != nil {
|
|
return fmt.Errorf("ChangeDmiInfo: %w", err)
|
|
}
|
|
|
|
j.append("Done. Reboot the server for changes to take effect.")
|
|
return nil
|
|
}
|
|
|