From 6c8671991191083653f4009c739aec288ace3038 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 12 Jun 2026 03:34:40 -0400 Subject: [PATCH 1/2] feat: implement BMC vendor management and connection handling Signed-off-by: Alex Lovell-Troy --- cmd/root.go | 3 + pkg/bmc/client.go | 128 +++++++++++++++++++++++ pkg/bmc/conn.go | 34 ++++++ pkg/bmc/manager.go | 99 ++++++++++++++++++ pkg/bmc/registry.go | 58 ++++++++++ pkg/bmc/vendors/cray/cray.go | 32 ++++++ pkg/bmc/vendors/dell/dell.go | 33 ++++++ pkg/bmc/vendors/hpe/hpe.go | 34 ++++++ pkg/bmc/vendors/lenovo/lenovo.go | 32 ++++++ pkg/bmc/vendors/supermicro/supermicro.go | 34 ++++++ pkg/bmc/vendors/vendors.go | 15 +++ pkg/collect.go | 24 +---- pkg/crawler/main.go | 63 ++--------- pkg/power/power.go | 88 +++------------- pkg/service/service.go | 106 +++++++++++++++++++ 15 files changed, 632 insertions(+), 151 deletions(-) create mode 100644 pkg/bmc/client.go create mode 100644 pkg/bmc/conn.go create mode 100644 pkg/bmc/manager.go create mode 100644 pkg/bmc/registry.go create mode 100644 pkg/bmc/vendors/cray/cray.go create mode 100644 pkg/bmc/vendors/dell/dell.go create mode 100644 pkg/bmc/vendors/hpe/hpe.go create mode 100644 pkg/bmc/vendors/lenovo/lenovo.go create mode 100644 pkg/bmc/vendors/supermicro/supermicro.go create mode 100644 pkg/bmc/vendors/vendors.go create mode 100644 pkg/service/service.go diff --git a/cmd/root.go b/cmd/root.go index 67aee6b4..b05ee592 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,9 @@ import ( "github.com/OpenCHAMI/magellan/internal/format" logger "github.com/OpenCHAMI/magellan/internal/log" "github.com/OpenCHAMI/magellan/internal/util" + // Register the in-tree BMC vendor plugins for their init() side effects so + // vendor detection can dispatch to them at runtime. + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" diff --git a/pkg/bmc/client.go b/pkg/bmc/client.go new file mode 100644 index 00000000..22f4866c --- /dev/null +++ b/pkg/bmc/client.go @@ -0,0 +1,128 @@ +package bmc + +import ( + "fmt" + + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/redfish" +) + +// Vendor identifies the manufacturer of a BMC. It is used to dispatch to +// vendor-specific quirk handling. VendorGeneric is the fallback used for +// unknown vendors and for vendors that have no registered plugin. +type Vendor string + +const ( + VendorGeneric Vendor = "generic" + VendorHPE Vendor = "hpe" + VendorDell Vendor = "dell" + VendorSupermicro Vendor = "supermicro" + VendorCray Vendor = "cray" + VendorLenovo Vendor = "lenovo" +) + +// Client is the vendor-shielded interface that callers use to operate a BMC. +// Implementations encapsulate vendor-specific behavior so that callers never +// need to special-case a manufacturer. The generic implementation +// (GenericClient) wraps gofish directly and is used as the fallback for unknown +// vendors; vendor plugins under pkg/bmc/vendors embed it and override only the +// operations that differ. +// +// Gofish exposes the underlying gofish client for operations that have not yet +// been hoisted behind the abstraction (e.g. inventory crawling). As the +// abstraction grows, callers should prefer the typed methods over Gofish. +type Client interface { + // Gofish returns the underlying gofish API client. + Gofish() *gofish.APIClient + // Vendor returns the detected vendor for this BMC. + Vendor() Vendor + // Logout terminates the underlying BMC session. + Logout() + + // GetPowerState returns the power state of the ComputerSystem with the + // given Redfish ID. + GetPowerState(systemID string) (redfish.PowerState, error) + // GetResetTypes returns the reset types supported by the ComputerSystem + // with the given Redfish ID. + GetResetTypes(systemID string) ([]redfish.ResetType, error) + // Reset issues a reset of the given type to the ComputerSystem with the + // given Redfish ID. + Reset(systemID string, resetType redfish.ResetType) error +} + +// GenericClient is the default, vendor-agnostic implementation of Client backed +// by gofish. It performs plain Redfish operations and is the fallback whenever a +// BMC's vendor is unknown or has no registered plugin. +type GenericClient struct { + api *gofish.APIClient + vendor Vendor +} + +// NewGenericClient wraps a connected gofish client as a GenericClient with the +// given detected vendor. +func NewGenericClient(api *gofish.APIClient, vendor Vendor) *GenericClient { + if vendor == "" { + vendor = VendorGeneric + } + return &GenericClient{api: api, vendor: vendor} +} + +func (g *GenericClient) Gofish() *gofish.APIClient { return g.api } + +func (g *GenericClient) Vendor() Vendor { return g.vendor } + +func (g *GenericClient) Logout() { + if g.api != nil { + g.api.Logout() + } +} + +// systemByID looks up a ComputerSystem under the ServiceRoot by its Redfish ID. +func (g *GenericClient) systemByID(systemID string) (*redfish.ComputerSystem, error) { + systems, err := g.api.GetService().Systems() + if err != nil { + return nil, err + } + for i := range systems { + if systems[i].ID == systemID { + return systems[i], nil + } + } + return nil, fmt.Errorf("computer system %q not found", systemID) +} + +func (g *GenericClient) GetPowerState(systemID string) (redfish.PowerState, error) { + system, err := g.systemByID(systemID) + if err != nil { + return "", err + } + return system.PowerState, nil +} + +func (g *GenericClient) GetResetTypes(systemID string) ([]redfish.ResetType, error) { + system, err := g.systemByID(systemID) + if err != nil { + return nil, err + } + return system.SupportedResetTypes, nil +} + +func (g *GenericClient) Reset(systemID string, resetType redfish.ResetType) error { + system, err := g.systemByID(systemID) + if err != nil { + return err + } + return system.Reset(resetType) +} + +// ErrUnsupportedQuirk is the canonical "fail loudly" error a vendor plugin (or +// the generic fallback) returns when it encounters an operation it cannot +// safely perform without vendor-specific handling. Failing loudly signals that +// a vendor-specific plugin under pkg/bmc/vendors is required. +func ErrUnsupportedQuirk(vendor Vendor, op string) error { + return fmt.Errorf( + "operation %q is not supported by the %q Redfish client without vendor-specific handling; "+ + "a vendor plugin under pkg/bmc/vendors is required (please file an issue or submit a PR)", + op, vendor, + ) +} diff --git a/pkg/bmc/conn.go b/pkg/bmc/conn.go new file mode 100644 index 00000000..6a4b667c --- /dev/null +++ b/pkg/bmc/conn.go @@ -0,0 +1,34 @@ +package bmc + +import ( + "fmt" + + "github.com/OpenCHAMI/magellan/pkg/secrets" +) + +// ConnConfig holds everything needed to open a session to a single BMC. +// +// It is the canonical connection-configuration type for the BMC interaction +// layer. The crawler package aliases its CrawlerConfig to this type so existing +// callers continue to compile unchanged. +type ConnConfig struct { + URI string // URI of the BMC (e.g. https://10.0.0.1) + Insecure bool // Whether to ignore TLS verification errors + CredentialStore secrets.SecretStore // Source of BMC credentials + UseDefault bool // Retained for compatibility with existing callers +} + +// GetUserPass resolves the BMC credentials for this connection from the +// configured secret store, falling back to the default credentials when no +// host-specific entry exists. It returns an error when the store is missing or +// the resolved credentials are blank. +func (c ConnConfig) GetUserPass() (BMCCredentials, error) { + if c.CredentialStore == nil { + return BMCCredentials{}, fmt.Errorf("credential store is invalid") + } + creds := GetBMCCredentialsOrDefault(c.CredentialStore, c.URI) + if creds == (BMCCredentials{}) { + return creds, fmt.Errorf("%s: credentials blank for BMC", c.URI) + } + return creds, nil +} diff --git a/pkg/bmc/manager.go b/pkg/bmc/manager.go new file mode 100644 index 00000000..7dd1cc7d --- /dev/null +++ b/pkg/bmc/manager.go @@ -0,0 +1,99 @@ +package bmc + +import ( + "fmt" + "strings" + "sync" + + "github.com/rs/zerolog/log" + "github.com/stmcginnis/gofish" +) + +// Manager is the single authority for opening sessions to BMCs. It owns the one +// place where gofish.Connect is called, performs vendor detection, and maintains +// an optional per-URI connection cache so callers can reuse open sessions. +type Manager struct { + mu sync.Mutex + cache map[string]Client +} + +// NewManager returns an empty Manager. +func NewManager() *Manager { + return &Manager{cache: make(map[string]Client)} +} + +// DefaultManager is the process-wide Manager used by the CLI call sites. The +// daemon may construct its own Manager instead. +var DefaultManager = NewManager() + +// Connect opens a new, uncached gofish session to the BMC described by cfg. This +// is the single point in the codebase where gofish.Connect is invoked; all 404 +// and 401 errors are decorated here for consistent messaging. +func (m *Manager) Connect(cfg ConnConfig) (*gofish.APIClient, error) { + creds, err := cfg.GetUserPass() + if err != nil { + log.Error().Err(err).Msg("failed to load BMC credentials") + return nil, err + } + + api, err := gofish.Connect(gofish.ClientConfig{ + Endpoint: cfg.URI, + Username: creds.Username, + Password: creds.Password, + Insecure: cfg.Insecure, + BasicAuth: true, + }) + if err != nil { + if strings.HasPrefix(err.Error(), "404:") { + err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", cfg.URI) + } + if strings.HasPrefix(err.Error(), "401:") { + err = fmt.Errorf("authentication failed. Check your username and password: %s", cfg.URI) + } + log.Error().Err(err).Msg("failed to connect to BMC") + return nil, err + } + return api, nil +} + +// Client opens a new, uncached session and wraps it in a vendor-aware Client. +// The caller is responsible for calling Logout on the returned Client. +func (m *Manager) Client(cfg ConnConfig) (Client, error) { + api, err := m.Connect(cfg) + if err != nil { + return nil, err + } + return clientFor(api), nil +} + +// CachedClient returns a vendor-aware Client for the BMC, creating and caching a +// new session keyed by URI if one does not already exist. Cached sessions are +// kept open for efficiency and released together by LogoutAll. +func (m *Manager) CachedClient(cfg ConnConfig) (Client, error) { + m.mu.Lock() + defer m.mu.Unlock() + if c, ok := m.cache[cfg.URI]; ok { + log.Debug().Msgf("found existing client for %s", cfg.URI) + return c, nil + } + api, err := m.Connect(cfg) + if err != nil { + return nil, err + } + c := clientFor(api) + m.cache[cfg.URI] = c + log.Debug().Msgf("created new client for %s", cfg.URI) + return c, nil +} + +// LogoutAll logs out and evicts every cached session. It should be called as a +// post-execution cleanup step. +func (m *Manager) LogoutAll() { + m.mu.Lock() + defer m.mu.Unlock() + for uri, c := range m.cache { + log.Debug().Msgf("logging out client for %s", uri) + c.Logout() + delete(m.cache, uri) + } +} diff --git a/pkg/bmc/registry.go b/pkg/bmc/registry.go new file mode 100644 index 00000000..c1db526d --- /dev/null +++ b/pkg/bmc/registry.go @@ -0,0 +1,58 @@ +package bmc + +import ( + "sync" + + "github.com/rs/zerolog/log" + "github.com/stmcginnis/gofish" +) + +// Detector reports whether a connected BMC is handled by a particular vendor +// plugin, based on its Redfish ServiceRoot metadata. +type Detector func(api *gofish.APIClient) bool + +// Factory builds a vendor-specific Client around a connected gofish client. +type Factory func(api *gofish.APIClient) Client + +type vendorPlugin struct { + detect Detector + factory Factory +} + +var ( + registryMu sync.RWMutex + registry = make(map[Vendor]vendorPlugin) +) + +// RegisterVendor registers a vendor plugin's detector and factory. Vendor +// plugins call this from their init() functions; the aggregator package +// (pkg/bmc/vendors) is blank-imported to trigger registration. +func RegisterVendor(vendor Vendor, detect Detector, factory Factory) { + registryMu.Lock() + defer registryMu.Unlock() + registry[vendor] = vendorPlugin{detect: detect, factory: factory} +} + +// clientFor wraps a connected gofish client in the most specific registered +// vendor Client whose detector matches, falling back to a GenericClient using +// the ServiceRoot Vendor string when no plugin matches. +func clientFor(api *gofish.APIClient) Client { + registryMu.RLock() + defer registryMu.RUnlock() + for vendor, plugin := range registry { + if plugin.detect != nil && plugin.detect(api) { + log.Debug().Str("vendor", string(vendor)).Msg("matched vendor plugin for BMC") + return plugin.factory(api) + } + } + return NewGenericClient(api, detectVendorString(api)) +} + +// detectVendorString returns the vendor reported by the Redfish ServiceRoot, or +// VendorGeneric when it is unavailable. +func detectVendorString(api *gofish.APIClient) Vendor { + if api == nil || api.Service == nil || api.Service.Vendor == "" { + return VendorGeneric + } + return Vendor(api.Service.Vendor) +} diff --git a/pkg/bmc/vendors/cray/cray.go b/pkg/bmc/vendors/cray/cray.go new file mode 100644 index 00000000..8392b436 --- /dev/null +++ b/pkg/bmc/vendors/cray/cray.go @@ -0,0 +1,32 @@ +// Package cray provides Cray/HPE-Cray-specific BMC handling. For now it +// registers a detector and a Client that embeds the generic Redfish client; +// Cray-specific quirks are implemented here as the abstraction grows. +package cray + +import ( + "strings" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" +) + +func init() { + bmc.RegisterVendor(bmc.VendorCray, detect, newClient) +} + +// detect reports whether the BMC's Redfish ServiceRoot identifies it as Cray. +func detect(api *gofish.APIClient) bool { + if api == nil || api.Service == nil { + return false + } + return strings.Contains(strings.ToLower(api.Service.Vendor), "cray") +} + +func newClient(api *gofish.APIClient) bmc.Client { + return &Client{GenericClient: bmc.NewGenericClient(api, bmc.VendorCray)} +} + +// Client adds Cray-specific quirk handling on top of the generic Redfish client. +type Client struct { + *bmc.GenericClient +} diff --git a/pkg/bmc/vendors/dell/dell.go b/pkg/bmc/vendors/dell/dell.go new file mode 100644 index 00000000..e4f22b2c --- /dev/null +++ b/pkg/bmc/vendors/dell/dell.go @@ -0,0 +1,33 @@ +// Package dell provides Dell/iDRAC-specific BMC handling. For now it registers a +// detector and a Client that embeds the generic Redfish client; Dell-specific +// quirks (firmware UpdateService actions, BIOS Oem.Dell namespace, ETag handling +// on power operations) are implemented here as the abstraction grows. +package dell + +import ( + "strings" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" +) + +func init() { + bmc.RegisterVendor(bmc.VendorDell, detect, newClient) +} + +// detect reports whether the BMC's Redfish ServiceRoot identifies it as Dell. +func detect(api *gofish.APIClient) bool { + if api == nil || api.Service == nil { + return false + } + return strings.Contains(strings.ToLower(api.Service.Vendor), "dell") +} + +func newClient(api *gofish.APIClient) bmc.Client { + return &Client{GenericClient: bmc.NewGenericClient(api, bmc.VendorDell)} +} + +// Client adds Dell-specific quirk handling on top of the generic Redfish client. +type Client struct { + *bmc.GenericClient +} diff --git a/pkg/bmc/vendors/hpe/hpe.go b/pkg/bmc/vendors/hpe/hpe.go new file mode 100644 index 00000000..2fef0ed9 --- /dev/null +++ b/pkg/bmc/vendors/hpe/hpe.go @@ -0,0 +1,34 @@ +// Package hpe provides HPE/iLO-specific BMC handling. For now it registers a +// detector and a Client that embeds the generic Redfish client; HPE-specific +// quirks (firmware UpdateService actions, BIOS Oem.Hpe namespace, manager +// metadata layout) are implemented here as the abstraction grows. +package hpe + +import ( + "strings" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" +) + +func init() { + bmc.RegisterVendor(bmc.VendorHPE, detect, newClient) +} + +// detect reports whether the BMC's Redfish ServiceRoot identifies it as HPE. +func detect(api *gofish.APIClient) bool { + if api == nil || api.Service == nil { + return false + } + vendor := strings.ToLower(api.Service.Vendor) + return strings.Contains(vendor, "hpe") || strings.Contains(vendor, "hewlett") +} + +func newClient(api *gofish.APIClient) bmc.Client { + return &Client{GenericClient: bmc.NewGenericClient(api, bmc.VendorHPE)} +} + +// Client adds HPE-specific quirk handling on top of the generic Redfish client. +type Client struct { + *bmc.GenericClient +} diff --git a/pkg/bmc/vendors/lenovo/lenovo.go b/pkg/bmc/vendors/lenovo/lenovo.go new file mode 100644 index 00000000..122865af --- /dev/null +++ b/pkg/bmc/vendors/lenovo/lenovo.go @@ -0,0 +1,32 @@ +// Package lenovo provides Lenovo/XCC-specific BMC handling. For now it registers +// a detector and a Client that embeds the generic Redfish client; +// Lenovo-specific quirks are implemented here as the abstraction grows. +package lenovo + +import ( + "strings" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" +) + +func init() { + bmc.RegisterVendor(bmc.VendorLenovo, detect, newClient) +} + +// detect reports whether the BMC's Redfish ServiceRoot identifies it as Lenovo. +func detect(api *gofish.APIClient) bool { + if api == nil || api.Service == nil { + return false + } + return strings.Contains(strings.ToLower(api.Service.Vendor), "lenovo") +} + +func newClient(api *gofish.APIClient) bmc.Client { + return &Client{GenericClient: bmc.NewGenericClient(api, bmc.VendorLenovo)} +} + +// Client adds Lenovo-specific quirk handling on top of the generic Redfish client. +type Client struct { + *bmc.GenericClient +} diff --git a/pkg/bmc/vendors/supermicro/supermicro.go b/pkg/bmc/vendors/supermicro/supermicro.go new file mode 100644 index 00000000..c54b7916 --- /dev/null +++ b/pkg/bmc/vendors/supermicro/supermicro.go @@ -0,0 +1,34 @@ +// Package supermicro provides Supermicro-specific BMC handling. For now it +// registers a detector and a Client that embeds the generic Redfish client; +// Supermicro-specific quirks (BIOS Oem.Supermicro namespace, firmware update +// shapes) are implemented here as the abstraction grows. +package supermicro + +import ( + "strings" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" +) + +func init() { + bmc.RegisterVendor(bmc.VendorSupermicro, detect, newClient) +} + +// detect reports whether the BMC's Redfish ServiceRoot identifies it as Supermicro. +func detect(api *gofish.APIClient) bool { + if api == nil || api.Service == nil { + return false + } + vendor := strings.ToLower(api.Service.Vendor) + return strings.Contains(vendor, "supermicro") || strings.Contains(vendor, "super micro") +} + +func newClient(api *gofish.APIClient) bmc.Client { + return &Client{GenericClient: bmc.NewGenericClient(api, bmc.VendorSupermicro)} +} + +// Client adds Supermicro-specific quirk handling on top of the generic Redfish client. +type Client struct { + *bmc.GenericClient +} diff --git a/pkg/bmc/vendors/vendors.go b/pkg/bmc/vendors/vendors.go new file mode 100644 index 00000000..05dc21c4 --- /dev/null +++ b/pkg/bmc/vendors/vendors.go @@ -0,0 +1,15 @@ +// Package vendors blank-imports all in-tree BMC vendor plugins so that their +// init() registrations run. Import this package for its side effects: +// +// import _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors" +// +// Vendors with no matching plugin fall back to the generic Redfish client. +package vendors + +import ( + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors/cray" + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors/dell" + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors/hpe" + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors/lenovo" + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors/supermicro" +) diff --git a/pkg/collect.go b/pkg/collect.go index 3ccb8a5d..dad56962 100644 --- a/pkg/collect.go +++ b/pkg/collect.go @@ -21,7 +21,6 @@ import ( "github.com/rs/zerolog/log" _ "github.com/mattn/go-sqlite3" - "github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish/redfish" "golang.org/x/exp/slices" ) @@ -256,28 +255,9 @@ func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string // gofish (at least for now). If there's a need for grabbing more // manager information in the future, we can move the logic into // the crawler. - bmc_creds, err := config.GetUserPass() + // Open a session through the shared BMC manager (the single gofish.Connect site). + client, err := bmc.DefaultManager.Connect(config) if err != nil { - return "", fmt.Errorf("failed to get credentials for URI: %s", config.URI) - } - - client, err := gofish.Connect(gofish.ClientConfig{ - Endpoint: config.URI, - Username: bmc_creds.Username, - Password: bmc_creds.Password, - Insecure: config.Insecure, - BasicAuth: true, - }) - if err != nil { - if strings.HasPrefix(err.Error(), "404:") { - err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", config.URI) - } - if strings.HasPrefix(err.Error(), "401:") { - err = fmt.Errorf("authentication failed. Check your username and password: %s", config.URI) - } - event := log.Error() - event.Err(err) - event.Msg("failed to connect to BMC") return "", err } defer client.Logout() diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index 28d519ef..0516fbd6 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -2,26 +2,17 @@ package crawler import ( "fmt" - "strings" - "github.com/OpenCHAMI/magellan/internal/util" "github.com/OpenCHAMI/magellan/pkg/bmc" - "github.com/OpenCHAMI/magellan/pkg/secrets" "github.com/rs/zerolog/log" "github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish/redfish" ) -type CrawlerConfig struct { - URI string // URI of the BMC - Insecure bool // Whether to ignore SSL errors - CredentialStore secrets.SecretStore - UseDefault bool -} - -func (cc *CrawlerConfig) GetUserPass() (bmc.BMCCredentials, error) { - return loadBMCCreds(*cc) -} +// CrawlerConfig is an alias for bmc.ConnConfig, the canonical BMC connection +// configuration. It is retained for backwards compatibility with existing +// callers and tests; its GetUserPass method is defined on bmc.ConnConfig. +type CrawlerConfig = bmc.ConnConfig type EthernetInterface struct { URI string `json:"uri,omitempty"` // URI of the interface @@ -128,36 +119,10 @@ type InventoryDetail struct { // 3. Handles specific connection errors such as 404 (ServiceRoot not found) and 401 (authentication failed). // 4. Returns the active gofish client. func GetBMCClient(config CrawlerConfig) (*gofish.APIClient, error) { - // get username and password from secret store - bmc_creds, err := loadBMCCreds(config) - if err != nil { - event := log.Error() - event.Err(err) - event.Msg("failed to load BMC credentials") - return nil, err - } - - // initialize gofish client - client, err := gofish.Connect(gofish.ClientConfig{ - Endpoint: config.URI, - Username: bmc_creds.Username, - Password: bmc_creds.Password, - Insecure: config.Insecure, - BasicAuth: true, - }) - if err != nil { - if strings.HasPrefix(err.Error(), "404:") { - err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", config.URI) - } - if strings.HasPrefix(err.Error(), "401:") { - err = fmt.Errorf("authentication failed. Check your username and password: %s", config.URI) - } - event := log.Error() - event.Err(err) - event.Msg("failed to connect to BMC") - return nil, err - } - return client, nil + // Delegate to the shared BMC manager, which is the single point where + // gofish.Connect is called and where credential loading and error + // decoration happen. + return bmc.DefaultManager.Connect(config) } // CrawlBMCForSystems pulls all pertinent information from a BMC. @@ -545,18 +510,6 @@ func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, er // } -func loadBMCCreds(config CrawlerConfig) (bmc.BMCCredentials, error) { - // NOTE: it is possible for the SecretStore to be nil, so we need a check - if config.CredentialStore == nil { - return bmc.BMCCredentials{}, fmt.Errorf("credential store is invalid") - } - if creds := util.GetBMCCredentials(config.CredentialStore, config.URI); creds == (bmc.BMCCredentials{}) { - return creds, fmt.Errorf("%s: credentials blank for BMC", config.URI) - } else { - return creds, nil - } -} - func extractPtrMapValues[T any](m map[string]*T) []T { slice := make([]T, 0, len(m)) for i := range m { diff --git a/pkg/power/power.go b/pkg/power/power.go index 52cc2806..78535364 100644 --- a/pkg/power/power.go +++ b/pkg/power/power.go @@ -26,9 +26,6 @@ type PowerInfo struct { State redfish.PowerState } -// Hold onto the current set of open clients, so we don't continually have to log into and out of BMCs -var savedClients map[string]*gofish.APIClient - // ParseInventory reads parameters relevant to power control from the kind of YAML file generated by the `collect` command. // // Parameters: @@ -104,25 +101,12 @@ func ParseInventory(filename string, dataFormat format.DataFormat) ([]bmc.Node, func GetResetTypes(node CrawlableNode) ([]redfish.ResetType, error) { log.Debug().Msgf("polling %s for reset types", node.ConnConfig.URI) - // Obtain an active client - client, err := GetBMCSession(node.ConnConfig) + // Obtain an active (cached) vendor-aware client + client, err := bmc.DefaultManager.CachedClient(node.ConnConfig) if err != nil { return nil, err } - - // Determine reset types for the target computer system - rf_systems, err := client.GetService().Systems() - if err != nil { - return nil, err - } - var system *redfish.ComputerSystem - for i := range rf_systems { - if rf_systems[i].ID == node.NodeID { - system = rf_systems[i] - break - } - } - return system.SupportedResetTypes, nil + return client.GetResetTypes(node.NodeID) } // PollBMCPowerStates connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -137,25 +121,12 @@ func GetResetTypes(node CrawlableNode) ([]redfish.ResetType, error) { func GetPowerState(node CrawlableNode) (redfish.PowerState, error) { log.Debug().Msgf("polling %s for power states", node.ConnConfig.URI) - // Obtain an active client - client, err := GetBMCSession(node.ConnConfig) + // Obtain an active (cached) vendor-aware client + client, err := bmc.DefaultManager.CachedClient(node.ConnConfig) if err != nil { return "", err } - - // Determine power details for the target computer system - rf_systems, err := client.GetService().Systems() - if err != nil { - return "", err - } - var system *redfish.ComputerSystem - for i := range rf_systems { - if rf_systems[i].ID == node.NodeID { - system = rf_systems[i] - break - } - } - return system.PowerState, nil + return client.GetPowerState(node.NodeID) } // ResetComputerSystem connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -170,31 +141,14 @@ func GetPowerState(node CrawlableNode) (redfish.PowerState, error) { func ResetComputerSystem(node CrawlableNode, resetType redfish.ResetType) error { log.Debug().Msgf("resetting computer system %s: %s", node.ClusterID, resetType) - client, err := crawler.GetBMCClient(node.ConnConfig) + // Use a fresh (uncached) vendor-aware client and log out when done. + client, err := bmc.DefaultManager.Client(node.ConnConfig) if err != nil { return err } defer client.Logout() - // Obtain the ServiceRoot - rf_service := client.GetService() - log.Debug().Msgf("found ServiceRoot %s. Redfish Version %s", rf_service.ID, rf_service.RedfishVersion) - - // Select the relevant ComputerSystem - rf_systems, err := rf_service.Systems() - if err != nil { - return err - } - var rf_compsys *redfish.ComputerSystem - for i := range rf_systems { - if rf_systems[i].ID == node.NodeID { - rf_compsys = rf_systems[i] - break - } - } - - // Reset the system - return rf_compsys.Reset(resetType) + return client.Reset(node.NodeID, resetType) } // GetBMCSession returns an already-active gofish BMC client, creating a new one if necessary. @@ -205,22 +159,11 @@ func ResetComputerSystem(node CrawlableNode, resetType redfish.ResetType) error // // Returns: none. func GetBMCSession(config crawler.CrawlerConfig) (*gofish.APIClient, error) { - client, exists := savedClients[config.URI] - if exists { - log.Debug().Msgf("found existing client for %s", config.URI) - } else { - if savedClients == nil { - savedClients = make(map[string]*gofish.APIClient) - } - var err error - client, err = crawler.GetBMCClient(config) - if err != nil { - return nil, err - } - log.Debug().Msgf("created new client for %s", config.URI) - savedClients[config.URI] = client + client, err := bmc.DefaultManager.CachedClient(config) + if err != nil { + return nil, err } - return client, nil + return client.Gofish(), nil } // LogoutBMCSessions logs out all active gofish BMC clients, which we normally like to keep open for efficiency. @@ -230,8 +173,5 @@ func GetBMCSession(config crawler.CrawlerConfig) (*gofish.APIClient, error) { // // Returns: none. func LogoutBMCSessions() { - for uri, client := range savedClients { - log.Debug().Msgf("logging out client for %s", uri) - client.Logout() - } + bmc.DefaultManager.LogoutAll() } diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 00000000..9c12cc67 --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,106 @@ +// Package service provides an in-process façade over magellan's BMC operations. +// +// Both the CLI and the (forthcoming) daemon are intended to drive BMC work +// through this single shared core so behavior stays consistent across +// front-ends. It composes the existing magellan, crawler, power, and bmc +// packages; it deliberately holds no logic of its own beyond wiring. +package service + +import ( + "github.com/OpenCHAMI/magellan/internal/format" + magellan "github.com/OpenCHAMI/magellan/pkg" + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/OpenCHAMI/magellan/pkg/crawler" + "github.com/OpenCHAMI/magellan/pkg/power" + "github.com/OpenCHAMI/magellan/pkg/secrets" + "github.com/stmcginnis/gofish/redfish" +) + +// Service is the shared BMC interaction core. A single instance is intended to +// be long-lived (owned by the daemon) or short-lived (constructed per CLI +// invocation); it is safe for concurrent use because the underlying Manager is. +type Service struct { + // Manager is the BMC connection authority. Defaults to bmc.DefaultManager. + Manager *bmc.Manager + // Secrets is the credential store used to resolve BMC credentials. + Secrets secrets.SecretStore + // Insecure controls whether TLS verification is skipped when connecting. + Insecure bool +} + +// New constructs a Service backed by the process-wide BMC manager and the given +// credential store. +func New(store secrets.SecretStore) *Service { + return &Service{ + Manager: bmc.DefaultManager, + Secrets: store, + } +} + +// connConfig builds a connection config for a BMC URI using the service's +// credential store and TLS settings. +func (s *Service) connConfig(uri string) crawler.CrawlerConfig { + return crawler.CrawlerConfig{ + URI: uri, + Insecure: s.Insecure, + CredentialStore: s.Secrets, + UseDefault: true, + } +} + +// Discover scans the network for BMC (and optionally PDU) assets. +func (s *Service) Discover(params *magellan.ScanParams) []magellan.RemoteAsset { + return magellan.ScanForAssets(params) +} + +// Collect crawls the given assets for Redfish inventory. SecretStore on params +// is filled from the service when unset. +func (s *Service) Collect(assets []magellan.RemoteAsset, params *magellan.CollectParams) ([]map[string]any, error) { + if params.SecretStore == nil { + params.SecretStore = s.Secrets + } + return magellan.CollectInventory(&assets, params) +} + +// Inventory returns the Redfish systems and managers for a single BMC. +func (s *Service) Inventory(uri string) ([]crawler.InventoryDetail, []crawler.Manager, error) { + cfg := s.connConfig(uri) + systems, err := crawler.CrawlBMCForSystems(cfg) + if err != nil { + return systems, nil, err + } + managers, err := crawler.CrawlBMCForManagers(cfg) + return systems, managers, err +} + +// crawlableNode adapts a (BMC URI, system ID) pair to power's node type. +func (s *Service) crawlableNode(uri, systemID string) power.CrawlableNode { + return power.CrawlableNode{ + ConnConfig: s.connConfig(uri), + NodeID: systemID, + } +} + +// PowerState returns the current power state of a ComputerSystem. +func (s *Service) PowerState(uri, systemID string) (redfish.PowerState, error) { + return power.GetPowerState(s.crawlableNode(uri, systemID)) +} + +// ResetTypes returns the reset types supported by a ComputerSystem. +func (s *Service) ResetTypes(uri, systemID string) ([]redfish.ResetType, error) { + return power.GetResetTypes(s.crawlableNode(uri, systemID)) +} + +// Reset issues a reset of the given type to a ComputerSystem. +func (s *Service) Reset(uri, systemID string, resetType redfish.ResetType) error { + return power.ResetComputerSystem(s.crawlableNode(uri, systemID), resetType) +} + +// Close releases any cached BMC sessions held by the manager. +func (s *Service) Close() { + s.Manager.LogoutAll() +} + +// Format is re-exported for callers that need to reference data formats without +// importing the internal package directly. +type Format = format.DataFormat From c1e04bd58db2ca6bec55663138a6bf10789d5ad9 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 12 Jun 2026 03:54:09 -0400 Subject: [PATCH 2/2] feat: add unit tests for BMC client and connection handling Signed-off-by: Alex Lovell-Troy --- pkg/bmc/client_test.go | 98 +++++++++++++++++++++++++++++ pkg/bmc/conn_test.go | 39 ++++++++++++ pkg/bmc/manager_test.go | 133 +++++++++++++++++++++++++++++++++++++++ pkg/bmc/registry_test.go | 62 ++++++++++++++++++ pkg/test/constants.go | 40 ++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 pkg/bmc/client_test.go create mode 100644 pkg/bmc/conn_test.go create mode 100644 pkg/bmc/manager_test.go create mode 100644 pkg/bmc/registry_test.go diff --git a/pkg/bmc/client_test.go b/pkg/bmc/client_test.go new file mode 100644 index 00000000..447d1cc9 --- /dev/null +++ b/pkg/bmc/client_test.go @@ -0,0 +1,98 @@ +package bmc + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/go-chi/chi/v5" + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/redfish" +) + +// newMockGenericClient stands up an in-memory Redfish service exposing a single +// ComputerSystem (Node0) and returns a GenericClient connected to it. The server +// is torn down automatically when the test finishes. +func newMockGenericClient(t *testing.T) *GenericClient { + t.Helper() + mux := chi.NewMux() + // gofish requests the service root at "/redfish/v1/"; register the + // no-trailing-slash form too for safety. + mux.HandleFunc("/redfish/v1/", test.Make(test.RESPONSE_ServiceRoot)) + mux.HandleFunc("/redfish/v1", test.Make(test.RESPONSE_ServiceRoot)) + mux.HandleFunc("/redfish/v1/Systems", test.Make(test.RESPONSE_Systems)) + mux.HandleFunc("/redfish/v1/Systems/Node0", test.Make(test.RESPONSE_System_Node0)) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + api, err := gofish.Connect(gofish.ClientConfig{ + Endpoint: srv.URL, + Username: "test", + Password: "test", + Insecure: true, + BasicAuth: true, + }) + if err != nil { + t.Fatalf("failed to connect to mock Redfish service: %v", err) + } + t.Cleanup(api.Logout) + return NewGenericClient(api, VendorGeneric) +} + +func TestGenericClientPowerStateFound(t *testing.T) { + c := newMockGenericClient(t) + state, err := c.GetPowerState("Node0") + if err != nil { + t.Fatalf("GetPowerState(Node0) unexpected error: %v", err) + } + if state != redfish.OnPowerState { + t.Fatalf("GetPowerState(Node0) = %q, want %q", state, redfish.OnPowerState) + } +} + +func TestGenericClientResetTypesFound(t *testing.T) { + c := newMockGenericClient(t) + got, err := c.GetResetTypes("Node0") + if err != nil { + t.Fatalf("GetResetTypes(Node0) unexpected error: %v", err) + } + want := map[redfish.ResetType]bool{ + redfish.OnResetType: true, + redfish.ForceOffResetType: true, + redfish.GracefulShutdownResetType: true, + redfish.ForceRestartResetType: true, + } + if len(got) != len(want) { + t.Fatalf("GetResetTypes(Node0) = %v, want %d types", got, len(want)) + } + for _, rt := range got { + if !want[rt] { + t.Fatalf("GetResetTypes(Node0) returned unexpected type %q", rt) + } + } +} + +// TestGenericClientSystemNotFound guards the deliberate "return an error, do not +// panic" behavior of systemByID for an unknown system ID. The pre-refactor code +// dereferenced a nil system here. +func TestGenericClientSystemNotFound(t *testing.T) { + c := newMockGenericClient(t) + + if _, err := c.GetPowerState("bogus"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("GetPowerState(bogus) err = %v, want a 'not found' error", err) + } + if _, err := c.GetResetTypes("bogus"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("GetResetTypes(bogus) err = %v, want a 'not found' error", err) + } + if err := c.Reset("bogus", redfish.OnResetType); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("Reset(bogus) err = %v, want a 'not found' error", err) + } +} + +func TestNewGenericClientDefaultsVendor(t *testing.T) { + if v := NewGenericClient(nil, "").Vendor(); v != VendorGeneric { + t.Fatalf("NewGenericClient empty vendor = %q, want %q", v, VendorGeneric) + } +} diff --git a/pkg/bmc/conn_test.go b/pkg/bmc/conn_test.go new file mode 100644 index 00000000..5a540e57 --- /dev/null +++ b/pkg/bmc/conn_test.go @@ -0,0 +1,39 @@ +package bmc + +import ( + "strings" + "testing" + + "github.com/OpenCHAMI/magellan/pkg/secrets" +) + +func TestGetUserPass(t *testing.T) { + t.Run("nil store errors", func(t *testing.T) { + cfg := ConnConfig{URI: "https://bmc.example", CredentialStore: nil} + _, err := cfg.GetUserPass() + if err == nil || !strings.Contains(err.Error(), "credential store is invalid") { + t.Fatalf("GetUserPass with nil store err = %v, want 'credential store is invalid'", err) + } + }) + + t.Run("blank credentials error", func(t *testing.T) { + // A store that resolves to empty username/password must be reported as an + // error rather than silently producing an unauthenticated connection. + cfg := ConnConfig{URI: "https://bmc.example", CredentialStore: secrets.NewStaticStore("", "")} + _, err := cfg.GetUserPass() + if err == nil || !strings.Contains(err.Error(), "credentials blank") { + t.Fatalf("GetUserPass with blank creds err = %v, want 'credentials blank'", err) + } + }) + + t.Run("valid credentials returned", func(t *testing.T) { + cfg := ConnConfig{URI: "https://bmc.example", CredentialStore: secrets.NewStaticStore("alice", "s3cret")} + creds, err := cfg.GetUserPass() + if err != nil { + t.Fatalf("GetUserPass unexpected error: %v", err) + } + if creds.Username != "alice" || creds.Password != "s3cret" { + t.Fatalf("GetUserPass creds = %+v, want {alice s3cret}", creds) + } + }) +} diff --git a/pkg/bmc/manager_test.go b/pkg/bmc/manager_test.go new file mode 100644 index 00000000..07fccfac --- /dev/null +++ b/pkg/bmc/manager_test.go @@ -0,0 +1,133 @@ +package bmc_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + // Blank-import the vendor plugins so their init() registrations run; this is + // what wires up vendor detection for the dispatch test below. Living in the + // external bmc_test package avoids the import cycle a same-package test would + // hit (vendors imports bmc). + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors" + "github.com/OpenCHAMI/magellan/pkg/secrets" + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/go-chi/chi/v5" +) + +// mockServer stands up a Redfish service root serving the given document and +// returns a ConnConfig pointed at it with working static credentials. +func mockServer(t *testing.T, serviceRoot string) bmc.ConnConfig { + t.Helper() + mux := chi.NewMux() + mux.HandleFunc("/redfish/v1/", test.Make(serviceRoot)) + mux.HandleFunc("/redfish/v1", test.Make(serviceRoot)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return bmc.ConnConfig{ + URI: srv.URL, + Insecure: true, + CredentialStore: secrets.NewStaticStore("test", "test"), + UseDefault: true, + } +} + +func TestManagerCachedClientReusesSession(t *testing.T) { + cfg := mockServer(t, test.RESPONSE_ServiceRoot) + m := bmc.NewManager() + + c1, err := m.CachedClient(cfg) + if err != nil { + t.Fatalf("CachedClient: %v", err) + } + c2, err := m.CachedClient(cfg) + if err != nil { + t.Fatalf("CachedClient (2nd): %v", err) + } + if c1 != c2 { + t.Fatal("CachedClient returned a different client for the same URI; cache miss") + } + + // LogoutAll must evict, so the next CachedClient builds a fresh session. + m.LogoutAll() + c3, err := m.CachedClient(cfg) + if err != nil { + t.Fatalf("CachedClient after LogoutAll: %v", err) + } + if c1 == c3 { + t.Fatal("CachedClient returned the evicted client after LogoutAll") + } +} + +func TestManagerClientIsUncached(t *testing.T) { + cfg := mockServer(t, test.RESPONSE_ServiceRoot) + m := bmc.NewManager() + + c1, err := m.Client(cfg) + if err != nil { + t.Fatalf("Client: %v", err) + } + c2, err := m.Client(cfg) + if err != nil { + t.Fatalf("Client (2nd): %v", err) + } + if c1 == c2 { + t.Fatal("Client returned the same client twice; it must not cache") + } +} + +// TestManagerDispatchesToVendorPlugin proves the full init()-registration → +// detect → factory chain: a ServiceRoot advertising "HPE" must be served by the +// HPE plugin, not the generic fallback. +func TestManagerDispatchesToVendorPlugin(t *testing.T) { + cfg := mockServer(t, test.RESPONSE_ServiceRoot_HPE) + c, err := bmc.NewManager().Client(cfg) + if err != nil { + t.Fatalf("Client: %v", err) + } + if c.Vendor() != bmc.VendorHPE { + t.Fatalf("dispatched client Vendor = %q, want %q", c.Vendor(), bmc.VendorHPE) + } +} + +func TestManagerConnectDecoratesErrors(t *testing.T) { + cases := []struct { + name string + status int + want string + }{ + {"404 not a BMC", http.StatusNotFound, "no ServiceRoot found"}, + {"401 auth failed", http.StatusUnauthorized, "authentication failed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mux := chi.NewMux() + mux.HandleFunc("/redfish/v1/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", tc.status) + }) + mux.HandleFunc("/redfish/v1", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", tc.status) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cfg := bmc.ConnConfig{URI: srv.URL, Insecure: true, CredentialStore: secrets.NewStaticStore("test", "test")} + _, err := bmc.NewManager().Connect(cfg) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Connect err = %v, want it to contain %q", err, tc.want) + } + }) + } +} + +func TestManagerConnectNilStoreFailsFast(t *testing.T) { + // No server: a nil credential store must fail during credential resolution, + // before any network call is attempted. + cfg := bmc.ConnConfig{URI: "https://unreachable.invalid", CredentialStore: nil} + _, err := bmc.NewManager().Connect(cfg) + if err == nil || !strings.Contains(err.Error(), "credential store is invalid") { + t.Fatalf("Connect with nil store err = %v, want 'credential store is invalid'", err) + } +} diff --git a/pkg/bmc/registry_test.go b/pkg/bmc/registry_test.go new file mode 100644 index 00000000..490471ab --- /dev/null +++ b/pkg/bmc/registry_test.go @@ -0,0 +1,62 @@ +package bmc + +import ( + "testing" + + "github.com/stmcginnis/gofish" +) + +// serviceWithVendor builds a minimal gofish client whose ServiceRoot reports the +// given vendor string, without touching the network. +func serviceWithVendor(vendor string) *gofish.APIClient { + return &gofish.APIClient{Service: &gofish.Service{Vendor: vendor}} +} + +func TestClientForMatchingDetector(t *testing.T) { + // Register a sentinel plugin that only matches a vendor string no real + // plugin (registered via the vendors blank-import in this test binary) or + // other test would ever produce, so it cannot interfere with other cases. + const sentinel = "ZZZ-TEST-VENDOR" + RegisterVendor("zzz-test", func(api *gofish.APIClient) bool { + return api != nil && api.Service != nil && api.Service.Vendor == sentinel + }, func(api *gofish.APIClient) Client { + return NewGenericClient(api, "zzz-test") + }) + + got := clientFor(serviceWithVendor(sentinel)) + if got.Vendor() != "zzz-test" { + t.Fatalf("clientFor selected vendor %q, want the registered sentinel plugin %q", got.Vendor(), "zzz-test") + } +} + +func TestClientForFallsBackToGeneric(t *testing.T) { + // "ACME" matches no registered detector, so clientFor must fall back to a + // GenericClient carrying the ServiceRoot vendor string verbatim. + got := clientFor(serviceWithVendor("ACME")) + if got.Vendor() != Vendor("ACME") { + t.Fatalf("clientFor fallback vendor = %q, want %q", got.Vendor(), "ACME") + } + if _, ok := got.(*GenericClient); !ok { + t.Fatalf("clientFor fallback returned %T, want *GenericClient", got) + } +} + +func TestDetectVendorString(t *testing.T) { + cases := []struct { + name string + api *gofish.APIClient + want Vendor + }{ + {"nil client", nil, VendorGeneric}, + {"nil service", &gofish.APIClient{}, VendorGeneric}, + {"empty vendor", &gofish.APIClient{Service: &gofish.Service{}}, VendorGeneric}, + {"populated vendor", serviceWithVendor("Dell Inc."), Vendor("Dell Inc.")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := detectVendorString(tc.api); got != tc.want { + t.Fatalf("detectVendorString = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/pkg/test/constants.go b/pkg/test/constants.go index 7c4ad4c9..cecc6a78 100644 --- a/pkg/test/constants.go +++ b/pkg/test/constants.go @@ -126,5 +126,45 @@ const ( ], "Members@odata.count": 1, "Name": "Systems Collection" +}` + // RESPONSE_System_Node0 is a single ComputerSystem detail document for the + // Node0 member of RESPONSE_Systems. Unlike RESPONSE_EthernetInterface it + // advertises a concrete set of allowable reset types, so tests that exercise + // GetResetTypes/GetPowerState/Reset have a deterministic system to assert on. + RESPONSE_System_Node0 = `{ + "@odata.id": "/redfish/v1/Systems/Node0", + "@odata.type": "#ComputerSystem.v1_5_0.ComputerSystem", + "Id": "Node0", + "Name": "Node0", + "PowerState": "On", + "Actions": { + "#ComputerSystem.Reset": { + "ResetType@Redfish.AllowableValues": [ + "On", + "ForceOff", + "GracefulShutdown", + "ForceRestart" + ], + "target": "/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset" + } + } +}` + // RESPONSE_ServiceRoot_HPE is a minimal ServiceRoot that identifies its + // manufacturer via the Redfish "Vendor" property. It lets tests drive the + // vendor-detection/dispatch path (e.g. asserting the HPE plugin is selected) + // without a full emulator. + RESPONSE_ServiceRoot_HPE = `{ + "@odata.id": "/redfish/v1/", + "@odata.type": "#ServiceRoot.v1_2_0.ServiceRoot", + "Id": "RootService", + "Name": "Root Service", + "RedfishVersion": "1.2.0", + "Vendor": "HPE", + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Systems": { + "@odata.id": "/redfish/v1/Systems" + } }` )