From dde30c1da98809f17f33234a989b6a84ed1e590e Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 12 Jun 2026 07:43:18 -0400 Subject: [PATCH 1/3] Refactor to upgrade to gofish 0.22.0 with new schemas package - Updated imports from redfish to schemas in various files to align with the new package structure. - Modified Client interface methods to return types from schemas instead of redfish. - Adjusted implementations in GenericClient to accommodate the new schemas types. - Updated tests to reflect changes in the types used for power states and reset types. - Refactored functions in collect, crawler, power, service, and update packages to utilize schemas types. - Introduced helper functions to dereference optional numeric fields for better handling of nil values. Signed-off-by: Alex Lovell-Troy --- cmd/power.go | 4 +- go.mod | 61 ++++++-------- go.sum | 172 ++++++++++++---------------------------- internal/util/net.go | 2 +- pkg/bmc/client.go | 24 +++--- pkg/bmc/client_test.go | 18 ++--- pkg/collect.go | 4 +- pkg/crawler/identify.go | 4 +- pkg/crawler/main.go | 50 ++++++++---- pkg/power/power.go | 16 ++-- pkg/service/service.go | 8 +- pkg/update.go | 20 ++--- 12 files changed, 166 insertions(+), 217 deletions(-) diff --git a/cmd/power.go b/cmd/power.go index b2ef25a1..c59a24c7 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -14,7 +14,7 @@ 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 ( @@ -156,7 +156,7 @@ var PowerCmd = &cobra.Command{ 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)) + err := power.ResetComputerSystem(target, schemas.ResetType(reset_type)) if err != nil { log.Error().Err(err).Msgf("failed to reset node %s", target.ClusterID) return "failure" 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/client.go b/pkg/bmc/client.go index 22f4866c..0ca42d1e 100644 --- a/pkg/bmc/client.go +++ b/pkg/bmc/client.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) // Vendor identifies the manufacturer of a BMC. It is used to dispatch to @@ -41,13 +41,13 @@ type Client interface { // GetPowerState returns the power state of the ComputerSystem with the // given Redfish ID. - GetPowerState(systemID string) (redfish.PowerState, error) + GetPowerState(systemID string) (schemas.PowerState, error) // GetResetTypes returns the reset types supported by the ComputerSystem // with the given Redfish ID. - GetResetTypes(systemID string) ([]redfish.ResetType, error) + GetResetTypes(systemID string) ([]schemas.ResetType, error) // Reset issues a reset of the given type to the ComputerSystem with the // given Redfish ID. - Reset(systemID string, resetType redfish.ResetType) error + Reset(systemID string, resetType schemas.ResetType) error } // GenericClient is the default, vendor-agnostic implementation of Client backed @@ -78,7 +78,7 @@ func (g *GenericClient) Logout() { } // systemByID looks up a ComputerSystem under the ServiceRoot by its Redfish ID. -func (g *GenericClient) systemByID(systemID string) (*redfish.ComputerSystem, error) { +func (g *GenericClient) systemByID(systemID string) (*schemas.ComputerSystem, error) { systems, err := g.api.GetService().Systems() if err != nil { return nil, err @@ -91,7 +91,7 @@ func (g *GenericClient) systemByID(systemID string) (*redfish.ComputerSystem, er return nil, fmt.Errorf("computer system %q not found", systemID) } -func (g *GenericClient) GetPowerState(systemID string) (redfish.PowerState, error) { +func (g *GenericClient) GetPowerState(systemID string) (schemas.PowerState, error) { system, err := g.systemByID(systemID) if err != nil { return "", err @@ -99,20 +99,24 @@ func (g *GenericClient) GetPowerState(systemID string) (redfish.PowerState, erro return system.PowerState, nil } -func (g *GenericClient) GetResetTypes(systemID string) ([]redfish.ResetType, error) { +func (g *GenericClient) GetResetTypes(systemID string) ([]schemas.ResetType, error) { system, err := g.systemByID(systemID) if err != nil { return nil, err } - return system.SupportedResetTypes, nil + return system.GetSupportedResetTypes() } -func (g *GenericClient) Reset(systemID string, resetType redfish.ResetType) error { +func (g *GenericClient) Reset(systemID string, resetType schemas.ResetType) error { system, err := g.systemByID(systemID) if err != nil { return err } - return system.Reset(resetType) + // gofish v0.22's Reset returns a *schemas.TaskMonitorInfo for async tracking; + // the generic client preserves error-only semantics for now. The task handle + // is where confirmation/polling (bugs.md power parity #4) will hook in later. + _, err = system.Reset(resetType) + return err } // ErrUnsupportedQuirk is the canonical "fail loudly" error a vendor plugin (or diff --git a/pkg/bmc/client_test.go b/pkg/bmc/client_test.go index 447d1cc9..1b95be5c 100644 --- a/pkg/bmc/client_test.go +++ b/pkg/bmc/client_test.go @@ -8,7 +8,7 @@ import ( "github.com/OpenCHAMI/magellan/pkg/test" "github.com/go-chi/chi/v5" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) // newMockGenericClient stands up an in-memory Redfish service exposing a single @@ -47,8 +47,8 @@ func TestGenericClientPowerStateFound(t *testing.T) { if err != nil { t.Fatalf("GetPowerState(Node0) unexpected error: %v", err) } - if state != redfish.OnPowerState { - t.Fatalf("GetPowerState(Node0) = %q, want %q", state, redfish.OnPowerState) + if state != schemas.OnPowerState { + t.Fatalf("GetPowerState(Node0) = %q, want %q", state, schemas.OnPowerState) } } @@ -58,11 +58,11 @@ func TestGenericClientResetTypesFound(t *testing.T) { if err != nil { t.Fatalf("GetResetTypes(Node0) unexpected error: %v", err) } - want := map[redfish.ResetType]bool{ - redfish.OnResetType: true, - redfish.ForceOffResetType: true, - redfish.GracefulShutdownResetType: true, - redfish.ForceRestartResetType: true, + 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)) @@ -86,7 +86,7 @@ func TestGenericClientSystemNotFound(t *testing.T) { if _, err := c.GetResetTypes("bogus"); err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("GetResetTypes(bogus) err = %v, want a 'not found' error", err) } - if err := c.Reset("bogus", redfish.OnResetType); err == nil || !strings.Contains(err.Error(), "not found") { + if err := c.Reset("bogus", schemas.OnResetType); err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("Reset(bogus) err = %v, want a 'not found' error", err) } } diff --git a/pkg/collect.go b/pkg/collect.go index dad56962..48ca8f74 100644 --- a/pkg/collect.go +++ b/pkg/collect.go @@ -21,7 +21,7 @@ import ( "github.com/rs/zerolog/log" _ "github.com/mattn/go-sqlite3" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" "golang.org/x/exp/slices" ) @@ -264,7 +264,7 @@ func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string 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 0516fbd6..14f35b1e 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -6,7 +6,7 @@ import ( "github.com/OpenCHAMI/magellan/pkg/bmc" "github.com/rs/zerolog/log" "github.com/stmcginnis/gofish" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) // CrawlerConfig is an alias for bmc.ConnConfig, the canonical BMC connection @@ -130,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) @@ -229,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: @@ -246,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 ) @@ -293,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)) } @@ -308,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, @@ -332,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 { @@ -414,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: @@ -426,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() @@ -525,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 78535364..bedc74dd 100644 --- a/pkg/power/power.go +++ b/pkg/power/power.go @@ -13,7 +13,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,7 +23,7 @@ type CrawlableNode struct { } type PowerInfo struct { ClusterID string - State redfish.PowerState + State schemas.PowerState } // ParseInventory reads parameters relevant to power control from the kind of YAML file generated by the `collect` command. @@ -96,9 +96,9 @@ 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(node CrawlableNode) ([]schemas.ResetType, error) { log.Debug().Msgf("polling %s for reset types", node.ConnConfig.URI) // Obtain an active (cached) vendor-aware client @@ -116,9 +116,9 @@ 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(node CrawlableNode) (schemas.PowerState, error) { log.Debug().Msgf("polling %s for power states", node.ConnConfig.URI) // Obtain an active (cached) vendor-aware client @@ -134,11 +134,11 @@ 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: // - error: An error object if any error occurs during the connection or reset process. -func ResetComputerSystem(node CrawlableNode, resetType redfish.ResetType) error { +func ResetComputerSystem(node CrawlableNode, resetType schemas.ResetType) error { log.Debug().Msgf("resetting computer system %s: %s", node.ClusterID, resetType) // Use a fresh (uncached) vendor-aware client and log out when done. diff --git a/pkg/service/service.go b/pkg/service/service.go index 9c12cc67..55f4945e 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -13,7 +13,7 @@ import ( "github.com/OpenCHAMI/magellan/pkg/crawler" "github.com/OpenCHAMI/magellan/pkg/power" "github.com/OpenCHAMI/magellan/pkg/secrets" - "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/schemas" ) // Service is the shared BMC interaction core. A single instance is intended to @@ -82,17 +82,17 @@ func (s *Service) crawlableNode(uri, systemID string) power.CrawlableNode { } // PowerState returns the current power state of a ComputerSystem. -func (s *Service) PowerState(uri, systemID string) (redfish.PowerState, error) { +func (s *Service) PowerState(uri, systemID string) (schemas.PowerState, error) { return power.GetPowerState(s.crawlableNode(uri, systemID)) } // ResetTypes returns the reset types supported by a ComputerSystem. -func (s *Service) ResetTypes(uri, systemID string) ([]redfish.ResetType, error) { +func (s *Service) ResetTypes(uri, systemID string) ([]schemas.ResetType, error) { return power.GetResetTypes(s.crawlableNode(uri, systemID)) } // Reset issues a reset of the given type to a ComputerSystem. -func (s *Service) Reset(uri, systemID string, resetType redfish.ResetType) error { +func (s *Service) Reset(uri, systemID string, resetType schemas.ResetType) error { return power.ResetComputerSystem(s.crawlableNode(uri, systemID), resetType) } 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) } From 6793a86f713eb5d383d4dabe8b5f2c05d75b955e Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 12 Jun 2026 08:20:26 -0400 Subject: [PATCH 2/3] feat: implement context-aware power operations and reset handling in BMC layer Signed-off-by: Alex Lovell-Troy --- cmd/power.go | 50 +++++++++++++---- cmd/power_test.go | 30 ++++++++++ pkg/bmc/client.go | 76 ++++++++++++++++++++------ pkg/bmc/client_test.go | 65 ++++++++++++++++++++-- pkg/bmc/manager.go | 34 +++++++++--- pkg/bmc/manager_test.go | 13 +++-- pkg/bmc/operation.go | 112 ++++++++++++++++++++++++++++++++++++++ pkg/bmc/operation_test.go | 108 ++++++++++++++++++++++++++++++++++++ pkg/power/power.go | 49 +++++++++++++---- pkg/service/service.go | 25 ++++++--- 10 files changed, 495 insertions(+), 67 deletions(-) create mode 100644 cmd/power_test.go create mode 100644 pkg/bmc/operation.go create mode 100644 pkg/bmc/operation_test.go diff --git a/cmd/power.go b/cmd/power.go index c59a24c7..0503d10f 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -20,6 +20,7 @@ import ( var ( list_reset_types bool reset_type string + operation string powerFormat format.DataFormat = format.FORMAT_JSON ) @@ -29,11 +30,14 @@ var PowerCmd = &cobra.Command{ Use: "power ...", 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 @@ -41,6 +45,15 @@ var PowerCmd = &cobra.Command{ 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()) + } + // Read node inventory from CLI flag, or default `collect` YAML output var datafile string if viper.IsSet("inventory-file") { @@ -145,18 +158,29 @@ 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 != "" { + // 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, schemas.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" @@ -165,7 +189,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" @@ -236,10 +260,14 @@ 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.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/pkg/bmc/client.go b/pkg/bmc/client.go index 0ca42d1e..9cb1aeef 100644 --- a/pkg/bmc/client.go +++ b/pkg/bmc/client.go @@ -1,6 +1,7 @@ package bmc import ( + "context" "fmt" "github.com/stmcginnis/gofish" @@ -31,6 +32,14 @@ const ( // 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 @@ -41,13 +50,20 @@ type Client interface { // GetPowerState returns the power state of the ComputerSystem with the // given Redfish ID. - GetPowerState(systemID string) (schemas.PowerState, error) + GetPowerState(ctx context.Context, systemID string) (schemas.PowerState, error) // GetResetTypes returns the reset types supported by the ComputerSystem // with the given Redfish ID. - GetResetTypes(systemID string) ([]schemas.ResetType, error) + 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. - Reset(systemID string, resetType schemas.ResetType) error + // 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 @@ -78,7 +94,11 @@ func (g *GenericClient) Logout() { } // systemByID looks up a ComputerSystem under the ServiceRoot by its Redfish ID. -func (g *GenericClient) systemByID(systemID string) (*schemas.ComputerSystem, error) { +// 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 @@ -91,32 +111,54 @@ func (g *GenericClient) systemByID(systemID string) (*schemas.ComputerSystem, er return nil, fmt.Errorf("computer system %q not found", systemID) } -func (g *GenericClient) GetPowerState(systemID string) (schemas.PowerState, error) { - system, err := g.systemByID(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(systemID string) ([]schemas.ResetType, error) { - system, err := g.systemByID(systemID) +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(systemID string, resetType schemas.ResetType) error { - system, err := g.systemByID(systemID) +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 err + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err } - // gofish v0.22's Reset returns a *schemas.TaskMonitorInfo for async tracking; - // the generic client preserves error-only semantics for now. The task handle - // is where confirmation/polling (bugs.md power parity #4) will hook in later. - _, err = system.Reset(resetType) - return err + return system.Reset(resetType) } // ErrUnsupportedQuirk is the canonical "fail loudly" error a vendor plugin (or diff --git a/pkg/bmc/client_test.go b/pkg/bmc/client_test.go index 1b95be5c..c8ceae5a 100644 --- a/pkg/bmc/client_test.go +++ b/pkg/bmc/client_test.go @@ -1,6 +1,9 @@ package bmc import ( + "context" + "io" + "net/http" "net/http/httptest" "strings" "testing" @@ -43,7 +46,7 @@ func newMockGenericClient(t *testing.T) *GenericClient { func TestGenericClientPowerStateFound(t *testing.T) { c := newMockGenericClient(t) - state, err := c.GetPowerState("Node0") + state, err := c.GetPowerState(context.Background(), "Node0") if err != nil { t.Fatalf("GetPowerState(Node0) unexpected error: %v", err) } @@ -54,7 +57,7 @@ func TestGenericClientPowerStateFound(t *testing.T) { func TestGenericClientResetTypesFound(t *testing.T) { c := newMockGenericClient(t) - got, err := c.GetResetTypes("Node0") + got, err := c.GetResetTypes(context.Background(), "Node0") if err != nil { t.Fatalf("GetResetTypes(Node0) unexpected error: %v", err) } @@ -79,16 +82,68 @@ func TestGenericClientResetTypesFound(t *testing.T) { // dereferenced a nil system here. func TestGenericClientSystemNotFound(t *testing.T) { c := newMockGenericClient(t) + ctx := context.Background() - if _, err := c.GetPowerState("bogus"); err == nil || !strings.Contains(err.Error(), "not found") { + 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("bogus"); err == nil || !strings.Contains(err.Error(), "not found") { + 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("bogus", schemas.OnResetType); err == nil || !strings.Contains(err.Error(), "not found") { + 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) { diff --git a/pkg/bmc/manager.go b/pkg/bmc/manager.go index 7dd1cc7d..0e766d45 100644 --- a/pkg/bmc/manager.go +++ b/pkg/bmc/manager.go @@ -1,6 +1,7 @@ package bmc import ( + "context" "fmt" "strings" "sync" @@ -26,17 +27,20 @@ func NewManager() *Manager { // daemon may construct its own Manager instead. var DefaultManager = NewManager() -// Connect opens a new, uncached gofish session to the BMC described by cfg. This -// is the single point in the codebase where gofish.Connect is invoked; all 404 -// and 401 errors are decorated here for consistent messaging. -func (m *Manager) Connect(cfg ConnConfig) (*gofish.APIClient, error) { +// 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.Connect(gofish.ClientConfig{ + api, err := gofish.ConnectContext(ctx, gofish.ClientConfig{ Endpoint: cfg.URI, Username: creds.Username, Password: creds.Password, @@ -56,10 +60,17 @@ func (m *Manager) Connect(cfg ConnConfig) (*gofish.APIClient, error) { 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(cfg ConnConfig) (Client, error) { - api, err := m.Connect(cfg) +func (m *Manager) Client(ctx context.Context, cfg ConnConfig) (Client, error) { + api, err := m.ConnectContext(ctx, cfg) if err != nil { return nil, err } @@ -69,14 +80,19 @@ func (m *Manager) Client(cfg ConnConfig) (Client, error) { // CachedClient returns a vendor-aware Client for the BMC, creating and caching a // new session keyed by URI if one does not already exist. Cached sessions are // kept open for efficiency and released together by LogoutAll. -func (m *Manager) CachedClient(cfg ConnConfig) (Client, error) { +// +// 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.Connect(cfg) + api, err := m.ConnectContext(ctx, cfg) if err != nil { return nil, err } diff --git a/pkg/bmc/manager_test.go b/pkg/bmc/manager_test.go index 07fccfac..b0ea9a0e 100644 --- a/pkg/bmc/manager_test.go +++ b/pkg/bmc/manager_test.go @@ -1,6 +1,7 @@ package bmc_test import ( + "context" "net/http" "net/http/httptest" "strings" @@ -38,11 +39,11 @@ func TestManagerCachedClientReusesSession(t *testing.T) { cfg := mockServer(t, test.RESPONSE_ServiceRoot) m := bmc.NewManager() - c1, err := m.CachedClient(cfg) + c1, err := m.CachedClient(context.Background(), cfg) if err != nil { t.Fatalf("CachedClient: %v", err) } - c2, err := m.CachedClient(cfg) + c2, err := m.CachedClient(context.Background(), cfg) if err != nil { t.Fatalf("CachedClient (2nd): %v", err) } @@ -52,7 +53,7 @@ func TestManagerCachedClientReusesSession(t *testing.T) { // LogoutAll must evict, so the next CachedClient builds a fresh session. m.LogoutAll() - c3, err := m.CachedClient(cfg) + c3, err := m.CachedClient(context.Background(), cfg) if err != nil { t.Fatalf("CachedClient after LogoutAll: %v", err) } @@ -65,11 +66,11 @@ func TestManagerClientIsUncached(t *testing.T) { cfg := mockServer(t, test.RESPONSE_ServiceRoot) m := bmc.NewManager() - c1, err := m.Client(cfg) + c1, err := m.Client(context.Background(), cfg) if err != nil { t.Fatalf("Client: %v", err) } - c2, err := m.Client(cfg) + c2, err := m.Client(context.Background(), cfg) if err != nil { t.Fatalf("Client (2nd): %v", err) } @@ -83,7 +84,7 @@ func TestManagerClientIsUncached(t *testing.T) { // HPE plugin, not the generic fallback. func TestManagerDispatchesToVendorPlugin(t *testing.T) { cfg := mockServer(t, test.RESPONSE_ServiceRoot_HPE) - c, err := bmc.NewManager().Client(cfg) + c, err := bmc.NewManager().Client(context.Background(), cfg) if err != nil { t.Fatalf("Client: %v", 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/power/power.go b/pkg/power/power.go index bedc74dd..24a0a979 100644 --- a/pkg/power/power.go +++ b/pkg/power/power.go @@ -1,6 +1,7 @@ package power import ( + "context" "encoding/json" "fmt" "io" @@ -98,15 +99,15 @@ func ParseInventory(filename string, dataFormat format.DataFormat) ([]bmc.Node, // Returns: // - []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) ([]schemas.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 (cached) vendor-aware client - client, err := bmc.DefaultManager.CachedClient(node.ConnConfig) + client, err := bmc.DefaultManager.CachedClient(ctx, node.ConnConfig) if err != nil { return nil, err } - return client.GetResetTypes(node.NodeID) + return client.GetResetTypes(ctx, node.NodeID) } // PollBMCPowerStates connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -118,15 +119,15 @@ func GetResetTypes(node CrawlableNode) ([]schemas.ResetType, error) { // Returns: // - 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) (schemas.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 (cached) vendor-aware client - client, err := bmc.DefaultManager.CachedClient(node.ConnConfig) + client, err := bmc.DefaultManager.CachedClient(ctx, node.ConnConfig) if err != nil { return "", err } - return client.GetPowerState(node.NodeID) + return client.GetPowerState(ctx, node.NodeID) } // ResetComputerSystem connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -137,18 +138,42 @@ func GetPowerState(node CrawlableNode) (schemas.PowerState, error) { // - 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 schemas.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) // Use a fresh (uncached) vendor-aware client and log out when done. - client, err := bmc.DefaultManager.Client(node.ConnConfig) + client, err := bmc.DefaultManager.Client(ctx, node.ConnConfig) if err != nil { - return err + return nil, err + } + defer client.Logout() + + 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) + + // Use a fresh (uncached) vendor-aware client and log out when done. + client, err := bmc.DefaultManager.Client(ctx, node.ConnConfig) + if err != nil { + return nil, err } defer client.Logout() - return client.Reset(node.NodeID, resetType) + return client.ResetOperation(ctx, node.NodeID, op) } // GetBMCSession returns an already-active gofish BMC client, creating a new one if necessary. @@ -158,8 +183,8 @@ func ResetComputerSystem(node CrawlableNode, resetType schemas.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, err := bmc.DefaultManager.CachedClient(config) +func GetBMCSession(ctx context.Context, config crawler.CrawlerConfig) (*gofish.APIClient, error) { + client, err := bmc.DefaultManager.CachedClient(ctx, config) if err != nil { return nil, err } diff --git a/pkg/service/service.go b/pkg/service/service.go index 55f4945e..5728fbe3 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -7,6 +7,8 @@ package service import ( + "context" + "github.com/OpenCHAMI/magellan/internal/format" magellan "github.com/OpenCHAMI/magellan/pkg" "github.com/OpenCHAMI/magellan/pkg/bmc" @@ -82,18 +84,27 @@ func (s *Service) crawlableNode(uri, systemID string) power.CrawlableNode { } // PowerState returns the current power state of a ComputerSystem. -func (s *Service) PowerState(uri, systemID string) (schemas.PowerState, error) { - return power.GetPowerState(s.crawlableNode(uri, systemID)) +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(uri, systemID string) ([]schemas.ResetType, error) { - return power.GetResetTypes(s.crawlableNode(uri, systemID)) +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) } -// Reset issues a reset of the given type to a ComputerSystem. -func (s *Service) Reset(uri, systemID string, resetType schemas.ResetType) error { - return power.ResetComputerSystem(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. From 3fb41855b18b7dfad373a186f4fa8b1db7be3c89 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 12 Jun 2026 08:53:53 -0400 Subject: [PATCH 3/3] Implement REST API for power management and middleware enhancements - Add middleware for request logging and bearer token authentication in the server. - Create response handling functions for JSON responses and error messages. - Develop the main server structure to handle API routes for inventory and power operations. - Implement health check endpoints for liveness and readiness. - Introduce a mock Redfish service for testing power state transitions. - Add power transition logic with confirmation and escalation handling. - Create tests for various power operations, including success, timeout, and escalation scenarios. - Enhance service layer to support power transition operations. Signed-off-by: Alex Lovell-Troy --- cmd/power.go | 30 ++++ cmd/root.go | 5 + cmd/serve.go | 77 ++++++++++ internal/server/handlers.go | 224 +++++++++++++++++++++++++++++ internal/server/middleware.go | 51 +++++++ internal/server/response.go | 31 ++++ internal/server/server.go | 115 +++++++++++++++ internal/server/server_test.go | 256 +++++++++++++++++++++++++++++++++ pkg/bmc/transition.go | 242 +++++++++++++++++++++++++++++++ pkg/bmc/transition_test.go | 222 ++++++++++++++++++++++++++++ pkg/power/power.go | 22 +++ pkg/service/service.go | 9 ++ 12 files changed, 1284 insertions(+) create mode 100644 cmd/serve.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/middleware.go create mode 100644 internal/server/response.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 pkg/bmc/transition.go create mode 100644 pkg/bmc/transition_test.go diff --git a/cmd/power.go b/cmd/power.go index 0503d10f..6b120d49 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/OpenCHAMI/magellan/internal/format" "github.com/OpenCHAMI/magellan/pkg/bmc" @@ -21,6 +22,8 @@ var ( list_reset_types bool reset_type string operation string + waitForConfirm bool + waitTimeout time.Duration powerFormat format.DataFormat = format.FORMAT_JSON ) @@ -53,6 +56,9 @@ var PowerCmd = &cobra.Command{ 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 @@ -165,6 +171,28 @@ var PowerCmd = &cobra.Command{ } 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. @@ -267,6 +295,8 @@ func init() { 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 diff --git a/cmd/root.go b/cmd/root.go index b05ee592..efec005d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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", "") } diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 00000000..6b9d3594 --- /dev/null +++ b/cmd/serve.go @@ -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) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 00000000..ffa860e0 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,224 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/OpenCHAMI/magellan/pkg/bmc" + "github.com/stmcginnis/gofish/schemas" +) + +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + +// handleInventory crawls a single BMC for its systems and managers. +// Request body: {"bmc": "https://..."}. +func (s *Server) handleInventory(w http.ResponseWriter, r *http.Request) { + var req struct { + BMC string `json:"bmc"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.BMC == "" { + writeError(w, http.StatusBadRequest, "field 'bmc' is required") + return + } + systems, managers, err := s.svc.Inventory(req.BMC) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "bmc": req.BMC, + "systems": systems, + "managers": managers, + }) +} + +// handlePowerState returns the power state of a ComputerSystem. +// Query: ?bmc=&system=. +func (s *Server) handlePowerState(w http.ResponseWriter, r *http.Request) { + bmcURI, systemID, ok := powerTarget(w, r) + if !ok { + return + } + state, err := s.svc.PowerState(r.Context(), bmcURI, systemID) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "bmc": bmcURI, + "system": systemID, + "powerState": state, + }) +} + +// handleResetTypes returns the reset types a ComputerSystem advertises. +// Query: ?bmc=&system=. +func (s *Server) handleResetTypes(w http.ResponseWriter, r *http.Request) { + bmcURI, systemID, ok := powerTarget(w, r) + if !ok { + return + } + types, err := s.svc.ResetTypes(r.Context(), bmcURI, systemID) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "bmc": bmcURI, + "system": systemID, + "resetTypes": types, + }) +} + +// powerActionRequest is the body for POST /v1/power. +type powerActionRequest struct { + BMC string `json:"bmc"` + System string `json:"system"` + // Exactly one of Operation (vendor-neutral, with fallback/escalation) or + // ResetType (raw Redfish token) must be set. + Operation string `json:"operation,omitempty"` + ResetType string `json:"resetType,omitempty"` + // Wait confirms the operation reached its target power state (operations + // only; not valid with a raw ResetType). + Wait bool `json:"wait,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +// handlePowerAction issues a power operation or raw reset, optionally confirming +// the resulting power state. +func (s *Server) handlePowerAction(w http.ResponseWriter, r *http.Request) { + var req powerActionRequest + if !decodeJSON(w, r, &req) { + return + } + if req.BMC == "" || req.System == "" { + writeError(w, http.StatusBadRequest, "fields 'bmc' and 'system' are required") + return + } + if (req.Operation == "") == (req.ResetType == "") { + writeError(w, http.StatusBadRequest, "exactly one of 'operation' or 'resetType' is required") + return + } + ctx := r.Context() + + // Raw reset type: passed through unresolved; confirmation is not supported + // because a raw reset has no vendor-neutral target state. + if req.ResetType != "" { + if req.Wait { + writeError(w, http.StatusBadRequest, "'wait' is only supported with 'operation', not raw 'resetType'") + return + } + if _, err := s.svc.Reset(ctx, req.BMC, req.System, schemas.ResetType(req.ResetType)); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"issued": true, "resetType": req.ResetType}) + return + } + + // Vendor-neutral operation. + op := bmc.Operation(req.Operation) + if !bmc.KnownOperation(op) { + writeError(w, http.StatusBadRequest, "unknown operation; known: "+operationsList()) + return + } + + if req.Wait { + opts := bmc.DefaultTransitionOptions() + if req.TimeoutSeconds > 0 { + opts.Timeout = time.Duration(req.TimeoutSeconds) * time.Second + } + res, err := s.svc.PowerTransition(ctx, req.BMC, req.System, op, opts) + if err != nil { + writePowerError(w, err) + return + } + status := http.StatusOK + if !res.Confirmed() { + // Issued but not confirmed (timed-out / unconfirmable): 202 conveys + // "accepted, outcome not (yet) confirmed". + status = http.StatusAccepted + } + writeJSON(w, status, map[string]any{ + "operation": res.Operation, + "status": res.Status, + "finalState": res.FinalState, + "escalated": res.Escalated, + "escalatedTo": res.EscalatedTo, + }) + return + } + + if _, err := s.svc.ResetOperation(ctx, req.BMC, req.System, op); err != nil { + writePowerError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"issued": true, "operation": req.Operation}) +} + +// powerTarget extracts and validates the bmc/system query parameters shared by +// the power read endpoints. +func powerTarget(w http.ResponseWriter, r *http.Request) (bmcURI, systemID string, ok bool) { + bmcURI = r.URL.Query().Get("bmc") + systemID = r.URL.Query().Get("system") + if bmcURI == "" || systemID == "" { + writeError(w, http.StatusBadRequest, "query parameters 'bmc' and 'system' are required") + return "", "", false + } + return bmcURI, systemID, true +} + +// writePowerError maps service-layer power errors to HTTP status codes, +// distinguishing an unsupported operation (a client/target mismatch) from a +// generic BMC failure. +func writePowerError(w http.ResponseWriter, err error) { + var unsupported *bmc.ErrUnsupportedOperation + if errors.As(err, &unsupported) { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + writeError(w, http.StatusBadGateway, err.Error()) +} + +func operationsList() string { + ops := bmc.Operations() + parts := make([]string, len(ops)) + for i, op := range ops { + parts[i] = string(op) + } + return joinComma(parts) +} + +func joinComma(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += ", " + } + out += p + } + return out +} + +// decodeJSON decodes a JSON request body, writing a 400 on failure. It rejects +// unknown fields so malformed requests fail loudly rather than silently. +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return false + } + return true +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 00000000..f68dcf39 --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,51 @@ +package server + +import ( + "crypto/subtle" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog/log" +) + +// requestLogger emits one structured zerolog line per request. It is the +// foundation of the RFD #133 audit trail: it records the requestor, endpoint, +// outcome, and latency of every API call, including the BMC-affecting ones. +func (s *Server) requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + defer func() { + log.Info(). + Str("method", r.Method). + Str("path", r.URL.RequestURI()). + Int("status", ww.Status()). + Int("bytes", ww.BytesWritten()). + Dur("duration", time.Since(start)). + Str("requestor", r.RemoteAddr). + Str("request_id", middleware.GetReqID(r.Context())). + Msg("api request") + }() + next.ServeHTTP(ww, r) + }) +} + +// requireBearer enforces a static bearer token on protected routes. It is a +// deliberately simple Phase-1 gate (suitable for a token shared with trusted +// callers or injected by a gateway); JWT validation via pkg/auth is a later +// enhancement. The comparison is constant-time to avoid leaking the token. +func (s *Server) requireBearer(next http.Handler) http.Handler { + const prefix = "Bearer " + want := []byte(s.cfg.AuthToken) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, prefix) || + subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(auth, prefix)), want) != 1 { + writeError(w, http.StatusUnauthorized, "missing or invalid bearer token") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/response.go b/internal/server/response.go new file mode 100644 index 00000000..d2ce4554 --- /dev/null +++ b/internal/server/response.go @@ -0,0 +1,31 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/rs/zerolog/log" +) + +// errorResponse is the JSON body returned for any non-2xx API response. +type errorResponse struct { + Error string `json:"error"` +} + +// writeJSON writes v as a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + // The status/headers are already sent; just record the failure. + log.Error().Err(err).Msg("failed to encode JSON response") + } +} + +// writeError writes a JSON error body with the given status code. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorResponse{Error: msg}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 00000000..a8f1d3dc --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,115 @@ +// Package server exposes magellan's shared BMC service core (pkg/service) over a +// REST API, so other OpenCHAMI tools can delegate discovery, inventory, and +// power operations to a long-lived magellan daemon instead of each talking to +// BMCs directly (RFD #133). The CLI and this daemon drive the same in-process +// service.Service, so behavior stays identical across front-ends. +package server + +import ( + "context" + "net/http" + "time" + + "github.com/OpenCHAMI/magellan/pkg/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog/log" +) + +// DefaultRequestTimeout bounds how long a single API request may run before the +// server cancels its context. BMC operations can be slow, so it is generous. +const DefaultRequestTimeout = 120 * time.Second + +// shutdownGrace bounds how long graceful shutdown waits for in-flight requests. +const shutdownGrace = 15 * time.Second + +// Config configures the daemon HTTP server. +type Config struct { + // Addr is the listen address, e.g. ":8443". + Addr string + // TLSCert and TLSKey, when both set, enable HTTPS. + TLSCert string + TLSKey string + // AuthToken, when non-empty, requires a matching `Authorization: Bearer` + // token on all /v1 routes. Empty disables auth (suitable behind a gateway). + AuthToken string + // RequestTimeout overrides DefaultRequestTimeout when > 0. + RequestTimeout time.Duration +} + +// Server is the magellan REST daemon. It is a thin HTTP front-end over a +// service.Service; it holds no BMC logic of its own. +type Server struct { + svc *service.Service + cfg Config + handler http.Handler +} + +// New builds a Server over the given service core. +func New(svc *service.Service, cfg Config) *Server { + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = DefaultRequestTimeout + } + s := &Server{svc: svc, cfg: cfg} + s.handler = s.routes() + return s +} + +// Handler returns the server's HTTP handler, primarily for testing. +func (s *Server) Handler() http.Handler { return s.handler } + +func (s *Server) routes() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(s.requestLogger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(s.cfg.RequestTimeout)) + + // Liveness/readiness are unauthenticated so orchestrators can probe them. + r.Get("/healthz", s.handleHealthz) + r.Get("/readyz", s.handleReadyz) + + r.Route("/v1", func(r chi.Router) { + if s.cfg.AuthToken != "" { + r.Use(s.requireBearer) + } + r.Post("/inventory", s.handleInventory) + r.Get("/power", s.handlePowerState) + r.Get("/power/reset-types", s.handleResetTypes) + r.Post("/power", s.handlePowerAction) + }) + + return r +} + +// ListenAndServe runs the server until ctx is cancelled, then drains in-flight +// requests within shutdownGrace. It serves HTTPS when TLS cert/key are set. +func (s *Server) ListenAndServe(ctx context.Context) error { + httpSrv := &http.Server{Addr: s.cfg.Addr, Handler: s.handler} + + errCh := make(chan error, 1) + go func() { + tls := s.cfg.TLSCert != "" && s.cfg.TLSKey != "" + log.Info().Str("addr", s.cfg.Addr).Bool("tls", tls).Bool("auth", s.cfg.AuthToken != "").Msg("magellan daemon listening") + var err error + if tls { + err = httpSrv.ListenAndServeTLS(s.cfg.TLSCert, s.cfg.TLSKey) + } else { + err = httpSrv.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Info().Msg("shutting down magellan daemon") + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 00000000..24d46311 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,256 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/OpenCHAMI/magellan/internal/server" + // Register vendor plugins so the service's manager can dispatch (and to + // exercise the same wiring the daemon uses in production). + _ "github.com/OpenCHAMI/magellan/pkg/bmc/vendors" + "github.com/OpenCHAMI/magellan/pkg/secrets" + "github.com/OpenCHAMI/magellan/pkg/service" + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/go-chi/chi/v5" + "github.com/stmcginnis/gofish/schemas" +) + +func systemJSON(state schemas.PowerState) string { + return fmt.Sprintf(`{ + "@odata.id": "/redfish/v1/Systems/Node0", + "@odata.type": "#ComputerSystem.v1_5_0.ComputerSystem", + "Id": "Node0", "Name": "Node0", "PowerState": "%s", + "Actions": { "#ComputerSystem.Reset": { + "ResetType@Redfish.AllowableValues": ["On","ForceOff","GracefulShutdown","ForceRestart"], + "target": "/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset" + }} + }`, state) +} + +// newMockRedfish stands up a stateful single-system Redfish service whose power +// state reacts to reset actions. +func newMockRedfish(t *testing.T, initial schemas.PowerState) *httptest.Server { + t.Helper() + var ( + mu sync.Mutex + state = initial + ) + 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", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + s := state + mu.Unlock() + w.Write([]byte(systemJSON(s))) + }) + mux.HandleFunc("/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) { + var b struct{ ResetType string } + _ = json.NewDecoder(r.Body).Decode(&b) + mu.Lock() + switch schemas.ResetType(b.ResetType) { + case schemas.OnResetType, schemas.ForceOnResetType: + state = schemas.OnPowerState + case schemas.ForceOffResetType, schemas.GracefulShutdownResetType: + state = schemas.OffPowerState + } + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newTestHandler(t *testing.T, cfg server.Config) http.Handler { + t.Helper() + svc := service.New(secrets.NewStaticStore("test", "test")) + svc.Insecure = true + t.Cleanup(svc.Close) + return server.New(svc, cfg).Handler() +} + +// do issues an in-process request against the handler. +func do(t *testing.T, h http.Handler, method, target string, body any, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + req = httptest.NewRequest(method, target, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, target, nil) + } + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode response %q: %v", rec.Body.String(), err) + } + return m +} + +func powerQuery(base, bmcURI, system string) string { + q := url.Values{} + q.Set("bmc", bmcURI) + q.Set("system", system) + return base + "?" + q.Encode() +} + +func TestHealthz(t *testing.T) { + h := newTestHandler(t, server.Config{}) + rec := do(t, h, http.MethodGet, "/healthz", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("/healthz status = %d, want 200", rec.Code) + } + if got := decodeBody(t, rec)["status"]; got != "ok" { + t.Fatalf("/healthz status field = %v, want ok", got) + } +} + +func TestPowerStateEndpoint(t *testing.T) { + mock := newMockRedfish(t, schemas.OnPowerState) + h := newTestHandler(t, server.Config{}) + + rec := do(t, h, http.MethodGet, powerQuery("/v1/power", mock.URL, "Node0"), nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got := decodeBody(t, rec)["powerState"]; got != string(schemas.OnPowerState) { + t.Fatalf("powerState = %v, want On", got) + } +} + +func TestPowerStateMissingParams(t *testing.T) { + h := newTestHandler(t, server.Config{}) + rec := do(t, h, http.MethodGet, "/v1/power?bmc=https://x", nil, nil) // no system + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestResetTypesEndpoint(t *testing.T) { + mock := newMockRedfish(t, schemas.OnPowerState) + h := newTestHandler(t, server.Config{}) + + rec := do(t, h, http.MethodGet, powerQuery("/v1/power/reset-types", mock.URL, "Node0"), nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + types, ok := decodeBody(t, rec)["resetTypes"].([]any) + if !ok || len(types) == 0 { + t.Fatalf("resetTypes = %v, want a non-empty list", decodeBody(t, rec)["resetTypes"]) + } +} + +func TestPowerActionOperationNoWait(t *testing.T) { + mock := newMockRedfish(t, schemas.OffPowerState) + h := newTestHandler(t, server.Config{}) + + rec := do(t, h, http.MethodPost, "/v1/power", + map[string]any{"bmc": mock.URL, "system": "Node0", "operation": "on"}, nil) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202; body=%s", rec.Code, rec.Body.String()) + } + if got := decodeBody(t, rec)["issued"]; got != true { + t.Fatalf("issued = %v, want true", got) + } +} + +func TestPowerActionOperationWaitConfirmed(t *testing.T) { + // Node starts Off; "on" with wait should drive it On and confirm. + mock := newMockRedfish(t, schemas.OffPowerState) + h := newTestHandler(t, server.Config{}) + + rec := do(t, h, http.MethodPost, "/v1/power", + map[string]any{"bmc": mock.URL, "system": "Node0", "operation": "on", "wait": true}, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (confirmed); body=%s", rec.Code, rec.Body.String()) + } + body := decodeBody(t, rec) + if body["status"] != "confirmed" { + t.Fatalf("status = %v, want confirmed", body["status"]) + } + if body["finalState"] != string(schemas.OnPowerState) { + t.Fatalf("finalState = %v, want On", body["finalState"]) + } +} + +func TestPowerActionUnknownOperation(t *testing.T) { + h := newTestHandler(t, server.Config{}) + rec := do(t, h, http.MethodPost, "/v1/power", + map[string]any{"bmc": "https://x", "system": "Node0", "operation": "teleport"}, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestPowerActionWaitWithRawResetRejected(t *testing.T) { + h := newTestHandler(t, server.Config{}) + rec := do(t, h, http.MethodPost, "/v1/power", + map[string]any{"bmc": "https://x", "system": "Node0", "resetType": "On", "wait": true}, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestPowerActionRequiresExactlyOneAction(t *testing.T) { + h := newTestHandler(t, server.Config{}) + // Neither operation nor resetType. + rec := do(t, h, http.MethodPost, "/v1/power", + map[string]any{"bmc": "https://x", "system": "Node0"}, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestInventoryMissingBMC(t *testing.T) { + h := newTestHandler(t, server.Config{}) + rec := do(t, h, http.MethodPost, "/v1/inventory", map[string]any{}, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestBearerAuth(t *testing.T) { + const token = "s3cret-token" + h := newTestHandler(t, server.Config{AuthToken: token}) + + // Health is unauthenticated. + if rec := do(t, h, http.MethodGet, "/healthz", nil, nil); rec.Code != http.StatusOK { + t.Fatalf("/healthz with auth on = %d, want 200", rec.Code) + } + // /v1 without a token is rejected. + if rec := do(t, h, http.MethodGet, "/v1/power?bmc=https://x&system=Node0", nil, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("no-token /v1 = %d, want 401", rec.Code) + } + // Wrong token is rejected. + if rec := do(t, h, http.MethodGet, "/v1/power?bmc=https://x&system=Node0", nil, + map[string]string{"Authorization": "Bearer wrong"}); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong-token /v1 = %d, want 401", rec.Code) + } + // Correct token passes auth (then fails downstream on the bogus BMC, which is + // a 502 — proving it got past the auth gate). + if rec := do(t, h, http.MethodGet, "/v1/power?bmc=https://x&system=Node0", nil, + map[string]string{"Authorization": "Bearer " + token}); rec.Code == http.StatusUnauthorized { + t.Fatalf("correct-token /v1 = 401, want it to pass the auth gate") + } +} diff --git a/pkg/bmc/transition.go b/pkg/bmc/transition.go new file mode 100644 index 00000000..9a599f24 --- /dev/null +++ b/pkg/bmc/transition.go @@ -0,0 +1,242 @@ +package bmc + +import ( + "context" + "time" + + "github.com/stmcginnis/gofish/schemas" +) + +// Defaults for power-transition confirmation. They mirror power-control (PCS): +// poll roughly every 15s against a 5-minute deadline, retrying transient read +// failures a few times. +const ( + DefaultPollInterval = 15 * time.Second + DefaultTimeout = 5 * time.Minute + DefaultPollRetries = 3 +) + +// TransitionOptions configures how ResetAndConfirm confirms that a power +// operation took effect. +type TransitionOptions struct { + // PollInterval is the delay between power-state polls. Defaults to + // DefaultPollInterval when <= 0. + PollInterval time.Duration + // Timeout bounds the confirmation of a single operation. Defaults to + // DefaultTimeout when <= 0. + Timeout time.Duration + // Retries is the number of additional attempts for each power-state poll + // (reads are idempotent and safe to retry). Defaults to DefaultPollRetries + // when < 0. The reset action itself is issued exactly once to avoid + // duplicate power operations. + Retries int + // Escalate enables the graceful→forced fallback: when a graceful operation + // (e.g. "off") fails to reach its target before the deadline, the forced + // equivalent ("force-off") is issued and confirmed. + Escalate bool +} + +// DefaultTransitionOptions returns options with all defaults applied and +// escalation enabled. Callers should start here and override as needed, because +// the zero value of Escalate (false) disables the graceful→forced fallback. +func DefaultTransitionOptions() TransitionOptions { + return TransitionOptions{ + PollInterval: DefaultPollInterval, + Timeout: DefaultTimeout, + Retries: DefaultPollRetries, + Escalate: true, + } +} + +func (o TransitionOptions) withDefaults() TransitionOptions { + if o.PollInterval <= 0 { + o.PollInterval = DefaultPollInterval + } + if o.Timeout <= 0 { + o.Timeout = DefaultTimeout + } + if o.Retries < 0 { + o.Retries = DefaultPollRetries + } + return o +} + +// TransitionStatus is the outcome of confirming a power operation. +type TransitionStatus string + +const ( + // StatusConfirmed means the target reached its expected power state, or the + // BMC's async task completed successfully. + StatusConfirmed TransitionStatus = "confirmed" + // StatusTimedOut means the reset was issued but the target did not reach its + // expected power state before the deadline. + StatusTimedOut TransitionStatus = "timed-out" + // StatusUnconfirmable means the reset was issued but cannot be confirmed via + // power state (e.g. a restart, whose terminal state is indistinguishable + // from its starting state) and the BMC supplied no trackable task. + StatusUnconfirmable TransitionStatus = "unconfirmable" +) + +// TransitionResult describes the outcome of a ResetAndConfirm call. +type TransitionResult struct { + // Operation is the operation originally requested. + Operation Operation + // Status is the confirmation outcome. + Status TransitionStatus + // FinalState is the last observed power state (empty if never read). + FinalState schemas.PowerState + // Escalated reports whether a forced fallback was issued after a graceful + // operation timed out. + Escalated bool + // EscalatedTo is the forced operation issued when Escalated is true. + EscalatedTo Operation + // Task is the most recent gofish task-monitor handle, when the BMC modeled + // the reset asynchronously (may be nil). + Task *schemas.TaskMonitorInfo +} + +// Confirmed reports whether the transition reached its target state. +func (r *TransitionResult) Confirmed() bool { return r.Status == StatusConfirmed } + +// targetPowerState returns the power state an operation is expected to settle +// into, and whether such a stable target exists. Restarts have no stable +// power-state target (they end On, as they began), so they return false. +func targetPowerState(op Operation) (schemas.PowerState, bool) { + switch op { + case OpOn: + return schemas.OnPowerState, true + case OpOff, OpSoftOff, OpForceOff: + return schemas.OffPowerState, true + default: + return "", false + } +} + +// forcedEscalation returns the forced operation to fall back to when a graceful +// operation times out, and whether escalation applies. "soft-off" deliberately +// does not escalate — it is the caller's explicit "graceful only" request. +func forcedEscalation(op Operation) (Operation, bool) { + switch op { + case OpOff: + return OpForceOff, true + case OpSoftRestart: + return OpHardRestart, true + default: + return "", false + } +} + +// ResetAndConfirm performs a vendor-neutral power Operation and confirms it took +// effect. It issues the operation (resolving it to a supported reset type), then +// confirms completion by following the BMC's async task to a terminal state when +// one is returned, otherwise by polling the target's power state until it matches +// the expected state or the deadline elapses. When a graceful operation times +// out and opts.Escalate is set, the forced equivalent is issued and confirmed. +// +// It returns a non-nil TransitionResult describing the outcome even when the +// operation could not be confirmed (Status reflects that); a non-nil error is +// returned only when the operation could not be issued at all (e.g. an +// unsupported operation or a connection failure). +func ResetAndConfirm(ctx context.Context, c Client, systemID string, op Operation, opts TransitionOptions) (*TransitionResult, error) { + opts = opts.withDefaults() + result := &TransitionResult{Operation: op} + + task, err := c.ResetOperation(ctx, systemID, op) + if err != nil { + return result, err + } + result.Task = task + + status, state := confirm(ctx, c, systemID, op, task, opts) + result.Status = status + result.FinalState = state + if status != StatusTimedOut { + return result, nil + } + + // Graceful operation did not reach its target in time; escalate if asked. + if opts.Escalate { + if escOp, ok := forcedEscalation(op); ok { + result.Escalated = true + result.EscalatedTo = escOp + + task2, err := c.ResetOperation(ctx, systemID, escOp) + if err != nil { + return result, err + } + result.Task = task2 + + status2, state2 := confirm(ctx, c, systemID, escOp, task2, opts) + result.Status = status2 + result.FinalState = state2 + } + } + return result, nil +} + +// confirm waits for an issued operation to take effect, returning the outcome +// and the last observed power state. It bounds itself to opts.Timeout. +func confirm(ctx context.Context, c Client, systemID string, op Operation, task *schemas.TaskMonitorInfo, opts TransitionOptions) (TransitionStatus, schemas.PowerState) { + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + target, hasTarget := targetPowerState(op) + + // Native path: if the BMC modeled the reset as an async task, follow it to + // completion. On success, verify against the target state when one exists. + if task != nil && task.TaskMonitor != "" { + if _, err := schemas.WaitForTaskMonitor(ctx, c.Gofish(), opts.PollInterval, task, nil); err == nil { + if !hasTarget { + return StatusConfirmed, "" + } + if state, err := pollPowerState(ctx, c, systemID, opts.Retries); err == nil && state == target { + return StatusConfirmed, state + } + // Task completed but state does not match yet; fall through to polling. + } + // Task wait failed; fall through to power-state polling where possible. + } + + if !hasTarget { + // A restart with no usable task signal: issued, but not confirmable via + // power state alone. + return StatusUnconfirmable, "" + } + + // Poll power state until it matches the target or the deadline elapses. + var last schemas.PowerState + for { + if state, err := pollPowerState(ctx, c, systemID, opts.Retries); err == nil { + last = state + if state == target { + return StatusConfirmed, state + } + } + + timer := time.NewTimer(opts.PollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return StatusTimedOut, last + case <-timer.C: + } + } +} + +// pollPowerState reads the power state once, retrying transient failures up to +// retries additional times. It honors ctx between attempts. +func pollPowerState(ctx context.Context, c Client, systemID string, retries int) (schemas.PowerState, error) { + var ( + state schemas.PowerState + err error + ) + for attempt := 0; attempt <= retries; attempt++ { + if cerr := ctx.Err(); cerr != nil { + return state, cerr + } + if state, err = c.GetPowerState(ctx, systemID); err == nil { + return state, nil + } + } + return state, err +} diff --git a/pkg/bmc/transition_test.go b/pkg/bmc/transition_test.go new file mode 100644 index 00000000..0942f64d --- /dev/null +++ b/pkg/bmc/transition_test.go @@ -0,0 +1,222 @@ +package bmc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "sync" + "testing" + "time" + + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/go-chi/chi/v5" + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/schemas" +) + +// powerMock is a stateful in-memory Redfish ComputerSystem whose power state +// changes in response to reset actions, letting transition tests drive the +// confirm / timeout / escalation paths deterministically. +type powerMock struct { + mu sync.Mutex + state schemas.PowerState + resets []string // reset types received, in order + ignoreGraceful bool // model a node that ignores GracefulShutdown +} + +func (pm *powerMock) systemJSON() string { + pm.mu.Lock() + defer pm.mu.Unlock() + return fmt.Sprintf(`{ + "@odata.id": "/redfish/v1/Systems/Node0", + "@odata.type": "#ComputerSystem.v1_5_0.ComputerSystem", + "Id": "Node0", + "Name": "Node0", + "PowerState": "%s", + "Actions": { + "#ComputerSystem.Reset": { + "ResetType@Redfish.AllowableValues": ["On","ForceOff","GracefulShutdown","ForceRestart","PowerCycle"], + "target": "/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset" + } + } + }`, pm.state) +} + +func (pm *powerMock) applyReset(rt schemas.ResetType) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.resets = append(pm.resets, string(rt)) + switch rt { + case schemas.OnResetType, schemas.ForceOnResetType: + pm.state = schemas.OnPowerState + case schemas.ForceOffResetType: + pm.state = schemas.OffPowerState + case schemas.GracefulShutdownResetType: + if !pm.ignoreGraceful { + pm.state = schemas.OffPowerState + } + case schemas.GracefulRestartResetType, schemas.ForceRestartResetType, schemas.PowerCycleResetType: + // A restart ends in the On state, as it began. + pm.state = schemas.OnPowerState + } +} + +func (pm *powerMock) resetLog() []string { + pm.mu.Lock() + defer pm.mu.Unlock() + return append([]string(nil), pm.resets...) +} + +// newPowerMock stands up the stateful Redfish service and returns a connected +// GenericClient plus the mock for assertions. +func newPowerMock(t *testing.T, initial schemas.PowerState, ignoreGraceful bool) (*GenericClient, *powerMock) { + t.Helper() + pm := &powerMock{state: initial, ignoreGraceful: ignoreGraceful} + + 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", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(pm.systemJSON())) + }) + mux.HandleFunc("/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) { + var body struct{ ResetType string } + _ = json.NewDecoder(r.Body).Decode(&body) + pm.applyReset(schemas.ResetType(body.ResetType)) + 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) + return NewGenericClient(api, VendorGeneric), pm +} + +// fastOpts confirms quickly so timeout-driven tests stay sub-second. +func fastOpts() TransitionOptions { + return TransitionOptions{ + PollInterval: 2 * time.Millisecond, + Timeout: 80 * time.Millisecond, + Retries: 0, + Escalate: true, + } +} + +func TestResetAndConfirm_OnConfirmed(t *testing.T) { + gc, _ := newPowerMock(t, schemas.OffPowerState, false) + + res, err := ResetAndConfirm(context.Background(), gc, "Node0", OpOn, fastOpts()) + if err != nil { + t.Fatalf("ResetAndConfirm(on) error: %v", err) + } + if !res.Confirmed() { + t.Fatalf("status = %q, want confirmed", res.Status) + } + if res.FinalState != schemas.OnPowerState { + t.Fatalf("final state = %q, want On", res.FinalState) + } + if res.Escalated { + t.Fatal("unexpected escalation on a successful 'on'") + } +} + +func TestResetAndConfirm_TimeoutNoEscalation(t *testing.T) { + // soft-off is graceful-only and must NOT escalate; a node that ignores + // GracefulShutdown therefore times out unconfirmed. + gc, pm := newPowerMock(t, schemas.OnPowerState, true) + + res, err := ResetAndConfirm(context.Background(), gc, "Node0", OpSoftOff, fastOpts()) + if err != nil { + t.Fatalf("ResetAndConfirm(soft-off) error: %v", err) + } + if res.Status != StatusTimedOut { + t.Fatalf("status = %q, want timed-out", res.Status) + } + if res.Escalated { + t.Fatal("soft-off must not escalate") + } + if got := pm.resetLog(); len(got) != 1 || got[0] != string(schemas.GracefulShutdownResetType) { + t.Fatalf("reset log = %v, want a single GracefulShutdown", got) + } +} + +func TestResetAndConfirm_GracefulEscalatesToForce(t *testing.T) { + // A node ignoring GracefulShutdown: "off" must time out gracefully, then + // escalate to ForceOff, which the node honors → confirmed. + gc, pm := newPowerMock(t, schemas.OnPowerState, true) + + res, err := ResetAndConfirm(context.Background(), gc, "Node0", OpOff, fastOpts()) + if err != nil { + t.Fatalf("ResetAndConfirm(off) error: %v", err) + } + if !res.Confirmed() { + t.Fatalf("status = %q, want confirmed after escalation", res.Status) + } + if !res.Escalated || res.EscalatedTo != OpForceOff { + t.Fatalf("escalated=%v to=%q, want true to force-off", res.Escalated, res.EscalatedTo) + } + if res.FinalState != schemas.OffPowerState { + t.Fatalf("final state = %q, want Off", res.FinalState) + } + want := []string{string(schemas.GracefulShutdownResetType), string(schemas.ForceOffResetType)} + if got := pm.resetLog(); !reflect.DeepEqual(got, want) { + t.Fatalf("reset log = %v, want %v", got, want) + } +} + +func TestResetAndConfirm_RestartUnconfirmable(t *testing.T) { + // A restart has no stable power-state target, and the mock returns no async + // task, so the operation is issued but reported unconfirmable. + gc, _ := newPowerMock(t, schemas.OnPowerState, false) + + res, err := ResetAndConfirm(context.Background(), gc, "Node0", OpHardRestart, fastOpts()) + if err != nil { + t.Fatalf("ResetAndConfirm(hard-restart) error: %v", err) + } + if res.Status != StatusUnconfirmable { + t.Fatalf("status = %q, want unconfirmable", res.Status) + } +} + +func TestResetAndConfirm_ContextCancelled(t *testing.T) { + gc, _ := newPowerMock(t, schemas.OnPowerState, false) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := ResetAndConfirm(ctx, gc, "Node0", OpOff, fastOpts()) + if err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", err) + } + if res == nil { + t.Fatal("expected a non-nil result even on a failed issue") + } +} + +func TestTransitionOptionsDefaults(t *testing.T) { + // Zero PollInterval/Timeout are defaulted, but Retries == 0 is a valid value + // (one attempt, no retry) and must be preserved; only Retries < 0 defaults. + o := TransitionOptions{}.withDefaults() + if o.PollInterval != DefaultPollInterval || o.Timeout != DefaultTimeout { + t.Fatalf("withDefaults() = %+v, want interval/timeout defaulted", o) + } + if o.Retries != 0 { + t.Fatalf("withDefaults() Retries = %d, want 0 preserved", o.Retries) + } + if neg := (TransitionOptions{Retries: -1}).withDefaults(); neg.Retries != DefaultPollRetries { + t.Fatalf("withDefaults() negative Retries = %d, want %d", neg.Retries, DefaultPollRetries) + } + if d := DefaultTransitionOptions(); !d.Escalate { + t.Fatal("DefaultTransitionOptions().Escalate = false, want true") + } +} diff --git a/pkg/power/power.go b/pkg/power/power.go index 24a0a979..8b017793 100644 --- a/pkg/power/power.go +++ b/pkg/power/power.go @@ -176,6 +176,28 @@ func ResetOperation(ctx context.Context, node CrawlableNode, op bmc.Operation) ( return client.ResetOperation(ctx, node.NodeID, op) } +// PowerTransition performs a vendor-neutral power Operation on a node and +// confirms it took effect: it polls the node's power state to its target (or +// follows the BMC's async task) within a deadline, retrying transient reads and +// escalating a timed-out graceful operation to its forced equivalent per opts. +// +// Returns: +// - *bmc.TransitionResult: the outcome (confirmed / timed-out / unconfirmable), +// last observed power state, and whether a forced escalation occurred. +// - error: only when the operation could not be issued at all. +func PowerTransition(ctx context.Context, node CrawlableNode, op bmc.Operation, opts bmc.TransitionOptions) (*bmc.TransitionResult, error) { + log.Debug().Msgf("performing confirmed power operation %q on computer system %s", op, node.ClusterID) + + // Use a fresh (uncached) vendor-aware client and log out when done. + client, err := bmc.DefaultManager.Client(ctx, node.ConnConfig) + if err != nil { + return nil, err + } + defer client.Logout() + + return bmc.ResetAndConfirm(ctx, client, node.NodeID, op, opts) +} + // GetBMCSession returns an already-active gofish BMC client, creating a new one if necessary. // This facilitates keeping the clients open for efficiency. // diff --git a/pkg/service/service.go b/pkg/service/service.go index 5728fbe3..d3e52b70 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -107,6 +107,15 @@ func (s *Service) ResetOperation(ctx context.Context, uri, systemID string, op b return power.ResetOperation(ctx, s.crawlableNode(uri, systemID), op) } +// PowerTransition performs a vendor-neutral power Operation and confirms it took +// effect (polling power state to its target or following the BMC's async task), +// escalating a timed-out graceful operation to its forced equivalent per opts. +// This is the synchronous primitive the daemon will wrap as a pollable async +// transition. +func (s *Service) PowerTransition(ctx context.Context, uri, systemID string, op bmc.Operation, opts bmc.TransitionOptions) (*bmc.TransitionResult, error) { + return power.PowerTransition(ctx, s.crawlableNode(uri, systemID), op, opts) +} + // Close releases any cached BMC sessions held by the manager. func (s *Service) Close() { s.Manager.LogoutAll()