Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ dist/

# Test binaries
bin/*

npm-cache/
38 changes: 33 additions & 5 deletions cmd/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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
Expand Down
117 changes: 117 additions & 0 deletions cmd/discover_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
76 changes: 76 additions & 0 deletions internal/discover/cabinets.go
Original file line number Diff line number Diff line change
@@ -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<number>.
// We assume format: x<system>c<cabinet>... (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
}
56 changes: 56 additions & 0 deletions internal/discover/cabinets_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 3 additions & 2 deletions internal/inventory/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
27 changes: 27 additions & 0 deletions internal/redfish/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down