diff --git a/cmd/power.go b/cmd/power.go index b2ef25a1..dd779897 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -1,245 +1,348 @@ package cmd import ( + "context" "encoding/json" "fmt" - "sync" + "strings" "github.com/OpenCHAMI/magellan/internal/format" "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/cznic/mathutil" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) var ( list_reset_types bool reset_type string + operation string powerFormat format.DataFormat = format.FORMAT_JSON ) -// The `power` command gets and sets power states for a collection of BMC nodes. -// This command should be run after `collect`, as it requires an existing node inventory. +// The `power` command gets and sets power states for a single BMC node using flexible identifiers. var PowerCmd = &cobra.Command{ - Use: "power ...", - Example: ` // get power state - magellan power x1000c0s0b3n0 - // perform a particular type of reset - magellan power x1000c0s0b3n0 -r On - magellan power x1000c0s0b3n0 -r PowerCycle - // list supported reset types - magellan power x1000c0s0b3n0 -l - // more realistic usage - magellan power -u USER -p PASS -f collect.json x1000c0s0b3n0 x1000c0s0b3n1 x1000c0s0b3n2 - // inventory from stdin - magellan collect -v ... | magellan power -f - x1000c0s0b3n0`, - Short: "Get and set node power states", - Long: "Determine and control the power states of nodes found by a previous inventory crawl.\nSee the 'scan' and 'crawl' commands for further details.", + Use: "power ", + Example: ` // Power control by IP address (no inventory file needed) + magellan power 10.0.0.101 -u admin -p password + magellan power 10.0.0.101 -o off + magellan power 192.168.1.100 --list-reset-types + + // Power control by UUID (from inventory file) + magellan power 3894755a-8e4c-41d6-a6eb-3c5f4b7d2e10 -o on -f nodes.json + + // Power control by serial number (from inventory file) + magellan power CN75120A3G -o hard-restart -f nodes.json + + // Power control by MAC address (from inventory file) + magellan power aa:bb:cc:dd:ee:ff -o soft-off -f nodes.json + magellan power aa-bb-cc-dd-ee-ff -o off -f nodes.json + + // Power control by xname (from inventory file) + magellan power x1000c0s0b3n0 -o off -f nodes.json + magellan power x5506c0s172b105n1 --list-reset-types -f nodes.json`, + Short: "Get and set node power states using flexible identifiers", + Long: `Control power states of individual nodes through their BMC using natural identifiers. + +Supported identifier types (auto-detected): + - IP address: Connect directly to BMC (no inventory file needed) + - UUID: Redfish system UUID from inventory + - Serial number: System serial number from inventory + - MAC address: Any NIC MAC address from inventory + - XName: Cray-format cluster identifier from inventory + +For IP addresses, magellan connects directly without requiring a pre-collected inventory. +For other identifiers, an inventory file from 'magellan collect' is required.`, Run: func(cmd *cobra.Command, args []string) { - // Read node inventory from CLI flag, or default `collect` YAML output - var datafile string - if viper.IsSet("inventory-file") { - datafile = viper.GetString("inventory-file") - } else { - datafile = viper.GetString("collect.output-file") - log.Info().Msgf("parsing default inventory file from 'collect': %s", datafile) + ctx := cmd.Context() + + // Validate: exactly one node identifier + if len(args) == 0 { + log.Fatal().Msg("exactly one node identifier required. Usage: magellan power [flags]") } - // Parse node inventory - nodes, err := power.ParseInventory(datafile, powerFormat) - if err != nil { - log.Fatal().Err(err).Msgf("failed to parse inventory file %s", datafile) - // log.Fatal().Msg() does os.Exit(1) for us + if len(args) > 1 { + log.Fatal().Msgf("multiple nodes not supported - specify one identifier at a time. Received %d identifiers: %v", len(args), args) } - // Set the minimum/maximum number of concurrent processes - if concurrency <= 0 { - concurrency = mathutil.Clamp(len(args), 1, 10000) + identifier := args[0] + + // Validate operation flag value + if operation != "" && !bmc.KnownOperation(bmc.Operation(operation)) { + log.Fatal().Msgf("unknown power operation %q (known operations: %v)", operation, bmc.Operations()) } - // Use secret store for BMC credentials, and/or credential CLI flags - var store secrets.SecretStore - if username != "" && password != "" { - // First, try and load credentials from --username and --password if both are set. - log.Debug().Msgf("--username and --password specified, using them for BMC credentials") - store = secrets.NewStaticStore(username, password) - } else { - // Alternatively, locate specific credentials (falling back to default) and override those - // with --username or --password if either are passed. - log.Debug().Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile) - if store, err = secrets.OpenStore(secretsFile); err != nil { - log.Error().Err(err).Msg("failed to open local secrets store") + // Setup credentials (from flags or secrets file) + store := setupCredentialStore() + + // Detect identifier type + identifierType := bmc.DetectIdentifierType(identifier) + log.Debug().Msgf("detected identifier type: %s", identifierType) + + // PATH 1: Direct IP connection (no inventory file needed) + if identifierType == bmc.IdentifierIP { + err := handleDirectIPConnection(ctx, identifier, store) + if err != nil { + log.Fatal().Err(err).Msg("direct IP connection failed") } + return + } + + // PATH 2: Inventory-based lookup (for UUID, Serial, MAC, XName) + log.Info().Msg("non-IP identifier detected, loading inventory for lookup") + + // Load inventory + nodes := loadInventory() + + // Find node by identifier + node, err := findNodeByIdentifier(nodes, identifier) + if err != nil { + log.Fatal().Err(err).Msg("node lookup failed") + } + + log.Debug().Msgf("found node in inventory: ClusterID=%s, BmcIP=%s, NodeID=%s", node.ClusterID, node.BmcIP, node.NodeID) - // Temporarily override username/password of each BMC if one of those - // flags is passed. The expectation is that if the flag is specified - // on the command line, it should be used. + // Connect to the node's BMC + config := crawler.CrawlerConfig{ + URI: "https://" + node.BmcIP, + CredentialStore: store, + Insecure: insecure, + } + + client, err := bmc.DefaultManager.Client(ctx, config) + if err != nil { + log.Fatal().Err(err).Msgf("failed to connect to BMC at %s for node %s", node.BmcIP, identifier) + } + defer client.Logout() + + // Perform power action + err = performPowerAction(ctx, client, node.NodeID, identifier) + if err != nil { + log.Fatal().Err(err).Msgf("power action failed for node %s", identifier) + } + }, +} + +// setupCredentialStore creates and configures the credential store from CLI flags or secrets file. +// It handles username/password overrides and returns a configured SecretStore. +func setupCredentialStore() secrets.SecretStore { + var store secrets.SecretStore + var err error + + if username != "" && password != "" { + log.Debug().Msg("using credentials from --username and --password flags") + store = secrets.NewStaticStore(username, password) + } else { + log.Debug().Msgf("loading credentials from secret store at %s", secretsFile) + if store, err = secrets.OpenStore(secretsFile); err != nil { + log.Error().Err(err).Msg("failed to open local secrets store") + } + + // Override username/password if either flag is set + if username != "" { + log.Info().Msg("--username passed, overriding all usernames from secret store") + } + if password != "" { + log.Info().Msg("--password passed, overriding all passwords from secret store") + } + switch s := store.(type) { + case *secrets.StaticStore: if username != "" { - log.Info().Msg("--username passed, temporarily overriding all usernames from secret store with value") + s.Username = username } if password != "" { - log.Info().Msg("--password passed, temporarily overriding all passwords from secret store with value") + s.Password = password } - switch s := store.(type) { - case *secrets.StaticStore: - if username != "" { - s.Username = username - } - if password != "" { - s.Password = password - } - case *secrets.LocalSecretStore: - for k := range s.Secrets { - if creds, err := bmc.GetBMCCredentials(store, k); err != nil { - log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials") + case *secrets.LocalSecretStore: + for k := range s.Secrets { + if creds, err := bmc.GetBMCCredentials(store, k); err != nil { + log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials") + } else { + if username != "" { + creds.Username = username + } + if password != "" { + creds.Password = password + } + if newCreds, err := json.Marshal(creds); err != nil { + log.Error().Str("id", k).Err(err).Msg("failed to marshal updated credentials") } else { - if username != "" { - creds.Username = username - } - if password != "" { - creds.Password = password - } - - if newCreds, err := json.Marshal(creds); err != nil { - log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials: marshal error") - } else { - err = s.StoreSecretByID(k, string(newCreds)) - if err != nil { - log.Error().Err(err).Str("id", k).Msg("failed to store secret by ID") - } + err = s.StoreSecretByID(k, string(newCreds)) + if err != nil { + log.Error().Err(err).Str("id", k).Msg("failed to store secret by ID") } } } } } + } + + return store +} + +// loadInventory reads and parses the node inventory from the configured file or default location. +func loadInventory() []bmc.Node { + var datafile string + if viper.IsSet("inventory-file") { + datafile = viper.GetString("inventory-file") + } else { + datafile = viper.GetString("collect.output-file") + log.Info().Msgf("using default inventory file from 'collect' command: %s", datafile) + } + + nodes, err := power.ParseInventory(datafile, powerFormat) + if err != nil { + log.Fatal().Err(err).Msgf("failed to parse inventory file %s. Hint: for IP-based operations, no inventory file is needed; for other identifiers, run 'magellan collect' first", datafile) + } - // Index nodes by xname, for faster lookup... - nodemap := make(map[string]bmc.Node, len(nodes)) + return nodes +} + +// findNodeByIdentifier searches the inventory for a node matching the given identifier. +// It automatically detects the identifier type and searches the appropriate field(s). +func findNodeByIdentifier(nodes []bmc.Node, identifier string) (*bmc.Node, error) { + identifierType := bmc.DetectIdentifierType(identifier) + + switch identifierType { + case bmc.IdentifierXName: for i := range nodes { - nodemap[nodes[i].ClusterID] = nodes[i] + if nodes[i].ClusterID == identifier { + return &nodes[i], nil + } } - // ...and select the ones requested by the user - target_nodes := make([]power.CrawlableNode, 0, len(args)) - for i := range args { - node, found := nodemap[args[i]] - if !found { - log.Error().Msgf("target node '%s' not found in inventory; skipping", args[i]) - continue + + case bmc.IdentifierIP: + for i := range nodes { + if nodes[i].BmcIP == identifier { + return &nodes[i], nil } - target_nodes = append(target_nodes, power.CrawlableNode{ - ClusterID: node.ClusterID, - NodeID: node.NodeID, - ConnConfig: crawler.CrawlerConfig{ - URI: "https://" + node.BmcIP, - CredentialStore: store, - Insecure: insecure, - }, - }) } - // Create the appropriate "action function" based on CLI flags (or lack thereof) - var action_func func(power.CrawlableNode) string - if list_reset_types { - action_func = func(target power.CrawlableNode) string { - types, err := power.GetResetTypes(target) - if err != nil { - log.Error().Err(err).Msgf("failed to get reset types for node %s", target.ClusterID) - return "" - } - return fmt.Sprintf("%s", types) + case bmc.IdentifierUUID: + for i := range nodes { + if strings.EqualFold(nodes[i].UUID, identifier) { + return &nodes[i], nil } - } else if reset_type != "" { - action_func = func(target power.CrawlableNode) string { - // TODO: Some kind of validation might be nice here, but ResetType - // is a custom string type, so a direct typecast works fine for now. - err := power.ResetComputerSystem(target, redfish.ResetType(reset_type)) - if err != nil { - log.Error().Err(err).Msgf("failed to reset node %s", target.ClusterID) - return "failure" - } - return "success" + } + + case bmc.IdentifierSerial: + for i := range nodes { + if strings.EqualFold(nodes[i].SerialNumber, identifier) { + return &nodes[i], nil } - } else { - action_func = func(target power.CrawlableNode) string { - state, err := power.GetPowerState(target) - if err != nil { - log.Error().Err(err).Msgf("failed to get power state of node %s", target.ClusterID) - state = "unknown" + } + + case bmc.IdentifierMAC: + // Normalize both input and stored MACs (lowercase, colon separator) + normalizedInput := strings.ToLower(strings.ReplaceAll(identifier, "-", ":")) + for i := range nodes { + for _, mac := range nodes[i].MACAddresses { + normalizedMAC := strings.ToLower(strings.ReplaceAll(mac, "-", ":")) + if normalizedMAC == normalizedInput { + return &nodes[i], nil } - return string(state) } } - // Actual node operations, in parallel - results := concurrent_helper(concurrency, target_nodes, action_func) - power.LogoutBMCSessions() - for node, status := range results { - fmt.Printf("%s:\t%s\n", node, status) - } - }, + case bmc.IdentifierUnknown: + return nil, fmt.Errorf("unable to determine identifier type for %q", identifier) + } + + return nil, fmt.Errorf("node with %s %q not found in inventory. Try: run 'magellan collect' to refresh inventory, or use the BMC IP address directly", identifierType, identifier) } -func concurrent_helper(concurrency int, targets []power.CrawlableNode, runner func(power.CrawlableNode) string) map[string]string { - type NodeInfo struct { - ClusterID string - Result string +// handleDirectIPConnection connects directly to a BMC by IP address without requiring an inventory file. +// This enables quick single-node operations: magellan power 10.0.0.101 -o off +func handleDirectIPConnection(ctx context.Context, ipAddr string, store secrets.SecretStore) error { + log.Info().Msgf("connecting directly to BMC at %s (no inventory file required)", ipAddr) + + // Build connection config + config := crawler.CrawlerConfig{ + URI: "https://" + ipAddr, + CredentialStore: store, + Insecure: insecure, } - dataChannel := make(chan power.CrawlableNode, 1) - returnChannel := make(chan NodeInfo, concurrency) - results := make(map[string]string, len(targets)) - var wg sync.WaitGroup - - // Worker threads - wg.Add(concurrency) - for i := 0; i < concurrency; i++ { - go func() { - for { - // Get next work item, if any - target, ok := <-dataChannel - if !ok { - wg.Done() - return - } - // Perform work and return result - returnChannel <- NodeInfo{target.ClusterID, runner(target)} - } - }() + + // Connect to BMC + client, err := bmc.DefaultManager.Client(ctx, config) + if err != nil { + return fmt.Errorf("failed to connect to BMC at %s: %w. Check: IP address is correct, credentials are valid, network connectivity is working", ipAddr, err) } - // Receive worker results - go func() { - for { - info, ok := <-returnChannel - if !ok { - break - } - results[info.ClusterID] = info.Result + defer client.Logout() + + // Get systems from BMC + systems, err := client.Gofish().GetService().Systems() + if err != nil { + return fmt.Errorf("failed to get computer systems from BMC: %w", err) + } + if len(systems) == 0 { + return fmt.Errorf("no computer systems found on BMC at %s", ipAddr) + } + + // Use first system (simple approach) + systemID := systems[0].ID + if len(systems) > 1 { + log.Info().Msgf("BMC has %d computer systems, using first system: %s", len(systems), systemID) + } + + // Perform the requested power action + return performPowerAction(ctx, client, systemID, ipAddr) +} + +// performPowerAction executes the requested power operation (or query) on a computer system. +// This consolidates the action logic used by both direct IP and inventory-based paths. +func performPowerAction(ctx context.Context, client bmc.Client, systemID string, displayName string) error { + // Action: List supported reset types + if list_reset_types { + types, err := client.GetResetTypes(ctx, systemID) + if err != nil { + return fmt.Errorf("failed to get reset types: %w", err) + } + fmt.Printf("%s: supported reset types: %v\n", displayName, types) + return nil + } + + // Action: Vendor-neutral operation + if operation != "" { + _, err := client.ResetOperation(ctx, systemID, bmc.Operation(operation)) + if err != nil { + return fmt.Errorf("failed to perform operation %q: %w", operation, err) } - wg.Done() - }() + fmt.Printf("%s: operation %q completed successfully\n", displayName, operation) + return nil + } + + // Action: Raw Redfish reset type + if reset_type != "" { + _, err := client.Reset(ctx, systemID, schemas.ResetType(reset_type)) + if err != nil { + return fmt.Errorf("failed to perform reset type %q: %w", reset_type, err) + } + fmt.Printf("%s: reset type %q completed successfully\n", displayName, reset_type) + return nil + } - // Dispatch data and wait for processing completion - for i := range targets { - dataChannel <- targets[i] + // Default action: Query power state + state, err := client.GetPowerState(ctx, systemID) + if err != nil { + return fmt.Errorf("failed to get power state: %w", err) } - close(dataChannel) - wg.Wait() - // Ensure the receiver thread has also finished - wg.Add(1) - close(returnChannel) - wg.Wait() - - return results + fmt.Printf("%s: %s\n", displayName, state) + return nil } func init() { - // Alternative actions from the default power-state query - PowerCmd.Flags().BoolVarP(&list_reset_types, "list-reset-types", "l", false, "List supported Redfish reset types") - PowerCmd.Flags().StringVarP(&reset_type, "reset-type", "r", "", "Redfish reset type to perform") - PowerCmd.MarkFlagsMutuallyExclusive("reset-type", "list-reset-types") + // Alternative actions from the default power-state query. + // NOTE: no "-l" shorthand here — it is reserved globally for --log-level on + // the root command (cmd/root.go). Defining it again panics pflag at execution + // time when the persistent flags are merged into this subcommand's flagset. + PowerCmd.Flags().BoolVar(&list_reset_types, "list-reset-types", false, "List supported Redfish reset types") + PowerCmd.Flags().StringVarP(&reset_type, "reset-type", "r", "", "Raw Redfish reset type to perform (no validation/fallback; prefer --operation)") + PowerCmd.Flags().StringVarP(&operation, "operation", "o", "", "Vendor-neutral power operation (on|off|soft-off|force-off|soft-restart|hard-restart|init)") + PowerCmd.MarkFlagsMutuallyExclusive("reset-type", "list-reset-types", "operation") // Normal config options PowerCmd.Flags().StringP("inventory-file", "f", "", "YAML file containing node inventory") diff --git a/cmd/power_test.go b/cmd/power_test.go new file mode 100644 index 00000000..06afc9a8 --- /dev/null +++ b/cmd/power_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "bytes" + "testing" +) + +// TestPowerCommandExecutes guards against shorthand-flag collisions between the +// power subcommand and the root command's persistent flags. pflag merges the +// root persistent flags into a subcommand's flagset at execution time and +// panics on a duplicate shorthand (e.g. the historical --list-reset-types/-l vs +// --log-level/-l clash). Executing `power --help` forces that merge without +// running the command body, so any reintroduced collision fails here instead of +// at runtime for every user. +func TestPowerCommandExecutes(t *testing.T) { + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"power", "--help"}) + t.Cleanup(func() { rootCmd.SetArgs(nil) }) + + // A shorthand collision surfaces as a panic during flag merge; the test + // fails (rather than the process aborting) if that regresses. + if err := rootCmd.Execute(); err != nil { + t.Fatalf("executing `power --help` returned an error: %v", err) + } + if out.Len() == 0 { + t.Fatal("expected help output for `power --help`, got none") + } +} 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/go.mod b/go.mod index aaeb9977..0dcd4bda 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,56 @@ module github.com/OpenCHAMI/magellan -go 1.23 - -toolchain go1.24.5 +go 1.26.0 require ( github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 - github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/chi/v5 v5.3.0 github.com/jmoiron/sqlx v1.4.0 - github.com/lestrrat-go/jwx v1.2.29 - github.com/mattn/go-sqlite3 v1.14.22 + github.com/lestrrat-go/jwx v1.2.31 + github.com/mattn/go-sqlite3 v1.14.45 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 - github.com/stmcginnis/gofish v0.19.0 - golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stmcginnis/gofish v0.22.0 + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 ) require ( github.com/Cray-HPE/hms-xname v1.4.0 - github.com/rs/zerolog v1.33.0 - github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.32.0 + github.com/rs/zerolog v1.35.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/google/go-cmp v0.6.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect - github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index edef927f..a707639e 100644 --- a/go.sum +++ b/go.sum @@ -2,32 +2,28 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Cray-HPE/hms-xname v1.4.0 h1:i47YmE8rbSfJ64simKCCC6ZVcGid3rDIX6/jfVbISAM= github.com/Cray-HPE/hms-xname v1.4.0/go.mod h1:wH7t1UXYck0VdHSWjrMsxZmaCK5W1lmwgNnsYAFPTus= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 h1:iwZdTE0PVqJCos1vaoKsclOGD3ADKpshg3SRtYBbwso= github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -41,33 +37,28 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= -github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx v1.2.29 h1:QT0utmUJ4/12rmsVQrJ3u55bycPkKqGYuGT4tyRhxSQ= -github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= +github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54= +github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -79,104 +70,45 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stmcginnis/gofish v0.19.0 h1:fmxdRZ5WHfs+4ExArMYoeRfoh+SAxLELKtmoVplBkU4= -github.com/stmcginnis/gofish v0.19.0/go.mod h1:lq2jHj2t8Krg0Gx02ABk8MbK7Dz9jvWpO/TGnVksn00= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stmcginnis/gofish v0.22.0 h1:OahXohfrIzAXOsWuKDQ7lm/QvdZBg1P2OzFYmbKAd/0= +github.com/stmcginnis/gofish v0.22.0/go.mod h1:PzF5i8ecRG9A2ol8XT64npKUunyraJ+7t0kYMpQAtqU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= -golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/util/net.go b/internal/util/net.go index 4ecb249f..2a6d7b10 100644 --- a/internal/util/net.go +++ b/internal/util/net.go @@ -5,7 +5,7 @@ import ( "net" ) -func IPAddrStrToInt(ipStr string)(int, error) { +func IPAddrStrToInt(ipStr string) (int, error) { // Generate an integer from an IP address. This is not // sensitive to byte ordering, so the integer produced on // different systems may be different. It will be consistent diff --git a/pkg/bmc/bmc.go b/pkg/bmc/bmc.go index 083def6f..c1400a37 100644 --- a/pkg/bmc/bmc.go +++ b/pkg/bmc/bmc.go @@ -12,9 +12,12 @@ type BMCCredentials struct { Password string `json:"password"` } type Node struct { - ClusterID string `yaml:"cluster_id"` - BmcIP string `yaml:"bmc_ip"` - NodeID string `yaml:"node_id"` + ClusterID string `yaml:"cluster_id"` + BmcIP string `yaml:"bmc_ip"` + NodeID string `yaml:"node_id"` + UUID string `yaml:"uuid,omitempty"` + SerialNumber string `yaml:"serial_number,omitempty"` + MACAddresses []string `yaml:"mac_addresses,omitempty"` } func GetBMCCredentialsDefault(store secrets.SecretStore) (BMCCredentials, error) { diff --git a/pkg/bmc/client.go b/pkg/bmc/client.go new file mode 100644 index 00000000..9cb1aeef --- /dev/null +++ b/pkg/bmc/client.go @@ -0,0 +1,174 @@ +package bmc + +import ( + "context" + "fmt" + + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/schemas" +) + +// 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. +// +// Context handling: gofish scopes its request context at connection time (see +// Manager.ConnectContext), so the ctx passed to these methods is honored as an +// entry guard — a cancelled or expired context aborts before any BMC I/O is +// issued — and is the seam the forthcoming async confirmation loop (bugs.md +// power-parity #4) will poll against. It governs the connection's request +// context for freshly opened sessions but does not retroactively re-scope an +// already-cached gofish session. +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(ctx context.Context, systemID string) (schemas.PowerState, error) + // GetResetTypes returns the reset types supported by the ComputerSystem + // with the given Redfish ID. + GetResetTypes(ctx context.Context, systemID string) ([]schemas.ResetType, error) + // Reset issues a reset of the given type to the ComputerSystem with the + // given Redfish ID. It returns the gofish task-monitor handle for the + // operation when the BMC models the reset as an async Redfish Task (it may + // be nil when the BMC completes synchronously). + Reset(ctx context.Context, systemID string, resetType schemas.ResetType) (*schemas.TaskMonitorInfo, error) + // ResetOperation resolves a vendor-neutral Operation to a concrete reset + // type the target advertises (applying the graceful→forced fallback chain) + // and issues it. It returns ErrUnsupportedOperation when no advertised reset + // type satisfies the operation, distinct from a BMC call failure. + ResetOperation(ctx context.Context, systemID string, op Operation) (*schemas.TaskMonitorInfo, 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. +// It honors ctx as an entry guard before issuing the Redfish collection fetch. +func (g *GenericClient) systemByID(ctx context.Context, systemID string) (*schemas.ComputerSystem, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + 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(ctx context.Context, systemID string) (schemas.PowerState, error) { + system, err := g.systemByID(ctx, systemID) + if err != nil { + return "", err + } + return system.PowerState, nil +} + +func (g *GenericClient) GetResetTypes(ctx context.Context, systemID string) ([]schemas.ResetType, error) { + system, err := g.systemByID(ctx, systemID) + if err != nil { + return nil, err + } + return system.GetSupportedResetTypes() +} + +func (g *GenericClient) Reset(ctx context.Context, systemID string, resetType schemas.ResetType) (*schemas.TaskMonitorInfo, error) { + system, err := g.systemByID(ctx, systemID) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + // gofish v0.22's Reset returns a *schemas.TaskMonitorInfo carrying the + // Redfish task-monitor URI for the in-flight operation; we surface it so the + // async confirmation loop (bugs.md power parity #4) can track completion + // natively instead of re-deriving it by polling PowerState. + return system.Reset(resetType) +} + +func (g *GenericClient) ResetOperation(ctx context.Context, systemID string, op Operation) (*schemas.TaskMonitorInfo, error) { + system, err := g.systemByID(ctx, systemID) + if err != nil { + return nil, err + } + supported, err := system.GetSupportedResetTypes() + if err != nil { + return nil, err + } + resetType, err := ResolveResetType(op, supported) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, 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/client_test.go b/pkg/bmc/client_test.go new file mode 100644 index 00000000..c8ceae5a --- /dev/null +++ b/pkg/bmc/client_test.go @@ -0,0 +1,153 @@ +package bmc + +import ( + "context" + "io" + "net/http" + "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/schemas" +) + +// 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(context.Background(), "Node0") + if err != nil { + t.Fatalf("GetPowerState(Node0) unexpected error: %v", err) + } + if state != schemas.OnPowerState { + t.Fatalf("GetPowerState(Node0) = %q, want %q", state, schemas.OnPowerState) + } +} + +func TestGenericClientResetTypesFound(t *testing.T) { + c := newMockGenericClient(t) + got, err := c.GetResetTypes(context.Background(), "Node0") + if err != nil { + t.Fatalf("GetResetTypes(Node0) unexpected error: %v", err) + } + want := map[schemas.ResetType]bool{ + schemas.OnResetType: true, + schemas.ForceOffResetType: true, + schemas.GracefulShutdownResetType: true, + schemas.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) + ctx := context.Background() + + if _, err := c.GetPowerState(ctx, "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(ctx, "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(ctx, "bogus", schemas.OnResetType); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("Reset(bogus) err = %v, want a 'not found' error", err) + } + if _, err := c.ResetOperation(ctx, "bogus", OpOff); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("ResetOperation(bogus) err = %v, want a 'not found' error", err) + } +} + +// TestGenericClientContextCancelled verifies ctx acts as an entry guard: a +// cancelled context aborts before any BMC I/O, returning the context error. +func TestGenericClientContextCancelled(t *testing.T) { + c := newMockGenericClient(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := c.GetPowerState(ctx, "Node0"); err != context.Canceled { + t.Fatalf("GetPowerState with cancelled ctx err = %v, want context.Canceled", err) + } +} + +// TestGenericClientResetOperationResolvesAndPosts is the end-to-end check that a +// vendor-neutral operation is resolved against the system's advertised reset +// types and the resolved type is what actually gets POSTed. Node0 advertises +// GracefulShutdown, so "off" must resolve to GracefulShutdown (not ForceOff). +func TestGenericClientResetOperationResolvesAndPosts(t *testing.T) { + var gotBody string + mux := chi.NewMux() + 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)) + mux.HandleFunc("/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusNoContent) + }) + 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) + c := NewGenericClient(api, VendorGeneric) + + if _, err := c.ResetOperation(context.Background(), "Node0", OpOff); err != nil { + t.Fatalf("ResetOperation(off) unexpected error: %v", err) + } + if !strings.Contains(gotBody, string(schemas.GracefulShutdownResetType)) { + t.Fatalf("reset POST body = %q, want it to contain %q", gotBody, schemas.GracefulShutdownResetType) + } +} + +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.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/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/identifier.go b/pkg/bmc/identifier.go new file mode 100644 index 00000000..2e17598a --- /dev/null +++ b/pkg/bmc/identifier.go @@ -0,0 +1,79 @@ +package bmc + +import ( + "net" + "regexp" + "strings" +) + +// IdentifierType represents the type of node identifier provided by the user +type IdentifierType int + +const ( + IdentifierUnknown IdentifierType = iota + IdentifierXName // x1000c0s0b3n0 + IdentifierIP // 10.0.0.101 or 2001:db8::1 + IdentifierUUID // 3894755a-8e4c-41d6-a6eb-3c5f4b7d2e10 + IdentifierSerial // CN75120A3G + IdentifierMAC // aa:bb:cc:dd:ee:ff or aa-bb-cc-dd-ee-ff +) + +// String returns the human-readable name of the identifier type +func (i IdentifierType) String() string { + return [...]string{"Unknown", "XName", "IP", "UUID", "Serial", "MAC"}[i] +} + +// DetectIdentifierType determines what type of identifier the input string represents. +// It uses pattern matching to auto-detect the identifier type, enabling flexible +// node targeting without requiring explicit type flags. +// +// Detection order: +// 1. IP address (IPv4/IPv6) +// 2. UUID (RFC 4122 format) +// 3. MAC address (colon or dash separated) +// 4. XName (Cray format: xcsb[n]) +// 5. Serial number (alphanumeric fallback) +// +// Examples: +// - "10.0.0.101" → IdentifierIP +// - "3894755a-8e4c-41d6-a6eb-3c5f4b7d2e10" → IdentifierUUID +// - "aa:bb:cc:dd:ee:ff" → IdentifierMAC +// - "x1000c0s0b3n0" → IdentifierXName +// - "CN75120A3G" → IdentifierSerial +func DetectIdentifierType(input string) IdentifierType { + if input == "" { + return IdentifierUnknown + } + + // IP: Try parsing as IP address (IPv4 or IPv6) + if net.ParseIP(input) != nil { + return IdentifierIP + } + + // UUID: RFC 4122 format (8-4-4-4-12 hex digits) + uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + if uuidRegex.MatchString(strings.ToLower(input)) { + return IdentifierUUID + } + + // MAC: 6 pairs of hex digits with : or - separator + macRegex := regexp.MustCompile(`^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$`) + if macRegex.MatchString(strings.ToLower(input)) { + return IdentifierMAC + } + + // XName: Cray format - xcsb[n] + // Examples: x1000c0s0b3n0, x5506c0s172b105n1, x1c2s3b4 + xnameRegex := regexp.MustCompile(`^x\d+c\d+s\d+b\d+(n\d+)?$`) + if xnameRegex.MatchString(strings.ToLower(input)) { + return IdentifierXName + } + + // Serial: Fallback for any other alphanumeric string + serialRegex := regexp.MustCompile(`^[a-zA-Z0-9]+$`) + if serialRegex.MatchString(input) { + return IdentifierSerial + } + + return IdentifierUnknown +} diff --git a/pkg/bmc/identifier_test.go b/pkg/bmc/identifier_test.go new file mode 100644 index 00000000..1093c382 --- /dev/null +++ b/pkg/bmc/identifier_test.go @@ -0,0 +1,100 @@ +package bmc + +import "testing" + +func TestDetectIdentifierType(t *testing.T) { + tests := []struct { + name string + input string + expected IdentifierType + }{ + // IP addresses - IPv4 + {"IPv4 standard", "10.0.0.101", IdentifierIP}, + {"IPv4 localhost", "127.0.0.1", IdentifierIP}, + {"IPv4 broadcast", "255.255.255.255", IdentifierIP}, + {"IPv4 zero", "0.0.0.0", IdentifierIP}, + + // IP addresses - IPv6 + {"IPv6 standard", "2001:db8::1", IdentifierIP}, + {"IPv6 localhost", "::1", IdentifierIP}, + {"IPv6 full", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", IdentifierIP}, + {"IPv6 compressed", "2001:db8::8a2e:370:7334", IdentifierIP}, + + // UUIDs + {"UUID lowercase", "3894755a-8e4c-41d6-a6eb-3c5f4b7d2e10", IdentifierUUID}, + {"UUID uppercase", "3894755A-8E4C-41D6-A6EB-3C5F4B7D2E10", IdentifierUUID}, + {"UUID mixed case", "3894755a-8E4c-41D6-a6eb-3c5F4b7d2e10", IdentifierUUID}, + {"UUID all zeros", "00000000-0000-0000-0000-000000000000", IdentifierUUID}, + {"UUID all fs", "ffffffff-ffff-ffff-ffff-ffffffffffff", IdentifierUUID}, + + // MAC addresses + {"MAC colon separator lowercase", "aa:bb:cc:dd:ee:ff", IdentifierMAC}, + {"MAC colon separator uppercase", "AA:BB:CC:DD:EE:FF", IdentifierMAC}, + {"MAC colon separator mixed", "Aa:Bb:Cc:Dd:Ee:Ff", IdentifierMAC}, + {"MAC dash separator lowercase", "aa-bb-cc-dd-ee-ff", IdentifierMAC}, + {"MAC dash separator uppercase", "AA-BB-CC-DD-EE-FF", IdentifierMAC}, + {"MAC all zeros", "00:00:00:00:00:00", IdentifierMAC}, + {"MAC broadcast", "ff:ff:ff:ff:ff:ff", IdentifierMAC}, + + // XNames (Cray format) + {"XName with node", "x1000c0s0b3n0", IdentifierXName}, + {"XName without node", "x1000c0s0b3", IdentifierXName}, + {"XName complex with node", "x5506c0s172b105n1", IdentifierXName}, + {"XName complex without node", "x5506c0s172b105", IdentifierXName}, + {"XName minimal", "x1c2s3b4", IdentifierXName}, + {"XName uppercase", "X1000C0S0B3N0", IdentifierXName}, + {"XName mixed case", "X1000c0S0b3N0", IdentifierXName}, + + // Serial numbers + {"Serial alphanumeric", "CN75120A3G", IdentifierSerial}, + {"Serial numeric only", "1234567890", IdentifierSerial}, + {"Serial alpha only", "ABCDEFGH", IdentifierSerial}, + {"Serial mixed case", "Cn75120a3G", IdentifierSerial}, + {"Serial long", "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", IdentifierSerial}, + + // Edge cases + {"Empty string", "", IdentifierUnknown}, + {"Whitespace only", " ", IdentifierUnknown}, + {"Special characters", "x1000@c0s0", IdentifierUnknown}, + {"UUID without dashes", "3894755a8e4c41d6a6eb3c5f4b7d2e10", IdentifierSerial}, // Falls back to serial + {"MAC without separators", "aabbccddeeff", IdentifierSerial}, // Falls back to serial + {"Invalid XName missing components", "x1000c0", IdentifierSerial}, // Falls back to serial + {"Invalid XName wrong prefix", "y1000c0s0b3n0", IdentifierSerial}, // Falls back to serial + {"Partial UUID", "3894755a-8e4c-41d6", IdentifierUnknown}, + {"Invalid MAC too short", "aa:bb:cc:dd:ee", IdentifierUnknown}, + {"Invalid MAC too long", "aa:bb:cc:dd:ee:ff:gg", IdentifierUnknown}, + {"Mixed separators MAC", "aa:bb-cc:dd-ee:ff", IdentifierMAC}, // Regex allows either separator at each position + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectIdentifierType(tt.input) + if result != tt.expected { + t.Errorf("DetectIdentifierType(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestIdentifierTypeString(t *testing.T) { + tests := []struct { + identifierType IdentifierType + expected string + }{ + {IdentifierUnknown, "Unknown"}, + {IdentifierXName, "XName"}, + {IdentifierIP, "IP"}, + {IdentifierUUID, "UUID"}, + {IdentifierSerial, "Serial"}, + {IdentifierMAC, "MAC"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.identifierType.String() + if result != tt.expected { + t.Errorf("IdentifierType.String() = %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/pkg/bmc/manager.go b/pkg/bmc/manager.go new file mode 100644 index 00000000..0e766d45 --- /dev/null +++ b/pkg/bmc/manager.go @@ -0,0 +1,115 @@ +package bmc + +import ( + "context" + "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() + +// ConnectContext opens a new, uncached gofish session to the BMC described by +// cfg, scoping the session's request context to ctx. This is the single point in +// the codebase where gofish.Connect(Context) is invoked; all 404 and 401 errors +// are decorated here for consistent messaging. Because gofish binds the context +// at connection time, ctx governs the request deadline/cancellation for every +// call made through the returned client. +func (m *Manager) ConnectContext(ctx context.Context, 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.ConnectContext(ctx, 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 +} + +// Connect opens a new, uncached gofish session using a background context. It is +// retained for the raw-gofish call sites (crawler, collect) that do not yet +// thread a context; prefer ConnectContext where a context is available. +func (m *Manager) Connect(cfg ConnConfig) (*gofish.APIClient, error) { + return m.ConnectContext(context.Background(), cfg) +} + +// 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(ctx context.Context, cfg ConnConfig) (Client, error) { + api, err := m.ConnectContext(ctx, 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. +// +// Note: ctx scopes the session only when a new one is opened; a cache hit +// returns a session bound to the context it was originally connected with. For +// per-request cancellation across many callers (e.g. the daemon), prefer Client +// to obtain an uncached, request-scoped session. +func (m *Manager) CachedClient(ctx context.Context, 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.ConnectContext(ctx, 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/manager_test.go b/pkg/bmc/manager_test.go new file mode 100644 index 00000000..b0ea9a0e --- /dev/null +++ b/pkg/bmc/manager_test.go @@ -0,0 +1,134 @@ +package bmc_test + +import ( + "context" + "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(context.Background(), cfg) + if err != nil { + t.Fatalf("CachedClient: %v", err) + } + c2, err := m.CachedClient(context.Background(), 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(context.Background(), 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(context.Background(), cfg) + if err != nil { + t.Fatalf("Client: %v", err) + } + c2, err := m.Client(context.Background(), 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(context.Background(), 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/operation.go b/pkg/bmc/operation.go new file mode 100644 index 00000000..24dabb89 --- /dev/null +++ b/pkg/bmc/operation.go @@ -0,0 +1,112 @@ +package bmc + +import ( + "fmt" + "sort" + + "github.com/stmcginnis/gofish/schemas" +) + +// Operation is a vendor-neutral power operation. Callers request an Operation +// (e.g. "off") and the BMC layer resolves it to a concrete Redfish ResetType the +// target actually advertises, applying a fallback chain. This shields callers +// from having to know which exact Redfish token a given BMC accepts — the gap +// called out in bugs.md power-parity #2. +type Operation string + +const ( + // OpOn powers the target on. + OpOn Operation = "on" + // OpOff powers the target off gracefully, falling back to a forced power-off. + OpOff Operation = "off" + // OpSoftOff requests a graceful shutdown only, with no forced fallback. + OpSoftOff Operation = "soft-off" + // OpForceOff powers the target off immediately, without a graceful attempt. + OpForceOff Operation = "force-off" + // OpSoftRestart restarts gracefully, falling back to a forced restart. + OpSoftRestart Operation = "soft-restart" + // OpHardRestart forces a restart, falling back to a power cycle. + OpHardRestart Operation = "hard-restart" + // OpInit power-cycles the target, falling back to a forced restart. + OpInit Operation = "init" +) + +// operationPreferences maps each Operation to an ordered list of Redfish reset +// types. ResolveResetType picks the first entry the target advertises as +// supported. The ordering encodes the graceful→forced fallback policy mirrored +// from power-control (PCS): e.g. "off" prefers GracefulShutdown but accepts +// ForceOff, while "soft-off" intentionally has no forced fallback. +var operationPreferences = map[Operation][]schemas.ResetType{ + OpOn: {schemas.OnResetType, schemas.ForceOnResetType}, + OpOff: {schemas.GracefulShutdownResetType, schemas.ForceOffResetType}, + OpSoftOff: {schemas.GracefulShutdownResetType}, + OpForceOff: {schemas.ForceOffResetType}, + OpSoftRestart: {schemas.GracefulRestartResetType, schemas.ForceRestartResetType}, + OpHardRestart: {schemas.ForceRestartResetType, schemas.PowerCycleResetType}, + OpInit: {schemas.PowerCycleResetType, schemas.ForceRestartResetType}, +} + +// ErrUnsupportedOperation reports that a known Operation could not be mapped to +// any reset type the target advertises. It is deliberately distinct from a BMC +// call failure so callers can report "unsupported" separately from "failed" +// (bugs.md power-parity #2). +type ErrUnsupportedOperation struct { + Op Operation + Supported []schemas.ResetType +} + +func (e *ErrUnsupportedOperation) Error() string { + return fmt.Sprintf("power operation %q is not supported by this system; advertised reset types: %v", + e.Op, e.Supported) +} + +// KnownOperation reports whether op is a recognized power operation. +func KnownOperation(op Operation) bool { + _, ok := operationPreferences[op] + return ok +} + +// Operations returns the known power operations in sorted order, for help text +// and input validation. +func Operations() []Operation { + ops := make([]Operation, 0, len(operationPreferences)) + for op := range operationPreferences { + ops = append(ops, op) + } + sort.Slice(ops, func(i, j int) bool { return ops[i] < ops[j] }) + return ops +} + +// ResolveResetType maps a vendor-neutral Operation to a concrete Redfish +// ResetType given the set the target advertises as supported. +// +// - If supported is non-empty, the operation's preference list is honored +// strictly: the first preferred reset type that appears in supported wins, +// and if none match, ErrUnsupportedOperation is returned. +// - If supported is empty (the BMC advertised no reset types), the preferred +// reset type is returned on a best-effort basis. This mirrors gofish's own +// Reset, which assumes the request is acceptable when the system advertises +// no AllowableValues. +// - An unrecognized Operation yields an error. +func ResolveResetType(op Operation, supported []schemas.ResetType) (schemas.ResetType, error) { + prefs, ok := operationPreferences[op] + if !ok { + return "", fmt.Errorf("unknown power operation %q (known: %v)", op, Operations()) + } + + if len(supported) == 0 { + // Nothing advertised; best-effort with the preferred reset type. + return prefs[0], nil + } + + supportedSet := make(map[schemas.ResetType]bool, len(supported)) + for _, s := range supported { + supportedSet[s] = true + } + for _, p := range prefs { + if supportedSet[p] { + return p, nil + } + } + return "", &ErrUnsupportedOperation{Op: op, Supported: supported} +} diff --git a/pkg/bmc/operation_test.go b/pkg/bmc/operation_test.go new file mode 100644 index 00000000..7db2f609 --- /dev/null +++ b/pkg/bmc/operation_test.go @@ -0,0 +1,108 @@ +package bmc + +import ( + "errors" + "testing" + + "github.com/stmcginnis/gofish/schemas" +) + +func TestResolveResetType(t *testing.T) { + // A typical advertised set that supports both graceful and forced variants. + full := []schemas.ResetType{ + schemas.OnResetType, + schemas.ForceOnResetType, + schemas.GracefulShutdownResetType, + schemas.ForceOffResetType, + schemas.GracefulRestartResetType, + schemas.ForceRestartResetType, + schemas.PowerCycleResetType, + } + + cases := []struct { + name string + op Operation + supported []schemas.ResetType + want schemas.ResetType + }{ + {"on prefers On", OpOn, full, schemas.OnResetType}, + {"off prefers graceful", OpOff, full, schemas.GracefulShutdownResetType}, + {"soft-restart prefers graceful", OpSoftRestart, full, schemas.GracefulRestartResetType}, + {"hard-restart prefers force", OpHardRestart, full, schemas.ForceRestartResetType}, + {"force-off is force-off", OpForceOff, full, schemas.ForceOffResetType}, + {"init prefers power cycle", OpInit, full, schemas.PowerCycleResetType}, + + // Fallbacks: graceful variant absent, forced variant present. + {"off falls back to ForceOff", OpOff, + []schemas.ResetType{schemas.ForceOffResetType}, schemas.ForceOffResetType}, + {"soft-restart falls back to ForceRestart", OpSoftRestart, + []schemas.ResetType{schemas.ForceRestartResetType}, schemas.ForceRestartResetType}, + {"hard-restart falls back to PowerCycle", OpHardRestart, + []schemas.ResetType{schemas.PowerCycleResetType}, schemas.PowerCycleResetType}, + + // Empty advertised set → best-effort preferred type. + {"empty supported → preferred", OpOff, nil, schemas.GracefulShutdownResetType}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveResetType(tc.op, tc.supported) + if err != nil { + t.Fatalf("ResolveResetType(%q) unexpected error: %v", tc.op, err) + } + if got != tc.want { + t.Fatalf("ResolveResetType(%q) = %q, want %q", tc.op, got, tc.want) + } + }) + } +} + +// TestResolveResetTypeUnsupported verifies that when the advertised set excludes +// every preference for an operation, the resolver reports it as unsupported (a +// typed error distinct from a BMC call failure) rather than guessing. +func TestResolveResetTypeUnsupported(t *testing.T) { + // soft-off only accepts GracefulShutdown; a BMC advertising only ForceOff + // cannot satisfy it. + _, err := ResolveResetType(OpSoftOff, []schemas.ResetType{schemas.ForceOffResetType}) + if err == nil { + t.Fatal("expected ErrUnsupportedOperation, got nil") + } + var unsupported *ErrUnsupportedOperation + if !errors.As(err, &unsupported) { + t.Fatalf("error = %v (%T), want *ErrUnsupportedOperation", err, err) + } + if unsupported.Op != OpSoftOff { + t.Fatalf("unsupported.Op = %q, want %q", unsupported.Op, OpSoftOff) + } +} + +func TestResolveResetTypeUnknownOperation(t *testing.T) { + _, err := ResolveResetType(Operation("teleport"), nil) + if err == nil { + t.Fatal("expected error for unknown operation, got nil") + } + // An unknown operation is a caller error, not an ErrUnsupportedOperation. + var unsupported *ErrUnsupportedOperation + if errors.As(err, &unsupported) { + t.Fatalf("unknown operation should not be ErrUnsupportedOperation, got %v", err) + } +} + +func TestKnownOperationAndOperations(t *testing.T) { + if !KnownOperation(OpOn) { + t.Fatal("KnownOperation(OpOn) = false, want true") + } + if KnownOperation(Operation("nope")) { + t.Fatal("KnownOperation(nope) = true, want false") + } + // Operations() must list every operation that has a resolver mapping, sorted. + ops := Operations() + if len(ops) != len(operationPreferences) { + t.Fatalf("Operations() len = %d, want %d", len(ops), len(operationPreferences)) + } + for i := 1; i < len(ops); i++ { + if ops[i-1] >= ops[i] { + t.Fatalf("Operations() not sorted: %v", ops) + } + } +} 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/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/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..48ca8f74 100644 --- a/pkg/collect.go +++ b/pkg/collect.go @@ -21,8 +21,7 @@ import ( "github.com/rs/zerolog/log" _ "github.com/mattn/go-sqlite3" - "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" "golang.org/x/exp/slices" ) @@ -256,35 +255,16 @@ 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() var ( rf_service = client.GetService() - rf_managers []*redfish.Manager + rf_managers []*schemas.Manager ) rf_managers, err = rf_service.Managers() if err != nil { diff --git a/pkg/crawler/identify.go b/pkg/crawler/identify.go index be5b7884..4a679745 100644 --- a/pkg/crawler/identify.go +++ b/pkg/crawler/identify.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) // BMCInfo represents relevant information about a BMC @@ -18,7 +18,7 @@ type BMCInfo struct { } // IsBMC checks if a given Manager is a BMC based on its type and associations -func IsBMC(manager *redfish.Manager) bool { +func IsBMC(manager *schemas.Manager) bool { if manager == nil { return false } diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index 28d519ef..14f35b1e 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" + "github.com/stmcginnis/gofish/schemas" ) -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. @@ -165,7 +130,7 @@ func GetBMCClient(config CrawlerConfig) (*gofish.APIClient, error) { func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { var ( systems = make(map[string]*InventoryDetail) - rf_systems []*redfish.ComputerSystem + rf_systems []*schemas.ComputerSystem ) client, err := GetBMCClient(config) @@ -264,8 +229,8 @@ func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { // and returns a list of inventory details for each system. // // Parameters: -// - rf_systems: A slice of pointers to redfish.ComputerSystem objects representing the computer systems to be processed. -// - rf_chassis: A pointer to a redfish.Chassis object representing the chassis associated with the computer systems. +// - rf_systems: A slice of pointers to schemas.ComputerSystem objects representing the computer systems to be processed. +// - rf_chassis: A pointer to a schemas.Chassis object representing the chassis associated with the computer systems. // - baseURI: A string representing the base URI for constructing resource URIs. // // Returns: @@ -281,13 +246,13 @@ func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { // 6. Processes trusted modules for each computer system, adding them to the TrustedModules field of the InventoryDetail object. // 7. Appends the populated InventoryDetail object to the systems slice. // 8. Returns the systems slice and any error encountered during processing. -func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chassis, baseURI string) ([]InventoryDetail, error) { +func walkSystems(rf_systems []*schemas.ComputerSystem, rf_chassis *schemas.Chassis, baseURI string) ([]InventoryDetail, error) { systems := []InventoryDetail{} for _, rf_computersystem := range rf_systems { var ( managerLinks []string chassisLinks []string - power *redfish.Power + power *schemas.Power powercontrolIDs []string ) @@ -328,7 +293,8 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass // convert supported reset types to []string actions := []string{} - for _, action := range rf_computersystem.SupportedResetTypes { + supportedResetTypes, _ := rf_computersystem.GetSupportedResetTypes() + for _, action := range supportedResetTypes { actions = append(actions, string(action)) } @@ -343,19 +309,19 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass SerialNumber: rf_computersystem.SerialNumber, SerialConsole: SerialConsole{ IPMI: SerialConsoleConfig{ - Port: rf_computersystem.SerialConsole.IPMI.Port, + Port: derefUint(rf_computersystem.SerialConsole.IPMI.Port), Enabled: rf_computersystem.SerialConsole.IPMI.ServiceEnabled, }, SSH: SerialConsoleConfig{ - Port: rf_computersystem.SerialConsole.SSH.Port, + Port: derefUint(rf_computersystem.SerialConsole.SSH.Port), Enabled: rf_computersystem.SerialConsole.SSH.ServiceEnabled, }, Telnet: SerialConsoleConfig{ - Port: rf_computersystem.SerialConsole.Telnet.Port, + Port: derefUint(rf_computersystem.SerialConsole.Telnet.Port), Enabled: rf_computersystem.SerialConsole.Telnet.ServiceEnabled, }, }, - BiosVersion: rf_computersystem.BIOSVersion, + BiosVersion: rf_computersystem.BiosVersion, Links: Links{ Managers: managerLinks, Chassis: chassisLinks, @@ -367,9 +333,9 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass PowerControlIDs: powercontrolIDs, }, Actions: actions, - ProcessorCount: rf_computersystem.ProcessorSummary.Count, + ProcessorCount: derefUint(rf_computersystem.ProcessorSummary.Count), ProcessorType: rf_computersystem.ProcessorSummary.Model, - MemoryTotal: rf_computersystem.MemorySummary.TotalSystemMemoryGiB, + MemoryTotal: derefFloat(rf_computersystem.MemorySummary.TotalSystemMemoryGiB), NodeID: rf_computersystem.ID, } if rf_chassis != nil { @@ -449,7 +415,7 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass // // Parameters: // -// rf_managers - A slice of pointers to redfish.Manager objects representing the Redfish managers to be processed. +// rf_managers - A slice of pointers to schemas.Manager objects representing the Redfish managers to be processed. // baseURI - A string representing the base URI to be used for constructing URIs for the managers and their Ethernet interfaces. // // Returns: @@ -461,7 +427,7 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass // and constructs a Manager object with the relevant details, including Ethernet interface information. // If an error occurs while retrieving Ethernet interfaces, the function logs the error and returns the managers // collected so far along with the error. -func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, error) { +func walkManagers(rf_managers []*schemas.Manager, baseURI string) ([]Manager, error) { var managers []Manager for _, rf_manager := range rf_managers { rf_ethernetinterfaces, err := rf_manager.EthernetInterfaces() @@ -545,18 +511,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 { @@ -572,3 +526,22 @@ func merge(systems map[string]*InventoryDetail, newSystems []InventoryDetail) ma } return systems } + +// derefUint dereferences an optional *uint Redfish field to an int, yielding 0 +// when the BMC omitted the value. gofish v0.22 pointer-ized these optional +// numeric fields; treating nil as 0 preserves the pre-upgrade output. +func derefUint(p *uint) int { + if p == nil { + return 0 + } + return int(*p) +} + +// derefFloat dereferences an optional *float64 Redfish field to a float32, +// yielding 0 when the BMC omitted the value (see derefUint). +func derefFloat(p *float64) float32 { + if p == nil { + return 0 + } + return float32(*p) +} diff --git a/pkg/power/power.go b/pkg/power/power.go index 52cc2806..b9140ce0 100644 --- a/pkg/power/power.go +++ b/pkg/power/power.go @@ -1,6 +1,7 @@ package power import ( + "context" "encoding/json" "fmt" "io" @@ -13,7 +14,7 @@ import ( "github.com/rs/zerolog/log" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) type CrawlableNode struct { @@ -23,12 +24,9 @@ type CrawlableNode struct { } type PowerInfo struct { ClusterID string - State redfish.PowerState + State schemas.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: @@ -76,15 +74,26 @@ func ParseInventory(filename string, dataFormat format.DataFormat) ([]bmc.Node, for i := range inventory { systems := inventory[i].Systems for j := range systems { + // Extract MAC addresses from ethernet interfaces + macs := make([]string, 0, len(systems[j].EthernetInterfaces)) + for _, eth := range systems[j].EthernetInterfaces { + if eth.MAC != "" { + macs = append(macs, eth.MAC) + } + } + nodelist = append(nodelist, bmc.Node{ // TODO: This assumes indices in the Systems list correspond to nodes' "…nX" xname components. // If the list is reordered at any point, or if nodes were missing during crawl, this may not hold! // FIXME: This assumes strict xname formatting! To become xname-agnostic, this should be // replaced with some other cluster-wide ID (which the BMC/ComputerSystem itself won't know, so // it'll have to be generated/looked up from somewhere else). - ClusterID: fmt.Sprintf("%sn%d", inventory[i].ID, j), - BmcIP: inventory[i].FQDN, - NodeID: systems[j].NodeID, + ClusterID: fmt.Sprintf("%sn%d", inventory[i].ID, j), + BmcIP: inventory[i].FQDN, + NodeID: systems[j].NodeID, + UUID: systems[j].UUID, + SerialNumber: systems[j].SerialNumber, + MACAddresses: macs, }) } } @@ -99,30 +108,17 @@ func ParseInventory(filename string, dataFormat format.DataFormat) ([]bmc.Node, // - node: A CrawlableNode struct containing the node's xname, index within the BMC, and a CrawlerConfig to connect to the BMC. // // Returns: -// - []redfish.ResetType: a slice of Redfish reset types supported on the node. +// - []schemas.ResetType: a slice of Redfish reset types supported on the node. // - error: An error object if any error occurs during the connection or reset process. -func GetResetTypes(node CrawlableNode) ([]redfish.ResetType, error) { +func GetResetTypes(ctx context.Context, node CrawlableNode) ([]schemas.ResetType, error) { log.Debug().Msgf("polling %s for reset types", node.ConnConfig.URI) - // Obtain an active client - client, err := GetBMCSession(node.ConnConfig) - if err != nil { - return nil, err - } - - // Determine reset types for the target computer system - rf_systems, err := client.GetService().Systems() + // Obtain an active (cached) vendor-aware client + client, err := bmc.DefaultManager.CachedClient(ctx, node.ConnConfig) 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(ctx, node.NodeID) } // PollBMCPowerStates connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -132,30 +128,17 @@ func GetResetTypes(node CrawlableNode) ([]redfish.ResetType, error) { // - node: A CrawlableNode struct containing the target node's xname, index within the BMC, and a crawler.CrawlerConfig struct. // // Returns: -// - redfish.PowerState: The current power state of the node. (Custom string subtype) +// - schemas.PowerState: The current power state of the node. (Custom string subtype) // - error: An error object if any error occurs during the connection or retrieval process. -func GetPowerState(node CrawlableNode) (redfish.PowerState, error) { +func GetPowerState(ctx context.Context, node CrawlableNode) (schemas.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(ctx, 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(ctx, node.NodeID) } // ResetComputerSystem connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -163,38 +146,45 @@ func GetPowerState(node CrawlableNode) (redfish.PowerState, error) { // // Parameters: // - node: A CrawlableNode struct containing the node's xname, index within the BMC, and a CrawlerConfig to connect to the BMC. -// - resetType: A redfish.ResetType parameter, specifying the manner in which the target ComputerSystem should be reset. +// - resetType: A schemas.ResetType parameter, specifying the manner in which the target ComputerSystem should be reset. // // Returns: +// - *schemas.TaskMonitorInfo: the Redfish task-monitor handle for the reset +// when the BMC models it asynchronously (may be nil for synchronous BMCs). // - error: An error object if any error occurs during the connection or reset process. -func ResetComputerSystem(node CrawlableNode, resetType redfish.ResetType) error { +func ResetComputerSystem(ctx context.Context, node CrawlableNode, resetType schemas.ResetType) (*schemas.TaskMonitorInfo, 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(ctx, node.ConnConfig) if err != nil { - return err + return nil, 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) + return client.Reset(ctx, node.NodeID, resetType) +} + +// ResetOperation connects to a node's BMC and performs a vendor-neutral power +// Operation (e.g. bmc.OpOff), resolving it to a reset type the target advertises +// with the graceful→forced fallback chain. It returns bmc.ErrUnsupportedOperation +// when the operation cannot be satisfied, distinct from a BMC call failure. +// +// Returns: +// - *schemas.TaskMonitorInfo: the Redfish task-monitor handle for the reset +// when the BMC models it asynchronously (may be nil for synchronous BMCs). +// - error: An error object if any error occurs during the connection or reset process. +func ResetOperation(ctx context.Context, node CrawlableNode, op bmc.Operation) (*schemas.TaskMonitorInfo, error) { + log.Debug().Msgf("performing power operation %q on computer system %s", op, node.ClusterID) - // Select the relevant ComputerSystem - rf_systems, err := rf_service.Systems() + // Use a fresh (uncached) vendor-aware client and log out when done. + client, err := bmc.DefaultManager.Client(ctx, node.ConnConfig) 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 - } + return nil, err } + defer client.Logout() - // Reset the system - return rf_compsys.Reset(resetType) + return client.ResetOperation(ctx, node.NodeID, op) } // GetBMCSession returns an already-active gofish BMC client, creating a new one if necessary. @@ -204,23 +194,12 @@ func ResetComputerSystem(node CrawlableNode, resetType redfish.ResetType) error // - config: A CrawlerConfig struct containing the URI, username, password, and other connection details. // // 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 +func GetBMCSession(ctx context.Context, config crawler.CrawlerConfig) (*gofish.APIClient, error) { + client, err := bmc.DefaultManager.CachedClient(ctx, 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 +209,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/secrets/examples/main.go b/pkg/secrets/examples/main.go new file mode 100644 index 00000000..52ab6499 --- /dev/null +++ b/pkg/secrets/examples/main.go @@ -0,0 +1,212 @@ +package main + +// This example demonstrates the usage of the LocalSecretStore to store and retrieve secrets. +// It provides a command-line interface to generate a master key, store secrets, and retrieve them. +// The master key is assumed to be stored in the environment variable MASTER_KEY and while it can +// anything you want, we recommend a 32 bit key for AES-256 encryption. The master key is used +// as part of a Key Derivation Function (KDF) to generate a unique AES key for each secret. +// The algorithm of choice is HMAC-based Extract-and-Expand Key Derivation Function (HKDF). +// Each secret is separately encrypted using AES-GCM and stored in a JSON file. +// The JSON file is loaded into memory when the LocalSecretStore is created and saved back to the file +// when a secret is stored or removed. +// + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/OpenCHAMI/magellan/pkg/secrets" +) + +func usage() { + fmt.Println("Usage:") + fmt.Println(" go run main.go generatekey") + fmt.Println(" - Generates a new 32-byte master key (in hex).") + fmt.Println() + fmt.Println(" Export MASTER_KEY= to use the same key in the next commands.") + fmt.Println() + fmt.Println(" go run main.go store [filename]") + fmt.Println(" - Stores the given string value under secretID.") + fmt.Println() + fmt.Println(" go run main.go storebase64 [filename]") + fmt.Println(" - Decodes the base64-encoded string before storing.") + fmt.Println() + fmt.Println(" go run main.go storejson [filename]") + fmt.Println(" - Stores the provided JSON for the specified secretID.") + fmt.Println() + fmt.Println(" go run main.go retrieve [filename]") + fmt.Println(" - Retrieves and prints the secret value for the given secretID.") + fmt.Println() + fmt.Println(" go run main.go list [filename]") + fmt.Println(" - Lists all the secret IDs and their values.") + fmt.Println() +} + +// openStore tries to create or open the LocalSecretStore based on the environment +// variable MASTER_KEY. If not found, it prints an error. +func openStore(filename string) (*secrets.LocalSecretStore, error) { + masterKey := os.Getenv("MASTER_KEY") + if masterKey == "" { + return nil, fmt.Errorf("MASTER_KEY environment variable not set") + } + + store, err := secrets.NewLocalSecretStore(masterKey, filename, true) + if err != nil { + return nil, fmt.Errorf("cannot open secrets store: %v", err) + } + return store, nil +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + cmd := os.Args[1] + + switch cmd { + case "generatekey": + key, err := secrets.GenerateMasterKey() + if err != nil { + fmt.Printf("Error generating master key: %v\n", err) + os.Exit(1) + } + fmt.Printf("%s\n", key) + + case "store": + if len(os.Args) < 4 { + fmt.Println("Not enough arguments. Usage: go run main.go store [filename]") + os.Exit(1) + } + secretID := os.Args[2] + secretValue := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, secretValue); err != nil { + fmt.Printf("Error storing secret: %v\n", err) + os.Exit(1) + } + fmt.Println("Secret stored successfully.") + + case "storebase64": + if len(os.Args) < 4 { + fmt.Println("Not enough arguments. Usage: go run main.go storebase64 [filename]") + os.Exit(1) + } + secretID := os.Args[2] + base64Value := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + decoded, err := base64.StdEncoding.DecodeString(base64Value) + if err != nil { + fmt.Printf("Error decoding base64 data: %v\n", err) + os.Exit(1) + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, string(decoded)); err != nil { + fmt.Printf("Error storing base64-decoded secret: %v\n", err) + os.Exit(1) + } + fmt.Println("Base64-decoded secret stored successfully.") + + case "storejson": + if len(os.Args) < 4 { + fmt.Println(`Not enough arguments. Usage: go run main.go storejson '{"key":"value"}' [filename]`) + os.Exit(1) + } + secretID := os.Args[2] + jsonValue := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, jsonValue); err != nil { + fmt.Printf("Error storing JSON secret: %v\n", err) + os.Exit(1) + } + fmt.Println("JSON secret stored successfully.") + + case "retrieve": + if len(os.Args) < 3 { + fmt.Println("Not enough arguments. Usage: go run main.go retrieve [filename]") + os.Exit(1) + } + secretID := os.Args[2] + filename := "mysecrets.json" + if len(os.Args) == 4 { + filename = os.Args[3] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secretValue, err := store.GetSecretByID(secretID) + if err != nil { + fmt.Printf("Error retrieving secret: %v\n", err) + os.Exit(1) + } + fmt.Printf("Secret for %s: %s\n", secretID, secretValue) + + case "list": + if len(os.Args) < 2 { + fmt.Println("Not enough arguments. Usage: go run main.go list [filename]") + os.Exit(1) + } + + filename := "mysecrets.json" + if len(os.Args) == 3 { + filename = os.Args[2] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secrets, err := store.ListSecrets() + if err != nil { + fmt.Printf("Error listing secrets: %v\n", err) + os.Exit(1) + } + + fmt.Println("Secrets:") + for key, value := range secrets { + fmt.Printf("%s: %s\n", key, value) + } + + default: + usage() + } + +} diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 00000000..5728fbe3 --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,117 @@ +// 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 ( + "context" + + "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/schemas" +) + +// 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(ctx context.Context, uri, systemID string) (schemas.PowerState, error) { + return power.GetPowerState(ctx, s.crawlableNode(uri, systemID)) +} + +// ResetTypes returns the reset types supported by a ComputerSystem. +func (s *Service) ResetTypes(ctx context.Context, uri, systemID string) ([]schemas.ResetType, error) { + return power.GetResetTypes(ctx, s.crawlableNode(uri, systemID)) +} + +// Reset issues a reset of the given raw Redfish type to a ComputerSystem, +// returning the gofish task-monitor handle when the BMC models it asynchronously. +func (s *Service) Reset(ctx context.Context, uri, systemID string, resetType schemas.ResetType) (*schemas.TaskMonitorInfo, error) { + return power.ResetComputerSystem(ctx, s.crawlableNode(uri, systemID), resetType) +} + +// ResetOperation performs a vendor-neutral power Operation (e.g. bmc.OpOff) on a +// ComputerSystem, resolving it to a supported reset type with the +// graceful→forced fallback chain. It returns bmc.ErrUnsupportedOperation when the +// operation cannot be satisfied by the target's advertised reset types. +func (s *Service) ResetOperation(ctx context.Context, uri, systemID string, op bmc.Operation) (*schemas.TaskMonitorInfo, error) { + return power.ResetOperation(ctx, s.crawlableNode(uri, systemID), op) +} + +// 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 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" + } }` ) diff --git a/pkg/update.go b/pkg/update.go index 55121196..ca45865a 100644 --- a/pkg/update.go +++ b/pkg/update.go @@ -6,15 +6,15 @@ import ( "github.com/OpenCHAMI/magellan/pkg/bmc" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) type UpdateParams struct { CollectParams - URI string // Set from the positional paramters to update - FirmwareURI string // set from the --firmware-url flag - TransferProtocol string // set from the --scheme flag - Insecure bool // set from the --insecure flag + URI string // Set from the positional paramters to update + FirmwareURI string // set from the --firmware-url flag + TransferProtocol string // set from the --scheme flag + Insecure bool // set from the --insecure flag } // UpdateFirmwareRemote() uses 'gofish' to update the firmware of a BMC node. @@ -56,13 +56,15 @@ func UpdateFirmwareRemote(q *UpdateParams) error { } // Build the update request payload - req := redfish.SimpleUpdateParameters{ + req := schemas.UpdateServiceSimpleUpdateParameters{ ImageURI: q.FirmwareURI, - TransferProtocol: redfish.TransferProtocolType(q.TransferProtocol), + TransferProtocol: schemas.TransferProtocolType(q.TransferProtocol), } - // Execute the SimpleUpdate action - err = updateService.SimpleUpdate(&req) + // Execute the SimpleUpdate action. gofish v0.22 returns a *TaskMonitorInfo + // for async tracking; firmware status is tracked separately via + // GetUpdateStatus, so the task handle is not retained here yet. + _, err = updateService.SimpleUpdate(&req) if err != nil { return fmt.Errorf("firmware update failed: %w", err) }