Skip to content
Draft
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
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
128 changes: 128 additions & 0 deletions pkg/bmc/client.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
98 changes: 98 additions & 0 deletions pkg/bmc/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
34 changes: 34 additions & 0 deletions pkg/bmc/conn.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions pkg/bmc/conn_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading
Loading