diff --git a/bmc/power_cap.go b/bmc/power_cap.go new file mode 100644 index 00000000..dfbc5136 --- /dev/null +++ b/bmc/power_cap.go @@ -0,0 +1,156 @@ +package bmc + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" +) + +// PowerMetrics holds the chassis power readings and the configured power cap. +type PowerMetrics struct { + // ConsumedWatts is the power currently drawn by the chassis. + ConsumedWatts float64 + // CapacityWatts is the total power capacity available to the chassis. + CapacityWatts float64 + // LimitInWatts is the configured power cap; nil when no cap is set. + LimitInWatts *float64 +} + +// PowerMetricsGetter provides retrieval of the chassis power readings and power cap. +type PowerMetricsGetter interface { + GetPowerMetrics(ctx context.Context) (metrics PowerMetrics, err error) +} + +type powerMetricsGetterProvider struct { + name string + PowerMetricsGetter +} + +// PowerCapSetter provides setting the chassis power cap. A nil limitWatts clears +// the cap. +type PowerCapSetter interface { + SetPowerCap(ctx context.Context, limitWatts *float64) (err error) +} + +type powerCapSetterProvider struct { + name string + PowerCapSetter +} + +func getPowerMetrics(ctx context.Context, generic []powerMetricsGetterProvider) (metrics PowerMetrics, metadata Metadata, err error) { + metadata = newMetadata() +Loop: + for _, elem := range generic { + if elem.PowerMetricsGetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + break Loop + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + metrics, vErr := elem.GetPowerMetrics(ctx) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + continue + } + metadata.SuccessfulProvider = elem.name + return metrics, metadata, nil + } + } + + return metrics, metadata, multierror.Append(err, errors.New("failure to get power metrics")) +} + +func setPowerCap(ctx context.Context, generic []powerCapSetterProvider, limitWatts *float64) (metadata Metadata, err error) { + metadata = newMetadata() +Loop: + for _, elem := range generic { + if elem.PowerCapSetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + break Loop + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + vErr := elem.SetPowerCap(ctx, limitWatts) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + continue + } + metadata.SuccessfulProvider = elem.name + return metadata, nil + } + } + + return metadata, multierror.Append(err, errors.New("failure to set power cap")) +} + +// GetPowerMetricsFromInterfaces identifies implementations of the PowerMetricsGetter +// interface and passes them to the getPowerMetrics() wrapper. +func GetPowerMetricsFromInterfaces(ctx context.Context, generic []interface{}) (metrics PowerMetrics, metadata Metadata, err error) { + implementations := make([]powerMetricsGetterProvider, 0) + for _, elem := range generic { + if elem == nil { + continue + } + temp := powerMetricsGetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case PowerMetricsGetter: + temp.PowerMetricsGetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a PowerMetricsGetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return metrics, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + "no PowerMetricsGetter implementations found", + ), + ) + } + + return getPowerMetrics(ctx, implementations) +} + +// SetPowerCapFromInterfaces identifies implementations of the PowerCapSetter +// interface and passes them to the setPowerCap() wrapper. +func SetPowerCapFromInterfaces(ctx context.Context, generic []interface{}, limitWatts *float64) (metadata Metadata, err error) { + implementations := make([]powerCapSetterProvider, 0) + for _, elem := range generic { + if elem == nil { + continue + } + temp := powerCapSetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case PowerCapSetter: + temp.PowerCapSetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a PowerCapSetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + "no PowerCapSetter implementations found", + ), + ) + } + + return setPowerCap(ctx, implementations, limitWatts) +} diff --git a/bmc/power_cap_test.go b/bmc/power_cap_test.go new file mode 100644 index 00000000..332e4fc1 --- /dev/null +++ b/bmc/power_cap_test.go @@ -0,0 +1,127 @@ +package bmc + +import ( + "context" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +type mockPowerMetricsGetter struct { + metrics PowerMetrics + err error +} + +func (m *mockPowerMetricsGetter) GetPowerMetrics(ctx context.Context) (PowerMetrics, error) { + return m.metrics, m.err +} + +func (m *mockPowerMetricsGetter) Name() string { + return "mock" +} + +type mockPowerCapSetter struct { + err error +} + +func (m *mockPowerCapSetter) SetPowerCap(ctx context.Context, _ *float64) error { + return m.err +} + +func (m *mockPowerCapSetter) Name() string { + return "mock" +} + +func TestGetPowerMetricsFromInterfaces(t *testing.T) { + limit := 800.0 + testCases := []struct { + name string + generic []interface{} + errMsg string + expectedMetrics PowerMetrics + }{ + { + name: "success", + generic: []interface{}{&mockPowerMetricsGetter{metrics: PowerMetrics{ConsumedWatts: 234, CapacityWatts: 1400, LimitInWatts: &limit}}}, + expectedMetrics: PowerMetrics{ConsumedWatts: 234, CapacityWatts: 1400, LimitInWatts: &limit}, + }, + { + name: "not an implementation", + generic: []interface{}{"foo"}, + errMsg: "no PowerMetricsGetter implementations found", + }, + { + name: "no implementations", + generic: []interface{}{}, + errMsg: "no PowerMetricsGetter implementations found", + }, + { + name: "error from getter", + generic: []interface{}{&mockPowerMetricsGetter{err: errors.New("foobar")}}, + errMsg: "foobar", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + metrics, _, err := GetPowerMetricsFromInterfaces(context.Background(), tt.generic) + + if tt.errMsg == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.errMsg) + } + + assert.Equal(t, tt.expectedMetrics, metrics) + }) + } +} + +func TestSetPowerCapFromInterfaces(t *testing.T) { + limit := 800.0 + testCases := []struct { + name string + generic []interface{} + limit *float64 + errMsg string + }{ + { + name: "success, set", + generic: []interface{}{&mockPowerCapSetter{}}, + limit: &limit, + }, + { + name: "success, clear", + generic: []interface{}{&mockPowerCapSetter{}}, + }, + { + name: "not an implementation", + generic: []interface{}{"foo"}, + errMsg: "no PowerCapSetter implementations found", + }, + { + name: "no implementations", + generic: []interface{}{}, + errMsg: "no PowerCapSetter implementations found", + }, + { + name: "error from setter", + generic: []interface{}{&mockPowerCapSetter{err: errors.New("foobar")}}, + limit: &limit, + errMsg: "foobar", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + _, err := SetPowerCapFromInterfaces(context.Background(), tt.generic, tt.limit) + + if tt.errMsg == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.errMsg) + } + }) + } +} diff --git a/client.go b/client.go index 88d3eae6..0f5fafa8 100644 --- a/client.go +++ b/client.go @@ -783,6 +783,30 @@ func (c *Client) AllowCustomSecureBootKeys(ctx context.Context, enable bool) (re return rebootRequired, err } +// GetPowerMetrics returns the chassis power readings and the configured power cap. +func (c *Client) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetPowerMetrics") + defer span.End() + + metrics, metadata, err := bmc.GetPowerMetricsFromInterfaces(ctx, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return metrics, err +} + +// SetPowerCap sets the chassis power cap in watts. A nil limitWatts clears the cap. +func (c *Client) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetPowerCap") + defer span.End() + + metadata, err := bmc.SetPowerCapFromInterfaces(ctx, c.registry().GetDriverInterfaces(), limitWatts) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + // FirmwareInstall pass through library function to upload firmware and install firmware func (c *Client) FirmwareInstall(ctx context.Context, component, operationApplyTime string, forceInstall bool, reader io.Reader) (taskID string, err error) { ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstall") diff --git a/errors/errors.go b/errors/errors.go index 67dab9de..10785e99 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -128,6 +128,12 @@ var ( // ErrBMCUpdating is returned when the BMC is going through an update and will not serve other queries. ErrBMCUpdating = errors.New("a BMC firmware update is in progress") + // ErrPowerControlNotFound is returned when no chassis exposes a Power resource with PowerControl + ErrPowerControlNotFound = errors.New("no chassis exposes a Power resource with PowerControl") + + // ErrPowerCapSet is returned when the BMC rejects a power cap update + ErrPowerCapSet = errors.New("failed to set power cap") + // ErrSecureBootDatabaseNotFound is returned when the requested UEFI Secure Boot key database // (db, KEK, PK, dbx, ...) is not present in the SecureBootDatabases collection reported by the BMC. ErrSecureBootDatabaseNotFound = errors.New("secure boot key database not found") diff --git a/examples/power-cap/main.go b/examples/power-cap/main.go new file mode 100644 index 00000000..330d325f --- /dev/null +++ b/examples/power-cap/main.go @@ -0,0 +1,99 @@ +// Package main demonstrates reading, setting and clearing the chassis power cap with bmclib. +package main + +import ( + "context" + "flag" + "fmt" + "strings" + "time" + + logrusr "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" + + bmclib "github.com/bmc-toolbox/bmclib/v2" + "github.com/bmc-toolbox/bmclib/v2/providers" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Command line option flag parsing + user := flag.String("user", "", "Username to login with") + pass := flag.String("password", "", "Password to login with") + host := flag.String("host", "", "BMC hostname to connect to") + mode := flag.String("mode", "get", "Mode [get,set,clear]") + limit := flag.Float64("limit", 0, "Power cap in watts (for set mode)") + + flag.Parse() + + // Logger configuration + l := logrus.New() + l.Level = logrus.DebugLevel + logger := logrusr.New(l) + + // Validate required parameters + if *host == "" || *user == "" || *pass == "" { + l.Fatal("required host/user/pass parameters not defined") + } + + // bmclib client abstraction + clientOpts := []bmclib.Option{bmclib.WithLogger(logger)} + client := bmclib.NewClient(*host, *user, *pass, clientOpts...) + + // Filter to providers that support power cap operations + client.Registry.Drivers = client.Registry.Supports( + providers.FeatureGetPowerMetrics, + providers.FeatureSetPowerCap, + ) + + err := client.Open(ctx) + if err != nil { + l.Fatal(err, "bmc login failed") + } + + defer func() { _ = client.Close(ctx) }() + + // Operating mode selection + switch strings.ToLower(*mode) { + case "get": + // Read the current power readings and cap + metrics, err := client.GetPowerMetrics(ctx) + if err != nil { + l.Fatal(err) + } + + fmt.Printf("Consumed: %.0f W\nCapacity: %.0f W\n", metrics.ConsumedWatts, metrics.CapacityWatts) + if metrics.LimitInWatts == nil { + fmt.Println("Power cap: not set") + } else { + fmt.Printf("Power cap: %.0f W\n", *metrics.LimitInWatts) + } + + case "set": + // Set a power cap + if *limit <= 0 { + l.Fatal("set mode requires a positive -limit in watts") + } + + err := client.SetPowerCap(ctx, limit) + if err != nil { + l.Fatal(err) + } + + fmt.Printf("Power cap set to %.0f W\n", *limit) + + case "clear": + // Clear the power cap (nil limit disables capping) + err := client.SetPowerCap(ctx, nil) + if err != nil { + l.Fatal(err) + } + + fmt.Println("Power cap cleared") + + default: + l.Fatal("unknown mode: " + *mode) + } +} diff --git a/internal/redfishwrapper/fixtures/dell/chassis.system.embedded.1.json b/internal/redfishwrapper/fixtures/dell/chassis.system.embedded.1.json new file mode 100644 index 00000000..f95d40dd --- /dev/null +++ b/internal/redfishwrapper/fixtures/dell/chassis.system.embedded.1.json @@ -0,0 +1,24 @@ +{ + "@odata.context": "/redfish/v1/$metadata#Chassis.Chassis", + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1", + "@odata.type": "#Chassis.v1_20_0.Chassis", + "ChassisType": "RackMount", + "Description": "It represents the properties for physical components for any system.", + "Id": "System.Embedded.1", + "Manufacturer": "Dell Inc.", + "Model": "PowerEdge R750", + "Name": "Computer System Chassis", + "Power": { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power" + }, + "PowerState": "On", + "SerialNumber": "CNIVC0012345678", + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + }, + "Thermal": { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal" + } +} diff --git a/internal/redfishwrapper/fixtures/dell/chassis_collection.json b/internal/redfishwrapper/fixtures/dell/chassis_collection.json new file mode 100644 index 00000000..0cacaf9f --- /dev/null +++ b/internal/redfishwrapper/fixtures/dell/chassis_collection.json @@ -0,0 +1,13 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ChassisCollection.ChassisCollection", + "@odata.id": "/redfish/v1/Chassis", + "@odata.type": "#ChassisCollection.ChassisCollection", + "Description": "Collection of Chassis", + "Members": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1" + } + ], + "Members@odata.count": 1, + "Name": "Chassis Collection" +} diff --git a/internal/redfishwrapper/fixtures/dell/power.json b/internal/redfishwrapper/fixtures/dell/power.json new file mode 100644 index 00000000..b6e99a73 --- /dev/null +++ b/internal/redfishwrapper/fixtures/dell/power.json @@ -0,0 +1,51 @@ +{ + "@odata.context": "/redfish/v1/$metadata#Power.Power", + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power", + "@odata.type": "#Power.v1_7_1.Power", + "Description": "Power", + "Id": "Power", + "Name": "Power", + "PowerControl": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerControl/0", + "MemberId": "PowerControl", + "Name": "System Power Control", + "PowerAllocatedWatts": 1400, + "PowerAvailableWatts": 0, + "PowerCapacityWatts": 1400, + "PowerConsumedWatts": 234, + "PowerLimit": { + "CorrectionInMs": 0, + "LimitException": "HardPowerOff", + "LimitInWatts": null + }, + "PowerMetrics": { + "AverageConsumedWatts": 231, + "IntervalInMin": 1, + "MaxConsumedWatts": 248, + "MinConsumedWatts": 226 + }, + "PowerRequestedWatts": 652, + "RelatedItem": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1" + } + ], + "RelatedItem@odata.count": 1 + } + ], + "PowerControl@odata.count": 1, + "PowerSupplies": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerSupplies/0", + "MemberId": "PSU.Slot.1", + "Name": "PS1 Status", + "PowerCapacityWatts": 1400, + "Status": { + "Health": "OK", + "State": "Enabled" + } + } + ], + "PowerSupplies@odata.count": 1 +} diff --git a/internal/redfishwrapper/power_cap.go b/internal/redfishwrapper/power_cap.go new file mode 100644 index 00000000..3c90c259 --- /dev/null +++ b/internal/redfishwrapper/power_cap.go @@ -0,0 +1,86 @@ +package redfishwrapper + +import ( + "context" + + "github.com/pkg/errors" + "github.com/stmcginnis/gofish/schemas" + + "github.com/bmc-toolbox/bmclib/v2/bmc" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" +) + +// powerControl returns the Power resource of the first chassis that exposes one +// with a non-empty PowerControl collection. Chassis without a Power resource +// (enclosures, sleds without their own power domain) are skipped. +func (c *Client) powerControl(ctx context.Context) (*schemas.Power, error) { + chassis, err := c.Chassis(ctx) + if err != nil { + return nil, err + } + + for _, ch := range chassis { + if ch == nil { + continue + } + + power, err := ch.Power() + if err != nil || power == nil || len(power.PowerControl) == 0 { + continue + } + + return power, nil + } + + return nil, bmclibErrs.ErrPowerControlNotFound +} + +// GetPowerMetrics returns the chassis power readings and the configured power cap +// from PowerControl[0] of the first chassis exposing a Power resource. +func (c *Client) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + power, err := c.powerControl(ctx) + if err != nil { + return metrics, err + } + + pc := power.PowerControl[0] + if pc.PowerConsumedWatts != nil { + metrics.ConsumedWatts = float64(*pc.PowerConsumedWatts) + } + if pc.PowerCapacityWatts != nil { + metrics.CapacityWatts = float64(*pc.PowerCapacityWatts) + } + if pc.PowerLimit.LimitInWatts != nil { + limit := *pc.PowerLimit.LimitInWatts + metrics.LimitInWatts = &limit + } + + return metrics, nil +} + +// SetPowerCap sets the chassis power cap by PATCHing +// PowerControl[0].PowerLimit.LimitInWatts on the Power resource. A nil limitWatts +// clears the cap (LimitInWatts: null). gofish exposes no Update() on the Power +// resource, hence the explicit PATCH. +func (c *Client) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + power, err := c.powerControl(ctx) + if err != nil { + return err + } + + payload := map[string]any{ + "PowerControl": []map[string]any{ + {"PowerLimit": map[string]any{"LimitInWatts": limitWatts}}, + }, + } + + // gofish returns an error for any non-2xx response, carrying the BMC's + // error body; wrap it so callers can match on ErrPowerCapSet. + resp, err := c.PatchWithHeaders(ctx, power.ODataID, payload, nil) + if err != nil { + return errors.Wrap(bmclibErrs.ErrPowerCapSet, err.Error()) + } + _ = resp.Body.Close() + + return nil +} diff --git a/internal/redfishwrapper/power_cap_test.go b/internal/redfishwrapper/power_cap_test.go new file mode 100644 index 00000000..4c9b2b43 --- /dev/null +++ b/internal/redfishwrapper/power_cap_test.go @@ -0,0 +1,142 @@ +package redfishwrapper + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" +) + +// powerPatch captures the PATCH payloads sent to the Dell Power resource. +type powerPatch struct { + PowerControl []struct { + PowerLimit struct { + LimitInWatts *float64 `json:"LimitInWatts"` + } `json:"PowerLimit"` + } `json:"PowerControl"` +} + +func newDellPowerClient(t *testing.T, mux *http.ServeMux) *Client { + t.Helper() + + mux.HandleFunc("/redfish/v1/", endpointFunc(t, "dell/serviceroot.json")) + mux.HandleFunc("/redfish/v1/Chassis", endpointFunc(t, "dell/chassis_collection.json")) + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1", endpointFunc(t, "dell/chassis.system.embedded.1.json")) + + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + + parsedURL, err := url.Parse(server.URL) + require.NoError(t, err) + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "", WithBasicAuthEnabled(true)) + + ctx := context.Background() + require.NoError(t, client.Open(ctx)) + t.Cleanup(func() { _ = client.Close(ctx) }) + + return client +} + +// dellPowerHandler serves the Power fixture on GET and records PATCH payloads. +func dellPowerHandler(t *testing.T, patches *[]powerPatch) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write(mustReadFile(t, "dell/power.json")) + case http.MethodPatch: + var p powerPatch + require.NoError(t, json.NewDecoder(r.Body).Decode(&p)) + *patches = append(*patches, p) + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +func TestGetPowerMetrics(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1/Power", endpointFunc(t, "dell/power.json")) + client := newDellPowerClient(t, mux) + + metrics, err := client.GetPowerMetrics(context.Background()) + require.NoError(t, err) + assert.Equal(t, float64(234), metrics.ConsumedWatts) + assert.Equal(t, float64(1400), metrics.CapacityWatts) + assert.Nil(t, metrics.LimitInWatts, "fixture reports LimitInWatts null, expected no cap") +} + +func TestSetPowerCap(t *testing.T) { + var patches []powerPatch + + mux := http.NewServeMux() + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1/Power", dellPowerHandler(t, &patches)) + client := newDellPowerClient(t, mux) + + limit := 800.0 + require.NoError(t, client.SetPowerCap(context.Background(), &limit)) + + require.Len(t, patches, 1, "expected SetPowerCap to PATCH the Power resource once") + require.Len(t, patches[0].PowerControl, 1) + require.NotNil(t, patches[0].PowerControl[0].PowerLimit.LimitInWatts) + assert.Equal(t, limit, *patches[0].PowerControl[0].PowerLimit.LimitInWatts) +} + +func TestSetPowerCapClear(t *testing.T) { + var patches []powerPatch + + mux := http.NewServeMux() + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1/Power", dellPowerHandler(t, &patches)) + client := newDellPowerClient(t, mux) + + require.NoError(t, client.SetPowerCap(context.Background(), nil)) + + require.Len(t, patches, 1, "expected SetPowerCap(nil) to PATCH the Power resource once") + require.Len(t, patches[0].PowerControl, 1) + assert.Nil(t, patches[0].PowerControl[0].PowerLimit.LimitInWatts, "expected LimitInWatts: null to clear the cap") +} + +func TestSetPowerCapRejected(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1/Power", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write(mustReadFile(t, "dell/power.json")) + case http.MethodPatch: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"LimitInWatts out of range"}}`)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + client := newDellPowerClient(t, mux) + + limit := 1.0 + err := client.SetPowerCap(context.Background(), &limit) + assert.ErrorIs(t, err, bmclibErrs.ErrPowerCapSet) + assert.ErrorContains(t, err, "LimitInWatts out of range") +} + +func TestPowerControlNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/redfish/v1/Chassis/System.Embedded.1/Power", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + client := newDellPowerClient(t, mux) + + _, err := client.GetPowerMetrics(context.Background()) + assert.ErrorIs(t, err, bmclibErrs.ErrPowerControlNotFound) + + err = client.SetPowerCap(context.Background(), nil) + assert.ErrorIs(t, err, bmclibErrs.ErrPowerControlNotFound) +} diff --git a/providers/dell/idrac.go b/providers/dell/idrac.go index b136bd9d..8bf82c84 100644 --- a/providers/dell/idrac.go +++ b/providers/dell/idrac.go @@ -58,6 +58,8 @@ var ( providers.FeatureResetSecureBootDatabaseKeys, providers.FeatureImportSecureBootCertificate, providers.FeatureAllowCustomSecureBootKeys, + providers.FeatureGetPowerMetrics, + providers.FeatureSetPowerCap, } errManufacturerUnknown = errors.New("error identifying device manufacturer") @@ -290,6 +292,16 @@ func (c *Conn) ImportSecureBootCertificate(ctx context.Context, database bmc.Sec return c.redfishwrapper.ImportSecureBootCertificate(ctx, database, certificatePEM) } +// GetPowerMetrics returns the chassis power readings and the configured power cap +func (c *Conn) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + return c.redfishwrapper.GetPowerMetrics(ctx) +} + +// SetPowerCap sets the chassis power cap in watts; a nil limitWatts clears the cap +func (c *Conn) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + return c.redfishwrapper.SetPowerCap(ctx, limitWatts) +} + // SendNMI tells the BMC to issue an NMI to the device func (c *Conn) SendNMI(ctx context.Context) error { return c.redfishwrapper.SendNMI(ctx) diff --git a/providers/lenovo/lenovo.go b/providers/lenovo/lenovo.go index 963017a3..a220a8a6 100644 --- a/providers/lenovo/lenovo.go +++ b/providers/lenovo/lenovo.go @@ -82,6 +82,8 @@ var Features = registrar.Features{ providers.FeatureResetSecureBootKeys, providers.FeatureResetSecureBootDatabaseKeys, providers.FeatureImportSecureBootCertificate, + providers.FeatureGetPowerMetrics, + providers.FeatureSetPowerCap, // inventory-storage providers.FeatureInventoryRead, // firmware-tasks diff --git a/providers/lenovo/main_test.go b/providers/lenovo/main_test.go index 7c2dfb55..907a042d 100644 --- a/providers/lenovo/main_test.go +++ b/providers/lenovo/main_test.go @@ -686,6 +686,12 @@ func (ts *testServer) didPatchSecureBoot() bool { return ts.secureBootPatched } +func (ts *testServer) didPatchPower() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.powerPatched +} + func (ts *testServer) didResetSecureBootKeys() bool { ts.mu.Lock() defer ts.mu.Unlock() diff --git a/providers/lenovo/power_boot_bios_test.go b/providers/lenovo/power_boot_bios_test.go index ddb4fd27..28b343fe 100644 --- a/providers/lenovo/power_boot_bios_test.go +++ b/providers/lenovo/power_boot_bios_test.go @@ -238,3 +238,43 @@ func TestResetSecureBootKeys(t *testing.T) { t.Fatal("expected the SecureBoot.ResetKeys action to be posted") } } + +// Requirement: Power metrics read. +func TestGetPowerMetrics(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + metrics, err := c.GetPowerMetrics(context.Background()) + if err != nil { + t.Fatalf("GetPowerMetrics: %v", err) + } + // The fixture reports 287 W consumed, 1800 W capacity and no cap. + if metrics.ConsumedWatts != 287 { + t.Errorf("ConsumedWatts = %v, want 287", metrics.ConsumedWatts) + } + if metrics.CapacityWatts != 1800 { + t.Errorf("CapacityWatts = %v, want 1800", metrics.CapacityWatts) + } + if metrics.LimitInWatts != nil { + t.Errorf("LimitInWatts = %v, want nil (no cap)", *metrics.LimitInWatts) + } +} + +// Requirement: Power cap set and clear. +func TestSetPowerCap(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + limit := 1200.0 + if err := c.SetPowerCap(context.Background(), &limit); err != nil { + t.Fatalf("SetPowerCap: %v", err) + } + if !ts.didPatchPower() { + t.Fatal("expected the Power resource to be PATCHed") + } + + // A nil limit clears the cap and must PATCH successfully as well. + if err := c.SetPowerCap(context.Background(), nil); err != nil { + t.Fatalf("SetPowerCap(nil): %v", err) + } +} diff --git a/providers/lenovo/power_cap.go b/providers/lenovo/power_cap.go new file mode 100644 index 00000000..0e37992d --- /dev/null +++ b/providers/lenovo/power_cap.go @@ -0,0 +1,21 @@ +package lenovo + +import ( + "context" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// GetPowerMetrics returns the chassis power readings and the configured power cap. +// +// Implements bmc.PowerMetricsGetter. +func (c *Conn) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + return c.redfishwrapper.GetPowerMetrics(ctx) +} + +// SetPowerCap sets the chassis power cap in watts; a nil limitWatts clears the cap. +// +// Implements bmc.PowerCapSetter. +func (c *Conn) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + return c.redfishwrapper.SetPowerCap(ctx, limitWatts) +} diff --git a/providers/providers.go b/providers/providers.go index eadcd4d4..1885f28f 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -103,4 +103,10 @@ const ( // FeatureAllowCustomSecureBootKeys means an implementation that can enable/disable acceptance of custom UEFI Secure Boot keys FeatureAllowCustomSecureBootKeys registrar.Feature = "allowcustomsecurebootkeys" + + // FeatureGetPowerMetrics means an implementation that can read chassis power readings and the configured power cap + FeatureGetPowerMetrics registrar.Feature = "getpowermetrics" + + // FeatureSetPowerCap means an implementation that can set or clear the chassis power cap + FeatureSetPowerCap registrar.Feature = "setpowercap" ) diff --git a/providers/redfish/redfish.go b/providers/redfish/redfish.go index 3b668a17..8e7814a9 100644 --- a/providers/redfish/redfish.go +++ b/providers/redfish/redfish.go @@ -47,14 +47,18 @@ var Features = registrar.Features{ providers.FeatureResetSecureBootKeys, providers.FeatureResetSecureBootDatabaseKeys, providers.FeatureImportSecureBootCertificate, + providers.FeatureGetPowerMetrics, + providers.FeatureSetPowerCap, } -// compile-time assertions that the provider implements the BIOS configuration interfaces. +// compile-time assertions that the provider implements the BIOS configuration and power cap interfaces. var ( _ bmc.BiosConfigurationGetter = (*Conn)(nil) _ bmc.BiosConfigurationSetter = (*Conn)(nil) _ bmc.HTTPBootURISetter = (*Conn)(nil) _ bmc.NetworkBootEnabledSetter = (*Conn)(nil) + _ bmc.PowerMetricsGetter = (*Conn)(nil) + _ bmc.PowerCapSetter = (*Conn)(nil) ) // Conn details for redfish client @@ -297,6 +301,16 @@ func (c *Conn) ImportSecureBootCertificate(ctx context.Context, database bmc.Sec return c.redfishwrapper.ImportSecureBootCertificate(ctx, database, certificatePEM) } +// GetPowerMetrics returns the chassis power readings and the configured power cap +func (c *Conn) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + return c.redfishwrapper.GetPowerMetrics(ctx) +} + +// SetPowerCap sets the chassis power cap in watts; a nil limitWatts clears the cap +func (c *Conn) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + return c.redfishwrapper.SetPowerCap(ctx, limitWatts) +} + // SendNMI tells the BMC to issue an NMI to the device func (c *Conn) SendNMI(ctx context.Context) error { return c.redfishwrapper.SendNMI(ctx) diff --git a/providers/supermicro/supermicro.go b/providers/supermicro/supermicro.go index 94824c23..c0d75787 100644 --- a/providers/supermicro/supermicro.go +++ b/providers/supermicro/supermicro.go @@ -67,6 +67,8 @@ var Features = registrar.Features{ providers.FeatureImportSecureBootCertificate, providers.FeatureSetHTTPBootURI, providers.FeatureSetNetworkBootEnabled, + providers.FeatureGetPowerMetrics, + providers.FeatureSetPowerCap, } // supports @@ -757,6 +759,24 @@ func (c *Client) ImportSecureBootCertificate(ctx context.Context, database bmc.S return c.serviceClient.redfish.ImportSecureBootCertificate(ctx, database, certificatePEM) } +// GetPowerMetrics returns the chassis power readings and the configured power cap +func (c *Client) GetPowerMetrics(ctx context.Context) (metrics bmc.PowerMetrics, err error) { + if c.serviceClient == nil || c.serviceClient.redfish == nil { + return metrics, errors.Wrap(bmclibErrs.ErrLoginFailed, "client not initialized") + } + + return c.serviceClient.redfish.GetPowerMetrics(ctx) +} + +// SetPowerCap sets the chassis power cap in watts; a nil limitWatts clears the cap +func (c *Client) SetPowerCap(ctx context.Context, limitWatts *float64) (err error) { + if c.serviceClient == nil || c.serviceClient.redfish == nil { + return errors.Wrap(bmclibErrs.ErrLoginFailed, "client not initialized") + } + + return c.serviceClient.redfish.SetPowerCap(ctx, limitWatts) +} + // SendNMI tells the BMC to issue an NMI to the device func (c *Client) SendNMI(ctx context.Context) error { return c.serviceClient.redfish.SendNMI(ctx)