From 923e0e34b6ce89ca49fa192947780420fe5e0d57 Mon Sep 17 00:00:00 2001 From: Travis Powell Date: Wed, 4 Mar 2026 10:41:33 -0700 Subject: [PATCH] add cabinet discovery and configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend inventory schema with a top‑level `cabinets[]` list. - Implement `DiscoverCabinets` to extract cabinet xnames from BMC entries, query the Redfish manager (`/Managers/BMC/EthernetInterfaces`) for each cabinet, and populate MAC and IP (fallback to BMC values). - Add `ManagerInfo` and `DiscoverManagerInfo` helpers in the redfish package to retrieve manager Ethernet interface details. - Update `discover` CLI command to invoke cabinet discovery and apply SSH authorized‑key configuration to cabinets (re‑using the same key as for BMCs). - Provide comprehensive unit tests: * `cabinets_test.go` validates Redfish manager discovery. Signed-off-by: Travis Powell --- .gitignore | 2 + cmd/discover.go | 38 ++++++++-- cmd/discover_test.go | 117 +++++++++++++++++++++++++++++ internal/discover/cabinets.go | 76 +++++++++++++++++++ internal/discover/cabinets_test.go | 56 ++++++++++++++ internal/inventory/types.go | 5 +- internal/redfish/client.go | 27 +++++++ 7 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 cmd/discover_test.go create mode 100644 internal/discover/cabinets.go create mode 100644 internal/discover/cabinets_test.go diff --git a/.gitignore b/.gitignore index 6216875..d8dc96b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ dist/ # Test binaries bin/* + +npm-cache/ diff --git a/cmd/discover.go b/cmd/discover.go index 701bb19..5346202 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -89,12 +89,13 @@ var discoverCmd = &cobra.Command{ } // Optionally set SSH authorized keys on each BMC if provided. + var authorized string if discSSHPubKey != "" { keyBytes, err := os.ReadFile(discSSHPubKey) if err != nil { return fmt.Errorf("read ssh pubkey: %w", err) } - authorized := string(keyBytes) + authorized = string(keyBytes) for _, b := range doc.BMCs { host := b.IP if host == "" { @@ -112,11 +113,38 @@ var discoverCmd = &cobra.Command{ } } - nodes, err := discover.UpdateNodes(&doc, discBMCSubnet, discNodeSubnet, discNodeStartIP, user, pass, discInsecure, discTimeout) - if err != nil { - return err + nodes, err := discover.UpdateNodes(&doc, discBMCSubnet, discNodeSubnet, discNodeStartIP, user, pass, discInsecure, discTimeout) + if err != nil { + return err + } + doc.Nodes = nodes + // Discover cabinets based on BMC xnames and add to inventory + if cabinets, err := discover.DiscoverCabinets(doc.BMCs, user, pass, discInsecure, discTimeout); err == nil { + doc.Cabinets = cabinets + } else { + // Log warning but continue + fmt.Fprintf(os.Stderr, "WARN: cabinet discovery: %v\n", err) + } + // Optionally set SSH authorized keys on each cabinet if provided (reuse same key file) + if discSSHPubKey != "" { + for _, c := range doc.Cabinets { + host := c.IP + if host == "" { + // No IP for cabinet, cannot configure + continue + } + ctx := cmd.Context() + if discTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, discTimeout) + defer cancel() + } + if err := redfish.SetAuthorizedKeys(ctx, host, user, pass, discInsecure, discTimeout, authorized); err != nil { + fmt.Fprintf(os.Stderr, "WARN: %s: set authorized keys (cabinet): %v\n", c.Xname, err) + } } - doc.Nodes = nodes + } + bytes, err := yaml.Marshal(&doc) if err != nil { return err diff --git a/cmd/discover_test.go b/cmd/discover_test.go new file mode 100644 index 0000000..e3bbd96 --- /dev/null +++ b/cmd/discover_test.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2025 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + "bootstrap/internal/inventory" + "gopkg.in/yaml.v3" +) + +func TestDiscoverCommand(t *testing.T) { + // Set up mock Redfish server (TLS) with required endpoints. + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/redfish/v1/Systems": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Members":[{"@odata.id":"/redfish/v1/Systems/Self"}]}`)) + case "/redfish/v1/Systems/Self/EthernetInterfaces": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Members":[{"@odata.id":"/redfish/v1/Systems/Self/EthernetInterfaces/1"}]}`)) + case "/redfish/v1/Systems/Self/EthernetInterfaces/1": + w.Header().Set("Content-Type", "application/json") + // Return a bootable NIC with a MAC address. + _, _ = w.Write([]byte(`{"Id":"1","MACAddress":"aa:bb:cc:dd:ee:01"}`)) + case "/redfish/v1/Managers/BMC/EthernetInterfaces": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Members":[{"@odata.id":"/redfish/v1/Managers/BMC/EthernetInterfaces/1"}]}`)) + case "/redfish/v1/Managers/BMC/EthernetInterfaces/1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Id":"1","MACAddress":"aa:bb:cc:dd:ee:ff","IPv4Addresses":[{"Address":"192.168.100.10","AddressOrigin":"Static"}]}`)) + default: + // Return empty JSON for any unexpected path. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + } + })) + defer ts.Close() + + // Extract host (without scheme) for the BMC entry. + host := ts.URL[len("https://"):] + + // Prepare a temporary inventory file. + tmpFile, err := os.CreateTemp("", "inventory-*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write initial inventory with a single BMC entry. + inv := inventory.FileFormat{BMCs: []inventory.Entry{{Xname: "x9000c1s0b0", MAC: "", IP: host}}} + data, _ := yaml.Marshal(&inv) + if err := os.WriteFile(tmpFile.Name(), data, 0o644); err != nil { + t.Fatalf("write inventory failed: %v", err) + } + + // Set required environment variables for Redfish authentication. + os.Setenv("REDFISH_USER", "admin") + os.Setenv("REDFISH_PASSWORD", "secret") + defer os.Unsetenv("REDFISH_USER") + defer os.Unsetenv("REDFISH_PASSWORD") + + // Configure command‑line flags directly (they are package‑level variables). + discFile = tmpFile.Name() + discBMCSubnet = "192.168.100.0/24" + discNodeSubnet = "192.168.100.0/24" + discNodeStartIP = "" + discInsecure = true + discTimeout = 5 * time.Second + discSSHPubKey = "" + discDryRun = false + + // Execute the discover command. + if err := discoverCmd.RunE(discoverCmd, []string{}); err != nil { + t.Fatalf("discover command failed: %v", err) + } + + // Load the resulting inventory. + out, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + var result inventory.FileFormat + if err := yaml.Unmarshal(out, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // Verify we have exactly one node and one cabinet. + if len(result.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(result.Nodes)) + } + if len(result.Cabinets) != 1 { + t.Fatalf("expected 1 cabinet, got %d", len(result.Cabinets)) + } + + // Node checks – the MAC should match the mock system NIC. + node := result.Nodes[0] + if node.MAC != "aa:bb:cc:dd:ee:01" { + t.Errorf("node MAC mismatch: got %s, want aa:bb:cc:dd:ee:01", node.MAC) + } + // Cabinet checks – MAC and IP should come from manager info. + cab := result.Cabinets[0] + if cab.Xname != "x9000c1" { + t.Errorf("cabinet xname mismatch: got %s, want x9000c1", cab.Xname) + } + if cab.MAC != "aa:bb:cc:dd:ee:ff" { + t.Errorf("cabinet MAC mismatch: got %s, want aa:bb:cc:dd:ee:ff", cab.MAC) + } + if cab.IP != "192.168.100.10" { + t.Errorf("cabinet IP mismatch: got %s, want 192.168.100.10", cab.IP) + } +} diff --git a/internal/discover/cabinets.go b/internal/discover/cabinets.go new file mode 100644 index 0000000..a8d6257 --- /dev/null +++ b/internal/discover/cabinets.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package discover + +import ( + "context" + "fmt" + "strings" + "time" + "bootstrap/internal/inventory" + "bootstrap/internal/redfish" +) + +// extractCabinetXname extracts the cabinet xname (e.g., x9000c1) from a BMC xname like x9000c1s0b0. +func extractCabinetXname(bmcX string) (string, error) { + // Find the first occurrence of "c" after the leading x. + // We assume format: xc... (e.g., x9000c1s0b0) + if !strings.HasPrefix(bmcX, "x") { + return "", fmt.Errorf("invalid xname: %s", bmcX) + } + idx := strings.Index(bmcX, "c") + if idx == -1 { + return "", fmt.Errorf("no cabinet identifier in xname: %s", bmcX) + } + // Find end of cabinet number digits + start := idx + 1 + end := start + for end < len(bmcX) && bmcX[end] >= '0' && bmcX[end] <= '9' { + end++ + } + if end == start { + return "", fmt.Errorf("cabinet number missing in xname: %s", bmcX) + } + cabinet := bmcX[:end] + return cabinet, nil +} + +// DiscoverCabinets scans the BMC entries and returns a list of unique cabinet entries, discovering MAC and IP via Redfish. +func DiscoverCabinets(bmcs []inventory.Entry, user, pass string, insecure bool, timeout time.Duration) ([]inventory.Entry, error) { + seen := make(map[string]bool) + var out []inventory.Entry + for _, b := range bmcs { + cabX, err := extractCabinetXname(b.Xname) + if err != nil { + continue + } + if seen[cabX] { + continue + } + // Determine host for Redfish manager query + host := b.IP + if host == "" { + host = b.Xname + } + // Attempt to discover manager info + ctx, cancel := context.WithTimeout(context.Background(), timeout) + info, err := redfish.DiscoverManagerInfo(ctx, host, user, pass, insecure, timeout) + cancel() + mac := "" + ip := b.IP // fallback to BMC IP if manager IP not found + if err == nil { + mac = info.MAC + if info.IP != "" { + ip = info.IP + } + } else { + // Fall back to existing BMC MAC if present + mac = b.MAC + } + out = append(out, inventory.Entry{Xname: cabX, MAC: mac, IP: ip}) + seen[cabX] = true + } + return out, nil +} diff --git a/internal/discover/cabinets_test.go b/internal/discover/cabinets_test.go new file mode 100644 index 0000000..700c0ad --- /dev/null +++ b/internal/discover/cabinets_test.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2025 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package discover + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "bootstrap/internal/inventory" +) + +func TestDiscoverCabinets(t *testing.T) { + // Mock Redfish manager Ethernet interfaces + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/redfish/v1/Managers/BMC/EthernetInterfaces": + // Return collection with one member + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Members":[{"@odata.id":"/redfish/v1/Managers/BMC/EthernetInterfaces/1"}]}`)) + case "/redfish/v1/Managers/BMC/EthernetInterfaces/1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Id":"1","MACAddress":"aa:bb:cc:dd:ee:ff","IPv4Addresses":[{"Address":"192.168.100.10","AddressOrigin":"Static"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + // Host without scheme (host:port) for client construction + host := strings.TrimPrefix(ts.URL, "https://") + + bmcs := []inventory.Entry{{Xname: "x9000c1s0b0", MAC: "", IP: host}} + + cabinets, err := DiscoverCabinets(bmcs, "user", "pass", true, 5*time.Second) + if err != nil { + t.Fatalf("DiscoverCabinets returned error: %v", err) + } + if len(cabinets) != 1 { + t.Fatalf("expected 1 cabinet, got %d", len(cabinets)) + } + cab := cabinets[0] + if cab.Xname != "x9000c1" { + t.Errorf("unexpected cabinet xname: %s", cab.Xname) + } + if cab.MAC != "aa:bb:cc:dd:ee:ff" { + t.Errorf("unexpected cabinet MAC: %s", cab.MAC) + } + if cab.IP != "192.168.100.10" { + t.Errorf("unexpected cabinet IP: %s", cab.IP) + } +} diff --git a/internal/inventory/types.go b/internal/inventory/types.go index 1dcdacf..5e6ebbf 100644 --- a/internal/inventory/types.go +++ b/internal/inventory/types.go @@ -14,6 +14,7 @@ type Entry struct { // FileFormat is the root YAML structure with bmcs and nodes. type FileFormat struct { - BMCs []Entry `yaml:"bmcs"` - Nodes []Entry `yaml:"nodes"` + BMCs []Entry `yaml:"bmcs"` + Cabinets []Entry `yaml:"cabinets"` + Nodes []Entry `yaml:"nodes"` } diff --git a/internal/redfish/client.go b/internal/redfish/client.go index 58e6e8d..2bff248 100644 --- a/internal/redfish/client.go +++ b/internal/redfish/client.go @@ -524,6 +524,33 @@ func SimpleUpdate(ctx context.Context, host, user, pass string, insecure bool, t // SetAuthorizedKeys configures the SSH authorized keys on a BMC. // The Redfish path used is /Managers/BMC/NetworkProtocol with an OEM payload. +type ManagerInfo struct { + MAC string + IP string +} + +// DiscoverManagerInfo discovers the manager (BMC) Ethernet interfaces and returns the first valid MAC and IP. +func DiscoverManagerInfo(ctx context.Context, host, user, pass string, insecure bool, timeout time.Duration) (ManagerInfo, error) { + c := newClient(host, user, pass, insecure, timeout) + nics, err := c.listEthernetInterfaces(ctx, "/Managers/BMC") + if err != nil { + return ManagerInfo{}, err + } + for _, nic := range nics { + if !isValidMAC(nic.MACAddress) { + continue + } + mac := strings.ToLower(nic.MACAddress) + ip := "" + if len(nic.IPv4Addresses) > 0 { + ip = nic.IPv4Addresses[0].Address + } + return ManagerInfo{MAC: mac, IP: ip}, nil + } + return ManagerInfo{}, fmt.Errorf("no valid manager ethernet interface found") +} + +// SetAuthorizedKeys configures the SSH authorized keys on a BMC. func SetAuthorizedKeys(ctx context.Context, host, user, pass string, insecure bool, timeout time.Duration, authorizedKey string) error { c := newClient(host, user, pass, insecure, timeout) payload := map[string]any{