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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 70 additions & 12 deletions cmd/power.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"sync"
"time"

"github.com/OpenCHAMI/magellan/internal/format"
"github.com/OpenCHAMI/magellan/pkg/bmc"
Expand All @@ -14,12 +15,15 @@ import (
"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
waitForConfirm bool
waitTimeout time.Duration
powerFormat format.DataFormat = format.FORMAT_JSON
)

Expand All @@ -29,18 +33,33 @@ var PowerCmd = &cobra.Command{
Use: "power <node-id>...",
Example: ` // get power state
magellan power x1000c0s0b3n0
// perform a particular type of reset
// perform a vendor-neutral power operation (resolved to a supported reset type)
magellan power x1000c0s0b3n0 -o off
magellan power x1000c0s0b3n0 -o hard-restart
// perform a raw Redfish reset type (no validation/fallback)
magellan power x1000c0s0b3n0 -r On
magellan power x1000c0s0b3n0 -r PowerCycle
// list supported reset types
magellan power x1000c0s0b3n0 -l
magellan power x1000c0s0b3n0 --list-reset-types
// 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.",
Run: func(cmd *cobra.Command, args []string) {
// Context for cancellation/deadlines, propagated into the BMC layer.
ctx := cmd.Context()

// Validate the requested operation up front so a bad value fails once,
// clearly, rather than once per target node.
if operation != "" && !bmc.KnownOperation(bmc.Operation(operation)) {
log.Fatal().Msgf("unknown power operation %q (known: %v)", operation, bmc.Operations())
}
if waitForConfirm && operation == "" {
log.Fatal().Msg("--wait requires --operation (raw --reset-type has no confirmable target)")
}

// Read node inventory from CLI flag, or default `collect` YAML output
var datafile string
if viper.IsSet("inventory-file") {
Expand Down Expand Up @@ -145,18 +164,51 @@ var PowerCmd = &cobra.Command{
var action_func func(power.CrawlableNode) string
if list_reset_types {
action_func = func(target power.CrawlableNode) string {
types, err := power.GetResetTypes(target)
types, err := power.GetResetTypes(ctx, 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)
}
} else if operation != "" && waitForConfirm {
// Vendor-neutral operation, confirmed: issue, then poll to the target
// power state (escalating a timed-out graceful op to its forced
// equivalent) within the deadline.
op := bmc.Operation(operation)
opts := bmc.DefaultTransitionOptions()
opts.Timeout = waitTimeout
action_func = func(target power.CrawlableNode) string {
res, err := power.PowerTransition(ctx, target, op, opts)
if err != nil {
log.Error().Err(err).Msgf("failed to perform %q on node %s", operation, target.ClusterID)
return "failure"
}
msg := string(res.Status)
if res.FinalState != "" {
msg += fmt.Sprintf(" (%s)", res.FinalState)
}
if res.Escalated {
msg += fmt.Sprintf(" [escalated to %s]", res.EscalatedTo)
}
return msg
}
} else if operation != "" {
// Vendor-neutral operation: resolved to a supported reset type with
// the graceful→forced fallback chain in the BMC layer.
action_func = func(target power.CrawlableNode) string {
_, err := power.ResetOperation(ctx, target, bmc.Operation(operation))
if err != nil {
log.Error().Err(err).Msgf("failed to perform %q on node %s", operation, target.ClusterID)
return "failure"
}
return "success"
}
} 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))
// Raw Redfish reset type, passed through unresolved. Prefer
// --operation for vendor-neutral semantics with fallbacks.
_, err := power.ResetComputerSystem(ctx, target, schemas.ResetType(reset_type))
if err != nil {
log.Error().Err(err).Msgf("failed to reset node %s", target.ClusterID)
return "failure"
Expand All @@ -165,7 +217,7 @@ var PowerCmd = &cobra.Command{
}
} else {
action_func = func(target power.CrawlableNode) string {
state, err := power.GetPowerState(target)
state, err := power.GetPowerState(ctx, target)
if err != nil {
log.Error().Err(err).Msgf("failed to get power state of node %s", target.ClusterID)
state = "unknown"
Expand Down Expand Up @@ -236,10 +288,16 @@ func concurrent_helper(concurrency int, targets []power.CrawlableNode, runner fu
}

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.Flags().BoolVar(&waitForConfirm, "wait", false, "With --operation, wait until the target reaches its expected power state (escalating a timed-out graceful op)")
PowerCmd.Flags().DurationVar(&waitTimeout, "wait-timeout", bmc.DefaultTimeout, "Maximum time to wait for --wait confirmation")
PowerCmd.MarkFlagsMutuallyExclusive("reset-type", "list-reset-types", "operation")

// Normal config options
PowerCmd.Flags().StringP("inventory-file", "f", "", "YAML file containing node inventory")
Expand Down
30 changes: 30 additions & 0 deletions cmd/power_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 5 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,9 @@ func SetDefaults() {
viper.SetDefault("update.component", "")
viper.SetDefault("update.status", false)
viper.SetDefault("power.cacert", "")
viper.SetDefault("server.host", "")
viper.SetDefault("server.port", 8443)
viper.SetDefault("server.tls-cert", "")
viper.SetDefault("server.tls-key", "")
viper.SetDefault("server.auth-token", "")
}
77 changes: 77 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

import (
"context"
"fmt"
"os/signal"
"syscall"

"github.com/OpenCHAMI/magellan/internal/server"
"github.com/OpenCHAMI/magellan/pkg/secrets"
"github.com/OpenCHAMI/magellan/pkg/service"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

var (
serveHost string
servePort int
serveTLSCert string
serveTLSKey string
serveAuthToken string
)

// ServeCmd runs magellan as a long-lived REST service over the shared BMC core,
// so other OpenCHAMI tools can delegate discovery, inventory, and power
// operations to magellan instead of talking to BMCs directly (RFD #133).
var ServeCmd = &cobra.Command{
Use: "serve",
Short: "Run magellan as a long-lived BMC service (REST API)",
Long: "Run magellan as a persistent daemon exposing a REST API for BMC inventory and\n" +
"power operations, backed by the same shared core the CLI uses. The server runs\n" +
"until it receives SIGINT or SIGTERM, then drains in-flight requests.",
Run: func(cmd *cobra.Command, args []string) {
// Resolve BMC credentials from the local secret store, matching the
// other subcommands. Per-request credentials are a later enhancement.
store, err := secrets.OpenStore(secretsFile)
if err != nil {
log.Warn().Err(err).Str("path", secretsFile).Msg("failed to open secrets store; BMC operations will fail until credentials are available")
}

svc := service.New(store)
svc.Insecure = insecure
defer svc.Close()

srv := server.New(svc, server.Config{
Addr: fmt.Sprintf("%s:%d", serveHost, servePort),
TLSCert: serveTLSCert,
TLSKey: serveTLSKey,
AuthToken: serveAuthToken,
})

// Cancel the run context on SIGINT/SIGTERM to trigger graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

if err := srv.ListenAndServe(ctx); err != nil {
log.Fatal().Err(err).Msg("magellan daemon error")
}
log.Info().Msg("magellan daemon stopped")
},
}

func init() {
ServeCmd.Flags().StringVar(&serveHost, "host", "", "Host/IP to bind (default: all interfaces)")
ServeCmd.Flags().IntVar(&servePort, "port", 8443, "Port to listen on")
ServeCmd.Flags().StringVar(&serveTLSCert, "tls-cert", "", "Path to TLS certificate (enables HTTPS when set with --tls-key)")
ServeCmd.Flags().StringVar(&serveTLSKey, "tls-key", "", "Path to TLS private key")
ServeCmd.Flags().StringVar(&serveAuthToken, "auth-token", "", "Require this bearer token on /v1 routes (auth disabled when empty)")
ServeCmd.Flags().StringVar(&secretsFile, "secrets-file", "", "Path to the node secrets file")
ServeCmd.Flags().BoolVarP(&insecure, "insecure", "i", false, "Ignore BMC TLS verification errors")

checkBindFlagError(viper.BindPFlag("server.host", ServeCmd.Flags().Lookup("host")))
checkBindFlagError(viper.BindPFlag("server.port", ServeCmd.Flags().Lookup("port")))

rootCmd.AddCommand(ServeCmd)
}
61 changes: 26 additions & 35 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
Loading