From a8095ade667a247daab7216d1dadc63850218715 Mon Sep 17 00:00:00 2001 From: nuxster Date: Fri, 26 Jun 2026 03:39:45 -0400 Subject: [PATCH 1/4] Add optional bmc core interfaces Adds optional bmc core interfaces (secure boot, thermal, power capping, volume management, license, secure key lifecycle, network, serial, event subscription, telemetry, jobs, certificates, SNMP) with their feature constants and client-level FromInterfaces plumbing. Assisted-by: Anthropic Claude (Claude Code, Opus model) --- bmc/certificate.go | 54 ++++ bmc/event_subscription.go | 56 ++++ bmc/extended.go | 438 ++++++++++++++++++++++++++ bmc/extended_test.go | 118 +++++++ bmc/job.go | 36 +++ bmc/license.go | 29 ++ bmc/network.go | 64 ++++ bmc/power_cap.go | 41 +++ bmc/secure_key_lifecycle.go | 28 ++ bmc/serial.go | 36 +++ bmc/snmp.go | 29 ++ bmc/telemetry.go | 54 ++++ bmc/thermal.go | 37 +++ bmc/volume.go | 66 ++++ client_features.go | 612 ++++++++++++++++++++++++++++++++++++ client_features_test.go | 63 ++++ providers/providers.go | 54 ++++ 17 files changed, 1815 insertions(+) create mode 100644 bmc/certificate.go create mode 100644 bmc/event_subscription.go create mode 100644 bmc/extended.go create mode 100644 bmc/extended_test.go create mode 100644 bmc/job.go create mode 100644 bmc/license.go create mode 100644 bmc/network.go create mode 100644 bmc/power_cap.go create mode 100644 bmc/secure_key_lifecycle.go create mode 100644 bmc/serial.go create mode 100644 bmc/snmp.go create mode 100644 bmc/telemetry.go create mode 100644 bmc/thermal.go create mode 100644 bmc/volume.go create mode 100644 client_features.go create mode 100644 client_features_test.go diff --git a/bmc/certificate.go b/bmc/certificate.go new file mode 100644 index 00000000..fd7d5e36 --- /dev/null +++ b/bmc/certificate.go @@ -0,0 +1,54 @@ +package bmc + +import "context" + +// CSRRequest describes a certificate-signing-request to generate. +type CSRRequest struct { + // CommonName is the fully-qualified domain name of the entity (required). + CommonName string + // Country, State, City, Organization, OrganizationalUnit and Email are the + // optional distinguished-name fields. + Country string + State string + City string + Organization string + OrganizationalUnit string + Email string + // CertificateCollection is the target certificate collection @odata.id. When + // empty the provider uses its default (the BMC HTTPS certificate collection). + CertificateCollection string +} + +// CertificateInfo describes an installed certificate. +type CertificateInfo struct { + // ID is the Redfish Certificate Id. + ID string + // SubjectCommonName is the certificate subject common name. + SubjectCommonName string + // IssuerCommonName is the certificate issuer common name. + IssuerCommonName string + // ValidNotBefore / ValidNotAfter are the validity bounds. + ValidNotBefore string + ValidNotAfter string +} + +// CertificateManager is implemented by providers that can manage BMC +// certificates. +type CertificateManager interface { + // CertificateLocations returns the @odata.id locations of installed + // certificates. + CertificateLocations(ctx context.Context) ([]string, error) + // Certificate returns a certificate's properties by its @odata.id location. + Certificate(ctx context.Context, location string) (CertificateInfo, error) + // GenerateCSR generates a certificate-signing-request and returns the PEM CSR. + GenerateCSR(ctx context.Context, req *CSRRequest) (csr string, err error) + // ReplaceCertificate replaces the certificate at targetURI with the given PEM + // certificate. + ReplaceCertificate(ctx context.Context, certificatePEM, targetURI string) error + // RekeyCertificate generates a new key pair and CSR for the certificate at + // certURI. + RekeyCertificate(ctx context.Context, certURI string) error + // RenewCertificate generates a CSR using the existing key for the certificate + // at certURI. + RenewCertificate(ctx context.Context, certURI string) error +} diff --git a/bmc/event_subscription.go b/bmc/event_subscription.go new file mode 100644 index 00000000..04e040b4 --- /dev/null +++ b/bmc/event_subscription.go @@ -0,0 +1,56 @@ +package bmc + +import "context" + +// EventServiceInfo describes the Redfish EventService configuration. +type EventServiceInfo struct { + // ServiceEnabled reports whether the event service is enabled. + ServiceEnabled bool + // DeliveryRetryAttempts is the number of delivery retry attempts. + DeliveryRetryAttempts int + // DeliveryRetryIntervalSeconds is the delivery retry interval. + DeliveryRetryIntervalSeconds int + // SSEFilterURI is the Server-Sent-Events stream URI, when supported. + SSEFilterURI string +} + +// EventSubscription describes an event destination (subscription). +type EventSubscription struct { + // ID is the Redfish subscription Id. + ID string + // Destination is the subscription delivery URL. + Destination string + // Protocol is the subscription protocol (e.g. "Redfish"). + Protocol string + // Context is the opaque client context echoed in events. + Context string +} + +// EventSubscriptionRequest describes a subscription to create. +type EventSubscriptionRequest struct { + // Destination is the delivery URL (required). + Destination string + // Protocol is the subscription protocol (defaults to "Redfish" when empty). + Protocol string + // Context is an opaque client context. + Context string + // RegistryPrefixes optionally filters the message-registry prefixes. + RegistryPrefixes []string +} + +// EventSubscriber is implemented by providers that can read the event service +// and manage event subscriptions. +type EventSubscriber interface { + // EventService returns the event-service configuration. + EventService(ctx context.Context) (EventServiceInfo, error) + // EventSubscriptions lists the event subscriptions. + EventSubscriptions(ctx context.Context) ([]EventSubscription, error) + // EventSubscriptionCreate creates a subscription and returns its Id. + EventSubscriptionCreate(ctx context.Context, req EventSubscriptionRequest) (id string, err error) + // EventSubscriptionDelete deletes a subscription by Id. + EventSubscriptionDelete(ctx context.Context, id string) error + // SubmitTestEvent submits a test event (optionally for a specific MessageId). + SubmitTestEvent(ctx context.Context, messageID string) error + // SetEventService PATCHes event-service properties. + SetEventService(ctx context.Context, attrs map[string]any) error +} diff --git a/bmc/extended.go b/bmc/extended.go new file mode 100644 index 00000000..a79206a9 --- /dev/null +++ b/bmc/extended.go @@ -0,0 +1,438 @@ +package bmc + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +// This file provides the FromInterfaces dispatchers for the extended +// vendor-capability interfaces (thermal, power capping, storage +// volumes, licensing, secure key lifecycle, networking, serial, events, +// telemetry, jobs, certificates and SNMP). +// +// To avoid repeating the provider-iteration/timeout/metadata boilerplate that +// the older hand-written *FromInterfaces functions carry, the dispatch is +// centralized in two generics: runProviderRead (operations returning a value) +// and runProviderAction (operations returning only an error). Each public +// *FromInterfaces function below is a thin, type-safe binding over one of them. + +// runProviderRead invokes op on the first registered provider that implements +// interface T, returning its result. It records provider attempts, the +// successful provider and per-provider failures in the returned Metadata, and +// applies the per-provider timeout — matching the behavior of the original +// hand-written *FromInterfaces helpers. +func runProviderRead[T, R any]( + ctx context.Context, + timeout time.Duration, + providers []interface{}, + ifaceName string, + op func(ctx context.Context, impl T) (R, error), +) (result R, metadata Metadata, err error) { + metadata = newMetadata() + + var matched bool + for _, elem := range providers { + if elem == nil { + continue + } + + impl, ok := elem.(T) + if !ok { + continue + } + matched = true + + name := getProviderName(elem) + + select { + case <-ctx.Done(): + return result, metadata, multierror.Append(err, ctx.Err()) + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, name) + + callCtx, cancel := context.WithTimeout(ctx, timeout) + res, opErr := op(callCtx, impl) + cancel() + + if opErr != nil { + err = multierror.Append(err, errors.WithMessagef(opErr, "provider: %v", name)) + metadata.FailedProviderDetail[name] = opErr.Error() + continue + } + + metadata.SuccessfulProvider = name + + return res, metadata, nil + } + } + + if !matched { + return result, metadata, multierror.Append(err, fmt.Errorf("no %s implementations found", ifaceName)) + } + + return result, metadata, multierror.Append(err, fmt.Errorf("failed to invoke %s", ifaceName)) +} + +// runProviderAction is runProviderRead for operations that return only an error. +func runProviderAction[T any]( + ctx context.Context, + timeout time.Duration, + providers []interface{}, + ifaceName string, + op func(ctx context.Context, impl T) error, +) (Metadata, error) { + _, metadata, err := runProviderRead(ctx, timeout, providers, ifaceName, + func(ctx context.Context, impl T) (struct{}, error) { + return struct{}{}, op(ctx, impl) + }) + + return metadata, err +} + +// --- ThermalReader --- + +// ThermalFromInterfaces returns the result of Thermal on the first registered provider that implements ThermalReader. +func ThermalFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (ThermalReading, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "ThermalReader", + func(ctx context.Context, p ThermalReader) (ThermalReading, error) { return p.Thermal(ctx) }) +} + +// --- PowerReader / PowerCapSetter --- + +// ReadPowerFromInterfaces returns the result of ReadPower on the first registered provider that implements PowerReader. +func ReadPowerFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (PowerInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "PowerReader", + func(ctx context.Context, p PowerReader) (PowerInfo, error) { return p.ReadPower(ctx) }) +} + +// SetPowerCapFromInterfaces runs SetPowerCap on the first registered provider that implements PowerCapSetter. +func SetPowerCapFromInterfaces(ctx context.Context, timeout time.Duration, limitWatts *float64, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "PowerCapSetter", + func(ctx context.Context, p PowerCapSetter) error { return p.SetPowerCap(ctx, limitWatts) }) +} + +// --- VolumeManager --- + +// StorageControllersFromInterfaces returns the result of StorageControllers on the first registered provider that implements VolumeManager. +func StorageControllersFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]StorageControllerInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) ([]StorageControllerInfo, error) { + return p.StorageControllers(ctx) + }) +} + +// VolumesFromInterfaces returns the result of Volumes on the first registered provider that implements VolumeManager. +func VolumesFromInterfaces(ctx context.Context, timeout time.Duration, storageID string, providers []interface{}) ([]VolumeInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) ([]VolumeInfo, error) { return p.Volumes(ctx, storageID) }) +} + +// VolumeCreateFromInterfaces returns the result of VolumeCreate on the first registered provider that implements VolumeManager. +func VolumeCreateFromInterfaces(ctx context.Context, timeout time.Duration, storageID string, req VolumeCreateRequest, providers []interface{}) (string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) (string, error) { return p.VolumeCreate(ctx, storageID, req) }) +} + +// VolumeInitializeFromInterfaces runs VolumeInitialize on the first registered provider that implements VolumeManager. +func VolumeInitializeFromInterfaces(ctx context.Context, timeout time.Duration, storageID, volumeID, initType string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) error { + return p.VolumeInitialize(ctx, storageID, volumeID, initType) + }) +} + +// VolumeUpdateFromInterfaces runs VolumeUpdate on the first registered provider that implements VolumeManager. +func VolumeUpdateFromInterfaces(ctx context.Context, timeout time.Duration, storageID, volumeID string, settings map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) error { + return p.VolumeUpdate(ctx, storageID, volumeID, settings) + }) +} + +// VolumeDeleteFromInterfaces runs VolumeDelete on the first registered provider that implements VolumeManager. +func VolumeDeleteFromInterfaces(ctx context.Context, timeout time.Duration, storageID, volumeID string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "VolumeManager", + func(ctx context.Context, p VolumeManager) error { return p.VolumeDelete(ctx, storageID, volumeID) }) +} + +// --- LicenseManager --- + +// LicensesFromInterfaces returns the result of Licenses on the first registered provider that implements LicenseManager. +func LicensesFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]LicenseInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "LicenseManager", + func(ctx context.Context, p LicenseManager) ([]LicenseInfo, error) { return p.Licenses(ctx) }) +} + +// LicenseInstallFromInterfaces runs LicenseInstall on the first registered provider that implements LicenseManager. +func LicenseInstallFromInterfaces(ctx context.Context, timeout time.Duration, license string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "LicenseManager", + func(ctx context.Context, p LicenseManager) error { return p.LicenseInstall(ctx, license) }) +} + +// LicenseDeleteFromInterfaces runs LicenseDelete on the first registered provider that implements LicenseManager. +func LicenseDeleteFromInterfaces(ctx context.Context, timeout time.Duration, licenseID string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "LicenseManager", + func(ctx context.Context, p LicenseManager) error { return p.LicenseDelete(ctx, licenseID) }) +} + +// --- SecureKeyLifecycle --- + +// GetSecureKeyLifecycleFromInterfaces returns the result of GetSecureKeyLifecycle on the first registered provider that implements SecureKeyLifecycle. +func GetSecureKeyLifecycleFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (SecureKeyLifecycleConfig, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "SecureKeyLifecycle", + func(ctx context.Context, p SecureKeyLifecycle) (SecureKeyLifecycleConfig, error) { + return p.GetSecureKeyLifecycle(ctx) + }) +} + +// SetSecureKeyRepoServersFromInterfaces runs SetSecureKeyRepoServers on the first registered provider that implements SecureKeyLifecycle. +func SetSecureKeyRepoServersFromInterfaces(ctx context.Context, timeout time.Duration, servers []SecureKeyRepoServer, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "SecureKeyLifecycle", + func(ctx context.Context, p SecureKeyLifecycle) error { return p.SetSecureKeyRepoServers(ctx, servers) }) +} + +// --- NetworkInterfaceGetter / Setter --- + +// ManagerNetworkInterfacesFromInterfaces returns the result of BMCNetworkInterfaces on the first registered provider that implements NetworkInterfaceGetter. +func ManagerNetworkInterfacesFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]NetworkInterface, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "NetworkInterfaceGetter", + func(ctx context.Context, p NetworkInterfaceGetter) ([]NetworkInterface, error) { + return p.BMCNetworkInterfaces(ctx) + }) +} + +// ServerNetworkInterfacesFromInterfaces returns the result of ServerNetworkInterfaces on the first registered provider that implements NetworkInterfaceGetter. +func ServerNetworkInterfacesFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]NetworkInterface, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "NetworkInterfaceGetter", + func(ctx context.Context, p NetworkInterfaceGetter) ([]NetworkInterface, error) { + return p.ServerNetworkInterfaces(ctx) + }) +} + +// SetBMCNetworkInterfaceFromInterfaces runs SetBMCNetworkInterface on the first registered provider that implements NetworkInterfaceSetter. +func SetBMCNetworkInterfaceFromInterfaces(ctx context.Context, timeout time.Duration, id string, attrs map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "NetworkInterfaceSetter", + func(ctx context.Context, p NetworkInterfaceSetter) error { + return p.SetBMCNetworkInterface(ctx, id, attrs) + }) +} + +// SetHostInterfaceEnabledFromInterfaces runs SetHostInterfaceEnabled on the first registered provider that implements NetworkInterfaceSetter. +func SetHostInterfaceEnabledFromInterfaces(ctx context.Context, timeout time.Duration, id string, enabled bool, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "NetworkInterfaceSetter", + func(ctx context.Context, p NetworkInterfaceSetter) error { + return p.SetHostInterfaceEnabled(ctx, id, enabled) + }) +} + +// --- NetworkProtocolGetter / Setter --- + +// NetworkProtocolsFromInterfaces returns the result of NetworkProtocols on the first registered provider that implements NetworkProtocolGetter. +func NetworkProtocolsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]NetworkProtocol, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "NetworkProtocolGetter", + func(ctx context.Context, p NetworkProtocolGetter) ([]NetworkProtocol, error) { + return p.NetworkProtocols(ctx) + }) +} + +// SetNetworkProtocolsFromInterfaces runs SetNetworkProtocols on the first registered provider that implements NetworkProtocolSetter. +func SetNetworkProtocolsFromInterfaces(ctx context.Context, timeout time.Duration, attrs map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "NetworkProtocolSetter", + func(ctx context.Context, p NetworkProtocolSetter) error { return p.SetNetworkProtocols(ctx, attrs) }) +} + +// --- SerialInterfaceGetter / Setter --- + +// SerialInterfacesFromInterfaces returns the result of SerialInterfaces on the first registered provider that implements SerialInterfaceGetter. +func SerialInterfacesFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]SerialInterface, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "SerialInterfaceGetter", + func(ctx context.Context, p SerialInterfaceGetter) ([]SerialInterface, error) { + return p.SerialInterfaces(ctx) + }) +} + +// SetSerialInterfaceFromInterfaces runs SetSerialInterface on the first registered provider that implements SerialInterfaceSetter. +func SetSerialInterfaceFromInterfaces(ctx context.Context, timeout time.Duration, id string, attrs map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "SerialInterfaceSetter", + func(ctx context.Context, p SerialInterfaceSetter) error { return p.SetSerialInterface(ctx, id, attrs) }) +} + +// --- EventSubscriber --- + +// EventServiceFromInterfaces returns the result of EventService on the first registered provider that implements EventSubscriber. +func EventServiceFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (EventServiceInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) (EventServiceInfo, error) { return p.EventService(ctx) }) +} + +// EventSubscriptionsFromInterfaces returns the result of EventSubscriptions on the first registered provider that implements EventSubscriber. +func EventSubscriptionsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]EventSubscription, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) ([]EventSubscription, error) { + return p.EventSubscriptions(ctx) + }) +} + +// EventSubscriptionCreateFromInterfaces returns the result of EventSubscriptionCreate on the first registered provider that implements EventSubscriber. +func EventSubscriptionCreateFromInterfaces(ctx context.Context, timeout time.Duration, req EventSubscriptionRequest, providers []interface{}) (string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) (string, error) { + return p.EventSubscriptionCreate(ctx, req) + }) +} + +// EventSubscriptionDeleteFromInterfaces runs EventSubscriptionDelete on the first registered provider that implements EventSubscriber. +func EventSubscriptionDeleteFromInterfaces(ctx context.Context, timeout time.Duration, id string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) error { return p.EventSubscriptionDelete(ctx, id) }) +} + +// SubmitTestEventFromInterfaces runs SubmitTestEvent on the first registered provider that implements EventSubscriber. +func SubmitTestEventFromInterfaces(ctx context.Context, timeout time.Duration, messageID string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) error { return p.SubmitTestEvent(ctx, messageID) }) +} + +// SetEventServiceFromInterfaces runs SetEventService on the first registered provider that implements EventSubscriber. +func SetEventServiceFromInterfaces(ctx context.Context, timeout time.Duration, attrs map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "EventSubscriber", + func(ctx context.Context, p EventSubscriber) error { return p.SetEventService(ctx, attrs) }) +} + +// --- TelemetryReader --- + +// TelemetryServiceFromInterfaces returns the result of TelemetryService on the first registered provider that implements TelemetryReader. +func TelemetryServiceFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (TelemetryServiceInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) (TelemetryServiceInfo, error) { + return p.TelemetryService(ctx) + }) +} + +// MetricReportsFromInterfaces returns the result of MetricReports on the first registered provider that implements TelemetryReader. +func MetricReportsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) ([]string, error) { return p.MetricReports(ctx) }) +} + +// MetricReportFromInterfaces returns the result of MetricReport on the first registered provider that implements TelemetryReader. +func MetricReportFromInterfaces(ctx context.Context, timeout time.Duration, id string, providers []interface{}) (MetricReportInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) (MetricReportInfo, error) { return p.MetricReport(ctx, id) }) +} + +// MetricReportDefinitionsFromInterfaces returns the result of MetricReportDefinitions on the first registered provider that implements TelemetryReader. +func MetricReportDefinitionsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) ([]string, error) { return p.MetricReportDefinitions(ctx) }) +} + +// MetricDefinitionsFromInterfaces returns the result of MetricDefinitions on the first registered provider that implements TelemetryReader. +func MetricDefinitionsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) ([]string, error) { return p.MetricDefinitions(ctx) }) +} + +// SubmitTestMetricReportFromInterfaces runs SubmitTestMetricReport on the first registered provider that implements TelemetryReader. +func SubmitTestMetricReportFromInterfaces(ctx context.Context, timeout time.Duration, reportName string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "TelemetryReader", + func(ctx context.Context, p TelemetryReader) error { return p.SubmitTestMetricReport(ctx, reportName) }) +} + +// --- JobManager --- + +// JobServiceFromInterfaces returns the result of JobService on the first registered provider that implements JobManager. +func JobServiceFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (JobServiceInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "JobManager", + func(ctx context.Context, p JobManager) (JobServiceInfo, error) { return p.JobService(ctx) }) +} + +// JobsFromInterfaces returns the result of Jobs on the first registered provider that implements JobManager. +func JobsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]JobInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "JobManager", + func(ctx context.Context, p JobManager) ([]JobInfo, error) { return p.Jobs(ctx) }) +} + +// JobFromInterfaces returns the result of Job on the first registered provider that implements JobManager. +func JobFromInterfaces(ctx context.Context, timeout time.Duration, id string, providers []interface{}) (JobInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "JobManager", + func(ctx context.Context, p JobManager) (JobInfo, error) { return p.Job(ctx, id) }) +} + +// JobUpdateScheduleFromInterfaces runs JobUpdateSchedule on the first registered provider that implements JobManager. +func JobUpdateScheduleFromInterfaces(ctx context.Context, timeout time.Duration, id string, schedule map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "JobManager", + func(ctx context.Context, p JobManager) error { return p.JobUpdateSchedule(ctx, id, schedule) }) +} + +// --- CertificateManager --- + +// CertificateLocationsFromInterfaces returns the result of CertificateLocations on the first registered provider that implements CertificateManager. +func CertificateLocationsFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) ([]string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) ([]string, error) { return p.CertificateLocations(ctx) }) +} + +// CertificateFromInterfaces returns the result of Certificate on the first registered provider that implements CertificateManager. +func CertificateFromInterfaces(ctx context.Context, timeout time.Duration, location string, providers []interface{}) (CertificateInfo, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) (CertificateInfo, error) { + return p.Certificate(ctx, location) + }) +} + +// GenerateCSRFromInterfaces returns the result of GenerateCSR on the first registered provider that implements CertificateManager. +func GenerateCSRFromInterfaces(ctx context.Context, timeout time.Duration, req *CSRRequest, providers []interface{}) (string, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) (string, error) { return p.GenerateCSR(ctx, req) }) +} + +// ReplaceCertificateFromInterfaces runs ReplaceCertificate on the first registered provider that implements CertificateManager. +func ReplaceCertificateFromInterfaces(ctx context.Context, timeout time.Duration, certificatePEM, targetURI string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) error { + return p.ReplaceCertificate(ctx, certificatePEM, targetURI) + }) +} + +// RekeyCertificateFromInterfaces runs RekeyCertificate on the first registered provider that implements CertificateManager. +func RekeyCertificateFromInterfaces(ctx context.Context, timeout time.Duration, certURI string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) error { return p.RekeyCertificate(ctx, certURI) }) +} + +// RenewCertificateFromInterfaces runs RenewCertificate on the first registered provider that implements CertificateManager. +func RenewCertificateFromInterfaces(ctx context.Context, timeout time.Duration, certURI string, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "CertificateManager", + func(ctx context.Context, p CertificateManager) error { return p.RenewCertificate(ctx, certURI) }) +} + +// --- SNMPConfigurer --- + +// SNMPFromInterfaces returns the result of SNMP on the first registered provider that implements SNMPConfigurer. +func SNMPFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (SNMPConfig, Metadata, error) { + return runProviderRead(ctx, timeout, providers, "SNMPConfigurer", + func(ctx context.Context, p SNMPConfigurer) (SNMPConfig, error) { return p.SNMP(ctx) }) +} + +// SetSNMPAlertFilterFromInterfaces runs SetSNMPAlertFilter on the first registered provider that implements SNMPConfigurer. +func SetSNMPAlertFilterFromInterfaces(ctx context.Context, timeout time.Duration, attrs map[string]any, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "SNMPConfigurer", + func(ctx context.Context, p SNMPConfigurer) error { return p.SetSNMPAlertFilter(ctx, attrs) }) +} + +// EnableSNMPv1TrapFromInterfaces runs EnableSNMPv1Trap on the first registered provider that implements SNMPConfigurer. +func EnableSNMPv1TrapFromInterfaces(ctx context.Context, timeout time.Duration, enabled bool, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "SNMPConfigurer", + func(ctx context.Context, p SNMPConfigurer) error { return p.EnableSNMPv1Trap(ctx, enabled) }) +} + +// EnableSNMPv3TrapFromInterfaces runs EnableSNMPv3Trap on the first registered provider that implements SNMPConfigurer. +func EnableSNMPv3TrapFromInterfaces(ctx context.Context, timeout time.Duration, enabled bool, providers []interface{}) (Metadata, error) { + return runProviderAction(ctx, timeout, providers, "SNMPConfigurer", + func(ctx context.Context, p SNMPConfigurer) error { return p.EnableSNMPv3Trap(ctx, enabled) }) +} diff --git a/bmc/extended_test.go b/bmc/extended_test.go new file mode 100644 index 00000000..9902b3da --- /dev/null +++ b/bmc/extended_test.go @@ -0,0 +1,118 @@ +package bmc + +import ( + "context" + "errors" + "testing" + "time" +) + +// fakePower is a test provider implementing PowerReader and PowerCapSetter +// (and Provider for a stable name). +type fakePower struct { + name string + info PowerInfo + getErr error + setErr error + setSeen *bool +} + +func (f *fakePower) Name() string { return f.name } + +func (f *fakePower) ReadPower(_ context.Context) (PowerInfo, error) { + return f.info, f.getErr +} + +func (f *fakePower) SetPowerCap(_ context.Context, _ *float64) error { + if f.setSeen != nil { + *f.setSeen = true + } + return f.setErr +} + +// bareProvider implements Provider but none of the extended interfaces. +type bareProvider struct{ name string } + +func (b *bareProvider) Name() string { return b.name } + +const testTimeout = 5 * time.Second + +func TestRunProviderRead_Success(t *testing.T) { + want := PowerInfo{ConsumedWatts: 240, CapacityWatts: 1100} + providers := []interface{}{ + &bareProvider{name: "bare"}, + &fakePower{name: "lenovo", info: want}, + } + + got, metadata, err := ReadPowerFromInterfaces(context.Background(), testTimeout, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ConsumedWatts != want.ConsumedWatts || got.CapacityWatts != want.CapacityWatts { + t.Errorf("info = %+v, want %+v", got, want) + } + if metadata.SuccessfulProvider != "lenovo" { + t.Errorf("SuccessfulProvider = %q, want %q", metadata.SuccessfulProvider, "lenovo") + } +} + +func TestRunProviderRead_NoImplementations(t *testing.T) { + providers := []interface{}{&bareProvider{name: "bare"}, nil} + + _, _, err := ReadPowerFromInterfaces(context.Background(), testTimeout, providers) + if err == nil { + t.Fatal("expected an error when no provider implements the interface") + } + if !containsStr(err.Error(), "no PowerReader implementations found") { + t.Errorf("error = %q, want it to mention no implementations", err.Error()) + } +} + +func TestRunProviderRead_FailureRecorded(t *testing.T) { + providers := []interface{}{ + &fakePower{name: "lenovo", getErr: errors.New("boom")}, + } + + _, metadata, err := ReadPowerFromInterfaces(context.Background(), testTimeout, providers) + if err == nil { + t.Fatal("expected an error when the provider call fails") + } + if detail := metadata.FailedProviderDetail["lenovo"]; detail == "" { + t.Error("expected FailedProviderDetail to record the failing provider") + } +} + +func TestRunProviderAction_Success(t *testing.T) { + var seen bool + providers := []interface{}{&fakePower{name: "lenovo", setSeen: &seen}} + + limit := 500.0 + metadata, err := SetPowerCapFromInterfaces(context.Background(), testTimeout, &limit, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !seen { + t.Error("expected SetPowerCap to be invoked") + } + if metadata.SuccessfulProvider != "lenovo" { + t.Errorf("SuccessfulProvider = %q, want %q", metadata.SuccessfulProvider, "lenovo") + } +} + +func TestRunProviderAction_Error(t *testing.T) { + providers := []interface{}{&fakePower{name: "lenovo", setErr: errors.New("denied")}} + + _, err := SetPowerCapFromInterfaces(context.Background(), testTimeout, nil, providers) + if err == nil { + t.Fatal("expected an error when the action fails") + } +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/bmc/job.go b/bmc/job.go new file mode 100644 index 00000000..586dbd7e --- /dev/null +++ b/bmc/job.go @@ -0,0 +1,36 @@ +package bmc + +import "context" + +// JobServiceInfo describes the Redfish JobService configuration. +type JobServiceInfo struct { + // ServiceEnabled reports whether the job service is enabled. + ServiceEnabled bool +} + +// JobInfo describes a Redfish job. +type JobInfo struct { + // ID is the Redfish Job Id. + ID string + // Name is the job name. + Name string + // JobState is the job state (e.g. "Scheduled", "Running", "Completed"). + JobState string + // PercentComplete is the job completion percentage. + PercentComplete int + // StartTime is the scheduled/actual start time. + StartTime string +} + +// JobManager is implemented by providers that can read jobs and update a job's +// schedule. +type JobManager interface { + // JobService returns the job-service configuration. + JobService(ctx context.Context) (JobServiceInfo, error) + // Jobs lists the jobs. + Jobs(ctx context.Context) ([]JobInfo, error) + // Job returns a job by Id. + Job(ctx context.Context, id string) (JobInfo, error) + // JobUpdateSchedule PATCHes a job's Schedule with the given properties. + JobUpdateSchedule(ctx context.Context, id string, schedule map[string]any) error +} diff --git a/bmc/license.go b/bmc/license.go new file mode 100644 index 00000000..806864cb --- /dev/null +++ b/bmc/license.go @@ -0,0 +1,29 @@ +package bmc + +import "context" + +// LicenseInfo describes an installed BMC license. +type LicenseInfo struct { + // ID is the Redfish License resource Id. + ID string + // EntitlementID is the license entitlement identifier. + EntitlementID string + // LicenseType is the license type (e.g. "Production", "Trial"). + LicenseType string + // State is the license status state (e.g. "Enabled"). + State string + // Removable reports whether the license can be deleted. + Removable bool +} + +// LicenseManager is implemented by providers that can read, install and delete +// BMC licenses. +type LicenseManager interface { + // Licenses returns the installed licenses. + Licenses(ctx context.Context) ([]LicenseInfo, error) + // LicenseInstall installs a license from its (typically base64-encoded) + // license string. + LicenseInstall(ctx context.Context, license string) error + // LicenseDelete removes an installed license by its Id. + LicenseDelete(ctx context.Context, licenseID string) error +} diff --git a/bmc/network.go b/bmc/network.go new file mode 100644 index 00000000..b94597e9 --- /dev/null +++ b/bmc/network.go @@ -0,0 +1,64 @@ +package bmc + +import "context" + +// NetworkInterface describes a BMC or server ethernet interface. +type NetworkInterface struct { + // ID is the Redfish EthernetInterface Id. + ID string + // MACAddress is the interface MAC address. + MACAddress string + // HostName is the configured hostname (BMC interfaces). + HostName string + // IPv4Addresses are the configured IPv4 addresses. + IPv4Addresses []string + // IPv6Addresses are the configured IPv6 addresses. + IPv6Addresses []string + // Enabled reports whether the interface is enabled. + Enabled bool +} + +// NetworkProtocol describes a manager network service (protocol) and its state. +type NetworkProtocol struct { + // Name is the service name (e.g. "HTTPS", "SSH", "IPMI", "SNMP", "NTP"). + Name string + // Enabled reports whether the service is enabled. + Enabled bool + // Port is the service port (0 when not reported). + Port int +} + +// NetworkInterfaceGetter is implemented by providers that can read BMC and +// server ethernet interfaces. +type NetworkInterfaceGetter interface { + // BMCNetworkInterfaces returns the BMC (manager) ethernet interfaces. + BMCNetworkInterfaces(ctx context.Context) ([]NetworkInterface, error) + // ServerNetworkInterfaces returns the server (system) ethernet interfaces. + ServerNetworkInterfaces(ctx context.Context) ([]NetworkInterface, error) +} + +// NetworkInterfaceSetter is implemented by providers that can configure BMC +// network interfaces. +type NetworkInterfaceSetter interface { + // SetBMCNetworkInterface PATCHes the BMC ethernet interface with id using the + // given Redfish attributes (e.g. {"HostName": "x", "IPv4StaticAddresses": [...], + // "VLAN": {...}}). + SetBMCNetworkInterface(ctx context.Context, id string, attrs map[string]any) error + // SetHostInterfaceEnabled enables or disables the host interface with id. + SetHostInterfaceEnabled(ctx context.Context, id string, enabled bool) error +} + +// NetworkProtocolGetter is implemented by providers that can read the manager +// network protocols/services. +type NetworkProtocolGetter interface { + // NetworkProtocols returns the manager network services and their state. + NetworkProtocols(ctx context.Context) ([]NetworkProtocol, error) +} + +// NetworkProtocolSetter is implemented by providers that can configure the +// manager network protocols/services. +type NetworkProtocolSetter interface { + // SetNetworkProtocols PATCHes ManagerNetworkProtocol with the given Redfish + // attributes (e.g. {"IPMI": {"ProtocolEnabled": false}}). + SetNetworkProtocols(ctx context.Context, attrs map[string]any) error +} diff --git a/bmc/power_cap.go b/bmc/power_cap.go new file mode 100644 index 00000000..fa6c6179 --- /dev/null +++ b/bmc/power_cap.go @@ -0,0 +1,41 @@ +package bmc + +import "context" + +// PowerSupplyInfo describes a single power supply. +type PowerSupplyInfo struct { + // Name is the power supply name. + Name string + // Health is the power supply health (e.g. "OK", "Warning", "Critical"). + Health string + // CapacityWatts is the maximum output capacity in watts. + CapacityWatts float64 +} + +// PowerInfo aggregates a device's power metrics and supplies. +type PowerInfo struct { + // ConsumedWatts is the current power draw in watts. + ConsumedWatts float64 + // CapacityWatts is the total power capacity in watts. + CapacityWatts float64 + // LimitInWatts is the configured power cap in watts; nil means no cap is + // configured (power capping disabled). + LimitInWatts *float64 + // PowerSupplies lists the device's power supplies. + PowerSupplies []PowerSupplyInfo +} + +// PowerReader is implemented by providers that can read a device's power +// metrics. +type PowerReader interface { + // ReadPower returns the device's power metrics and supplies. + ReadPower(ctx context.Context) (PowerInfo, error) +} + +// PowerCapSetter is implemented by providers that can set a chassis power limit +// (power capping). +type PowerCapSetter interface { + // SetPowerCap sets the power limit in watts. A nil limitWatts disables power + // capping. + SetPowerCap(ctx context.Context, limitWatts *float64) error +} diff --git a/bmc/secure_key_lifecycle.go b/bmc/secure_key_lifecycle.go new file mode 100644 index 00000000..cbbb8e4c --- /dev/null +++ b/bmc/secure_key_lifecycle.go @@ -0,0 +1,28 @@ +package bmc + +import "context" + +// SecureKeyRepoServer is a key-repository (SKLM/TKLM) server endpoint. +type SecureKeyRepoServer struct { + // HostName is a remote server address (IPv4, IPv6 or hostname). + HostName string + // Port is the remote server port (1-65535). + Port int +} + +// SecureKeyLifecycleConfig is the Secure Key Lifecycle (SKLM) configuration. +type SecureKeyLifecycleConfig struct { + // DeviceGroup is the SKLM device group name. + DeviceGroup string + // KeyRepoServers is the list of configured key-repository servers. + KeyRepoServers []SecureKeyRepoServer +} + +// SecureKeyLifecycle is implemented by providers that can read and configure the +// Secure Key Lifecycle service (key-repository servers). +type SecureKeyLifecycle interface { + // GetSecureKeyLifecycle returns the current SKLM configuration. + GetSecureKeyLifecycle(ctx context.Context) (SecureKeyLifecycleConfig, error) + // SetSecureKeyRepoServers replaces the configured key-repository servers. + SetSecureKeyRepoServers(ctx context.Context, servers []SecureKeyRepoServer) error +} diff --git a/bmc/serial.go b/bmc/serial.go new file mode 100644 index 00000000..3b078bd4 --- /dev/null +++ b/bmc/serial.go @@ -0,0 +1,36 @@ +package bmc + +import "context" + +// SerialInterface describes a BMC serial interface. +type SerialInterface struct { + // ID is the Redfish SerialInterface Id. + ID string + // Enabled reports whether the interface is enabled. + Enabled bool + // BitRate is the configured baud rate (e.g. "115200"). + BitRate string + // FlowControl is the flow-control mode (e.g. "None", "Software", "Hardware"). + FlowControl string + // Parity is the parity mode (e.g. "None", "Even", "Odd"). + Parity string + // StopBits is the number of stop bits (e.g. "1", "2"). + StopBits string + // DataBits is the number of data bits (e.g. "8"). + DataBits string +} + +// SerialInterfaceGetter is implemented by providers that can read BMC serial +// interfaces. +type SerialInterfaceGetter interface { + // SerialInterfaces returns the BMC serial interfaces. + SerialInterfaces(ctx context.Context) ([]SerialInterface, error) +} + +// SerialInterfaceSetter is implemented by providers that can configure a BMC +// serial interface. +type SerialInterfaceSetter interface { + // SetSerialInterface PATCHes the serial interface with id using the given + // Redfish attributes (e.g. {"BitRate": "115200", "FlowControl": "None"}). + SetSerialInterface(ctx context.Context, id string, attrs map[string]any) error +} diff --git a/bmc/snmp.go b/bmc/snmp.go new file mode 100644 index 00000000..f948d84e --- /dev/null +++ b/bmc/snmp.go @@ -0,0 +1,29 @@ +package bmc + +import "context" + +// SNMPConfig describes the BMC SNMP trap configuration. +type SNMPConfig struct { + // V1TrapEnabled reports whether the SNMPv1 trap is enabled. + V1TrapEnabled bool + // V3TrapEnabled reports whether the SNMPv3 trap is enabled. + V3TrapEnabled bool + // TrapPort is the trap receiver port. + TrapPort int + // CommunityNames are the configured SNMPv1 community names. + CommunityNames []string +} + +// SNMPConfigurer is implemented by providers that can read and configure SNMP +// traps. +type SNMPConfigurer interface { + // SNMP returns the SNMP trap configuration. + SNMP(ctx context.Context) (SNMPConfig, error) + // SetSNMPAlertFilter PATCHes the SNMP trap (SNMPTraps) properties with the + // given attributes (alert recipients, community names, addresses, ...). + SetSNMPAlertFilter(ctx context.Context, attrs map[string]any) error + // EnableSNMPv1Trap enables or disables the SNMPv1 trap. + EnableSNMPv1Trap(ctx context.Context, enabled bool) error + // EnableSNMPv3Trap enables or disables the SNMPv3 trap. + EnableSNMPv3Trap(ctx context.Context, enabled bool) error +} diff --git a/bmc/telemetry.go b/bmc/telemetry.go new file mode 100644 index 00000000..f8f18e76 --- /dev/null +++ b/bmc/telemetry.go @@ -0,0 +1,54 @@ +package bmc + +import "context" + +// TelemetryServiceInfo describes the Redfish TelemetryService configuration. +type TelemetryServiceInfo struct { + // ServiceEnabled reports whether the telemetry service is enabled. + ServiceEnabled bool + // MaxReports is the maximum number of metric reports supported. + MaxReports int + // MinCollectionInterval is the minimum metric collection interval (ISO 8601). + MinCollectionInterval string +} + +// MetricValue is a single metric reading within a metric report. +type MetricValue struct { + // MetricID is the metric identifier (Redfish MetricId). XCC often leaves + // this empty and identifies the reading via MetricProperty instead. + MetricID string + // MetricProperty is the Redfish resource property the reading came from + // (e.g. "/redfish/v1/Chassis/1/Power#/PowerControl/0/PowerMetrics/MaxConsumedWatts"). + MetricProperty string + // Value is the metric value (as reported, stringified). + Value string + // Timestamp is the reading timestamp. + Timestamp string +} + +// MetricReportInfo is a metric report and its values. +type MetricReportInfo struct { + // ID is the Redfish MetricReport Id. + ID string + // Name is the report name. + Name string + // Values are the report's metric values. + Values []MetricValue +} + +// TelemetryReader is implemented by providers that can read telemetry-service +// metric reports and definitions. +type TelemetryReader interface { + // TelemetryService returns the telemetry-service configuration. + TelemetryService(ctx context.Context) (TelemetryServiceInfo, error) + // MetricReports lists the metric report Ids. + MetricReports(ctx context.Context) ([]string, error) + // MetricReport returns a metric report and its values by Id. + MetricReport(ctx context.Context, id string) (MetricReportInfo, error) + // MetricReportDefinitions lists the metric report definition Ids. + MetricReportDefinitions(ctx context.Context) ([]string, error) + // MetricDefinitions lists the metric definition Ids. + MetricDefinitions(ctx context.Context) ([]string, error) + // SubmitTestMetricReport submits a test metric report. + SubmitTestMetricReport(ctx context.Context, reportName string) error +} diff --git a/bmc/thermal.go b/bmc/thermal.go new file mode 100644 index 00000000..ebcf78bb --- /dev/null +++ b/bmc/thermal.go @@ -0,0 +1,37 @@ +package bmc + +import "context" + +// TemperatureReading is a single temperature sensor reading. +type TemperatureReading struct { + // Name is the sensor name (e.g. "Ambient Temp"). + Name string + // ReadingCelsius is the current temperature in degrees Celsius. + ReadingCelsius float64 + // Health is the sensor health (e.g. "OK", "Warning", "Critical"). + Health string +} + +// FanReading is a single fan reading. +type FanReading struct { + // Name is the fan name (e.g. "Fan 1 Tach"). + Name string + // Reading is the current fan reading (RPM, unless the device reports + // another unit). + Reading int + // Health is the fan health (e.g. "OK", "Warning", "Critical"). + Health string +} + +// ThermalReading aggregates the thermal sensors of a device. +type ThermalReading struct { + Temperatures []TemperatureReading + Fans []FanReading +} + +// ThermalReader is implemented by providers that can read a device's thermal +// sensors (temperatures and fans). +type ThermalReader interface { + // Thermal returns the device's temperature and fan readings. + Thermal(ctx context.Context) (ThermalReading, error) +} diff --git a/bmc/volume.go b/bmc/volume.go new file mode 100644 index 00000000..140619b3 --- /dev/null +++ b/bmc/volume.go @@ -0,0 +1,66 @@ +package bmc + +import "context" + +// StorageControllerInfo describes a storage controller. +type StorageControllerInfo struct { + // ID is the Redfish Storage resource Id (used to address volumes/drives). + ID string + // Name is the controller name. + Name string + // Model is the controller model. + Model string + // Health is the controller health (e.g. "OK", "Warning", "Critical"). + Health string + // DriveCount is the number of drives managed by the controller. + DriveCount int +} + +// VolumeInfo describes a storage volume. +type VolumeInfo struct { + // ID is the Redfish Volume resource Id. + ID string + // Name is the volume name. + Name string + // RAIDType is the volume RAID type (e.g. "RAID0", "RAID1"). + RAIDType string + // CapacityBytes is the volume capacity in bytes. + CapacityBytes int64 + // Health is the volume health (e.g. "OK", "Warning", "Critical"). + Health string +} + +// VolumeCreateRequest describes a volume to create on a storage controller. +type VolumeCreateRequest struct { + // Name is the requested volume name. + Name string + // RAIDType is the requested RAID type (e.g. "RAID0", "RAID1", "RAID5"). + RAIDType string + // CapacityBytes is the requested capacity in bytes. Zero lets the + // controller choose the maximum available capacity. + CapacityBytes int64 + // Drives is the list of member drive Redfish @odata.id paths. + Drives []string +} + +// VolumeManager is implemented by providers that can read and manage storage +// volumes on a controller. +// +// Volumes and controllers are addressed by their Redfish resource Id within the +// system's Storage collection. +type VolumeManager interface { + // StorageControllers lists the storage controllers of the system. + StorageControllers(ctx context.Context) ([]StorageControllerInfo, error) + // Volumes lists the volumes managed by the given storage controller. + Volumes(ctx context.Context, storageID string) ([]VolumeInfo, error) + // VolumeCreate creates a volume on the given storage controller and returns + // the new volume Id (or a task Id when the operation is asynchronous). + VolumeCreate(ctx context.Context, storageID string, req VolumeCreateRequest) (volumeID string, err error) + // VolumeInitialize initializes a volume. initType is a Redfish + // InitializeType such as "Fast" or "Slow". + VolumeInitialize(ctx context.Context, storageID, volumeID, initType string) error + // VolumeUpdate updates volume settings via a PATCH of the given attributes. + VolumeUpdate(ctx context.Context, storageID, volumeID string, settings map[string]any) error + // VolumeDelete deletes a volume. + VolumeDelete(ctx context.Context, storageID, volumeID string) error +} diff --git a/client_features.go b/client_features.go new file mode 100644 index 00000000..bf023d47 --- /dev/null +++ b/client_features.go @@ -0,0 +1,612 @@ +package bmclib + +import ( + "context" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// This file exposes the high-level bmclib.Client methods for the extended +// vendor-capability interfaces. Each method resolves the registered providers, +// dispatches to the corresponding bmc.*FromInterfaces helper using the +// per-provider timeout, stores the returned metadata and records span +// attributes — the same contract as the core Client methods (GetPowerState, +// SetBootDevice, ...). + +// --- Thermal / Power --- + +// GetThermal returns the device's temperature and fan readings. +func (c *Client) GetThermal(ctx context.Context) (bmc.ThermalReading, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetThermal") + defer span.End() + + reading, metadata, err := bmc.ThermalFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return reading, err +} + +// ReadPower returns the device's power metrics and supplies. +func (c *Client) ReadPower(ctx context.Context) (bmc.PowerInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ReadPower") + defer span.End() + + info, metadata, err := bmc.ReadPowerFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return info, err +} + +// SetPowerCap sets the chassis power limit in watts; a nil limit disables capping. +func (c *Client) SetPowerCap(ctx context.Context, limitWatts *float64) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetPowerCap") + defer span.End() + + metadata, err := bmc.SetPowerCapFromInterfaces(ctx, c.perProviderTimeout(ctx), limitWatts, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Storage volumes --- + +// StorageControllers lists the system's storage controllers. +func (c *Client) StorageControllers(ctx context.Context) ([]bmc.StorageControllerInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "StorageControllers") + defer span.End() + + controllers, metadata, err := bmc.StorageControllersFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return controllers, err +} + +// Volumes lists the volumes managed by a storage controller. +func (c *Client) Volumes(ctx context.Context, storageID string) ([]bmc.VolumeInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Volumes") + defer span.End() + + volumes, metadata, err := bmc.VolumesFromInterfaces(ctx, c.perProviderTimeout(ctx), storageID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return volumes, err +} + +// VolumeCreate creates a volume on a storage controller and returns its Id. +func (c *Client) VolumeCreate(ctx context.Context, storageID string, req bmc.VolumeCreateRequest) (string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "VolumeCreate") + defer span.End() + + id, metadata, err := bmc.VolumeCreateFromInterfaces(ctx, c.perProviderTimeout(ctx), storageID, req, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return id, err +} + +// VolumeInitialize initializes a volume. +func (c *Client) VolumeInitialize(ctx context.Context, storageID, volumeID, initType string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "VolumeInitialize") + defer span.End() + + metadata, err := bmc.VolumeInitializeFromInterfaces(ctx, c.perProviderTimeout(ctx), storageID, volumeID, initType, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// VolumeUpdate updates volume settings. +func (c *Client) VolumeUpdate(ctx context.Context, storageID, volumeID string, settings map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "VolumeUpdate") + defer span.End() + + metadata, err := bmc.VolumeUpdateFromInterfaces(ctx, c.perProviderTimeout(ctx), storageID, volumeID, settings, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// VolumeDelete deletes a volume. +func (c *Client) VolumeDelete(ctx context.Context, storageID, volumeID string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "VolumeDelete") + defer span.End() + + metadata, err := bmc.VolumeDeleteFromInterfaces(ctx, c.perProviderTimeout(ctx), storageID, volumeID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Licenses --- + +// Licenses returns the installed BMC licenses. +func (c *Client) Licenses(ctx context.Context) ([]bmc.LicenseInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Licenses") + defer span.End() + + licenses, metadata, err := bmc.LicensesFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return licenses, err +} + +// LicenseInstall installs a license from its license string. +func (c *Client) LicenseInstall(ctx context.Context, license string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "LicenseInstall") + defer span.End() + + metadata, err := bmc.LicenseInstallFromInterfaces(ctx, c.perProviderTimeout(ctx), license, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// LicenseDelete removes an installed license by Id. +func (c *Client) LicenseDelete(ctx context.Context, licenseID string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "LicenseDelete") + defer span.End() + + metadata, err := bmc.LicenseDeleteFromInterfaces(ctx, c.perProviderTimeout(ctx), licenseID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Secure Key Lifecycle --- + +// GetSecureKeyLifecycle returns the Secure Key Lifecycle configuration. +func (c *Client) GetSecureKeyLifecycle(ctx context.Context) (bmc.SecureKeyLifecycleConfig, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetSecureKeyLifecycle") + defer span.End() + + cfg, metadata, err := bmc.GetSecureKeyLifecycleFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return cfg, err +} + +// SetSecureKeyRepoServers replaces the configured key-repository servers. +func (c *Client) SetSecureKeyRepoServers(ctx context.Context, servers []bmc.SecureKeyRepoServer) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetSecureKeyRepoServers") + defer span.End() + + metadata, err := bmc.SetSecureKeyRepoServersFromInterfaces(ctx, c.perProviderTimeout(ctx), servers, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Network interfaces & protocols --- + +// BMCNetworkInterfaces returns the BMC (manager) ethernet interfaces. +func (c *Client) BMCNetworkInterfaces(ctx context.Context) ([]bmc.NetworkInterface, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "BMCNetworkInterfaces") + defer span.End() + + ifaces, metadata, err := bmc.ManagerNetworkInterfacesFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return ifaces, err +} + +// ServerNetworkInterfaces returns the server (system) ethernet interfaces. +func (c *Client) ServerNetworkInterfaces(ctx context.Context) ([]bmc.NetworkInterface, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ServerNetworkInterfaces") + defer span.End() + + ifaces, metadata, err := bmc.ServerNetworkInterfacesFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return ifaces, err +} + +// SetBMCNetworkInterface PATCHes a BMC ethernet interface. +func (c *Client) SetBMCNetworkInterface(ctx context.Context, id string, attrs map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetBMCNetworkInterface") + defer span.End() + + metadata, err := bmc.SetBMCNetworkInterfaceFromInterfaces(ctx, c.perProviderTimeout(ctx), id, attrs, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// SetHostInterfaceEnabled enables or disables a host interface. +func (c *Client) SetHostInterfaceEnabled(ctx context.Context, id string, enabled bool) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetHostInterfaceEnabled") + defer span.End() + + metadata, err := bmc.SetHostInterfaceEnabledFromInterfaces(ctx, c.perProviderTimeout(ctx), id, enabled, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// NetworkProtocols returns the manager network services and their state. +func (c *Client) NetworkProtocols(ctx context.Context) ([]bmc.NetworkProtocol, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "NetworkProtocols") + defer span.End() + + protos, metadata, err := bmc.NetworkProtocolsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return protos, err +} + +// SetNetworkProtocols PATCHes the manager network protocols. +func (c *Client) SetNetworkProtocols(ctx context.Context, attrs map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetNetworkProtocols") + defer span.End() + + metadata, err := bmc.SetNetworkProtocolsFromInterfaces(ctx, c.perProviderTimeout(ctx), attrs, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Serial interfaces --- + +// SerialInterfaces returns the BMC serial interfaces. +func (c *Client) SerialInterfaces(ctx context.Context) ([]bmc.SerialInterface, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SerialInterfaces") + defer span.End() + + ifaces, metadata, err := bmc.SerialInterfacesFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return ifaces, err +} + +// SetSerialInterface PATCHes a BMC serial interface. +func (c *Client) SetSerialInterface(ctx context.Context, id string, attrs map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetSerialInterface") + defer span.End() + + metadata, err := bmc.SetSerialInterfaceFromInterfaces(ctx, c.perProviderTimeout(ctx), id, attrs, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Events --- + +// EventService returns the event-service configuration. +func (c *Client) EventService(ctx context.Context) (bmc.EventServiceInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EventService") + defer span.End() + + info, metadata, err := bmc.EventServiceFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return info, err +} + +// EventSubscriptions lists the event subscriptions. +func (c *Client) EventSubscriptions(ctx context.Context) ([]bmc.EventSubscription, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EventSubscriptions") + defer span.End() + + subs, metadata, err := bmc.EventSubscriptionsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return subs, err +} + +// EventSubscriptionCreate creates an event subscription and returns its Id. +func (c *Client) EventSubscriptionCreate(ctx context.Context, req bmc.EventSubscriptionRequest) (string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EventSubscriptionCreate") + defer span.End() + + id, metadata, err := bmc.EventSubscriptionCreateFromInterfaces(ctx, c.perProviderTimeout(ctx), req, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return id, err +} + +// EventSubscriptionDelete deletes an event subscription by Id. +func (c *Client) EventSubscriptionDelete(ctx context.Context, id string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EventSubscriptionDelete") + defer span.End() + + metadata, err := bmc.EventSubscriptionDeleteFromInterfaces(ctx, c.perProviderTimeout(ctx), id, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// SubmitTestEvent submits a test event (optionally for a specific MessageId). +func (c *Client) SubmitTestEvent(ctx context.Context, messageID string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SubmitTestEvent") + defer span.End() + + metadata, err := bmc.SubmitTestEventFromInterfaces(ctx, c.perProviderTimeout(ctx), messageID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// SetEventService PATCHes event-service properties. +func (c *Client) SetEventService(ctx context.Context, attrs map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetEventService") + defer span.End() + + metadata, err := bmc.SetEventServiceFromInterfaces(ctx, c.perProviderTimeout(ctx), attrs, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Telemetry --- + +// TelemetryService returns the telemetry-service configuration. +func (c *Client) TelemetryService(ctx context.Context) (bmc.TelemetryServiceInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "TelemetryService") + defer span.End() + + info, metadata, err := bmc.TelemetryServiceFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return info, err +} + +// MetricReports lists the metric report Ids. +func (c *Client) MetricReports(ctx context.Context) ([]string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "MetricReports") + defer span.End() + + reports, metadata, err := bmc.MetricReportsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return reports, err +} + +// MetricReport returns a metric report and its values by Id. +func (c *Client) MetricReport(ctx context.Context, id string) (bmc.MetricReportInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "MetricReport") + defer span.End() + + report, metadata, err := bmc.MetricReportFromInterfaces(ctx, c.perProviderTimeout(ctx), id, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return report, err +} + +// MetricReportDefinitions lists the metric report definition Ids. +func (c *Client) MetricReportDefinitions(ctx context.Context) ([]string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "MetricReportDefinitions") + defer span.End() + + defs, metadata, err := bmc.MetricReportDefinitionsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return defs, err +} + +// MetricDefinitions lists the metric definition Ids. +func (c *Client) MetricDefinitions(ctx context.Context) ([]string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "MetricDefinitions") + defer span.End() + + defs, metadata, err := bmc.MetricDefinitionsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return defs, err +} + +// SubmitTestMetricReport submits a test metric report. +func (c *Client) SubmitTestMetricReport(ctx context.Context, reportName string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SubmitTestMetricReport") + defer span.End() + + metadata, err := bmc.SubmitTestMetricReportFromInterfaces(ctx, c.perProviderTimeout(ctx), reportName, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Jobs --- + +// JobService returns the job-service configuration. +func (c *Client) JobService(ctx context.Context) (bmc.JobServiceInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "JobService") + defer span.End() + + info, metadata, err := bmc.JobServiceFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return info, err +} + +// Jobs lists the jobs. +func (c *Client) Jobs(ctx context.Context) ([]bmc.JobInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Jobs") + defer span.End() + + jobs, metadata, err := bmc.JobsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return jobs, err +} + +// Job returns a job by Id. +func (c *Client) Job(ctx context.Context, id string) (bmc.JobInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Job") + defer span.End() + + job, metadata, err := bmc.JobFromInterfaces(ctx, c.perProviderTimeout(ctx), id, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return job, err +} + +// JobUpdateSchedule updates a job's schedule. +func (c *Client) JobUpdateSchedule(ctx context.Context, id string, schedule map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "JobUpdateSchedule") + defer span.End() + + metadata, err := bmc.JobUpdateScheduleFromInterfaces(ctx, c.perProviderTimeout(ctx), id, schedule, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- Certificates --- + +// CertificateLocations returns the @odata.id locations of installed certificates. +func (c *Client) CertificateLocations(ctx context.Context) ([]string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "CertificateLocations") + defer span.End() + + locs, metadata, err := bmc.CertificateLocationsFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return locs, err +} + +// Certificate returns a certificate's properties by its @odata.id location. +func (c *Client) Certificate(ctx context.Context, location string) (bmc.CertificateInfo, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Certificate") + defer span.End() + + cert, metadata, err := bmc.CertificateFromInterfaces(ctx, c.perProviderTimeout(ctx), location, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return cert, err +} + +// GenerateCSR generates a certificate-signing-request and returns the PEM CSR. +func (c *Client) GenerateCSR(ctx context.Context, req *bmc.CSRRequest) (string, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GenerateCSR") + defer span.End() + + csr, metadata, err := bmc.GenerateCSRFromInterfaces(ctx, c.perProviderTimeout(ctx), req, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return csr, err +} + +// ReplaceCertificate replaces the certificate at targetURI with the given PEM. +func (c *Client) ReplaceCertificate(ctx context.Context, certificatePEM, targetURI string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ReplaceCertificate") + defer span.End() + + metadata, err := bmc.ReplaceCertificateFromInterfaces(ctx, c.perProviderTimeout(ctx), certificatePEM, targetURI, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// RekeyCertificate generates a new key pair and CSR for the certificate at certURI. +func (c *Client) RekeyCertificate(ctx context.Context, certURI string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "RekeyCertificate") + defer span.End() + + metadata, err := bmc.RekeyCertificateFromInterfaces(ctx, c.perProviderTimeout(ctx), certURI, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// RenewCertificate generates a CSR using the existing key for the certificate at certURI. +func (c *Client) RenewCertificate(ctx context.Context, certURI string) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "RenewCertificate") + defer span.End() + + metadata, err := bmc.RenewCertificateFromInterfaces(ctx, c.perProviderTimeout(ctx), certURI, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// --- SNMP --- + +// SNMP returns the SNMP trap configuration. +func (c *Client) SNMP(ctx context.Context) (bmc.SNMPConfig, error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SNMP") + defer span.End() + + cfg, metadata, err := bmc.SNMPFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return cfg, err +} + +// SetSNMPAlertFilter PATCHes the SNMP trap (SNMPTraps) properties. +func (c *Client) SetSNMPAlertFilter(ctx context.Context, attrs map[string]any) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetSNMPAlertFilter") + defer span.End() + + metadata, err := bmc.SetSNMPAlertFilterFromInterfaces(ctx, c.perProviderTimeout(ctx), attrs, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// EnableSNMPv1Trap enables or disables the SNMPv1 trap. +func (c *Client) EnableSNMPv1Trap(ctx context.Context, enabled bool) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EnableSNMPv1Trap") + defer span.End() + + metadata, err := bmc.EnableSNMPv1TrapFromInterfaces(ctx, c.perProviderTimeout(ctx), enabled, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +// EnableSNMPv3Trap enables or disables the SNMPv3 trap. +func (c *Client) EnableSNMPv3Trap(ctx context.Context, enabled bool) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "EnableSNMPv3Trap") + defer span.End() + + metadata, err := bmc.EnableSNMPv3TrapFromInterfaces(ctx, c.perProviderTimeout(ctx), enabled, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} diff --git a/client_features_test.go b/client_features_test.go new file mode 100644 index 00000000..6ba93575 --- /dev/null +++ b/client_features_test.go @@ -0,0 +1,63 @@ +package bmclib + +import ( + "context" + "testing" + + "github.com/jacobweinstock/registrar" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// fakeExtendedProvider is a registry driver implementing a couple of the +// extended interfaces, used to verify the Client-level wiring (dispatch + +// metadata storage). +type fakeExtendedProvider struct { + name string + snmp bmc.SNMPConfig +} + +func (f *fakeExtendedProvider) Name() string { return f.name } + +func (f *fakeExtendedProvider) SNMP(_ context.Context) (bmc.SNMPConfig, error) { + return f.snmp, nil +} + +func (f *fakeExtendedProvider) SetSNMPAlertFilter(_ context.Context, _ map[string]any) error { + return nil +} + +func (f *fakeExtendedProvider) EnableSNMPv1Trap(_ context.Context, _ bool) error { return nil } + +func (f *fakeExtendedProvider) EnableSNMPv3Trap(_ context.Context, _ bool) error { return nil } + +func TestClientSNMPWiring(t *testing.T) { + want := bmc.SNMPConfig{V1TrapEnabled: true, TrapPort: 162, CommunityNames: []string{"public"}} + + registry := registrar.NewRegistry() + registry.Register("fake", "fake", nil, nil, &fakeExtendedProvider{name: "fake", snmp: want}) + + cl := NewClient("", "", "", WithRegistry(registry)) + + got, err := cl.SNMP(context.Background()) + if err != nil { + t.Fatalf("SNMP: %v", err) + } + if got.TrapPort != want.TrapPort || !got.V1TrapEnabled { + t.Errorf("SNMP = %+v, want %+v", got, want) + } + if md := cl.GetMetadata(); md.SuccessfulProvider != "fake" { + t.Errorf("metadata SuccessfulProvider = %q, want %q", md.SuccessfulProvider, "fake") + } +} + +func TestClientSNMPNoProvider(t *testing.T) { + registry := registrar.NewRegistry() + registry.Register("bare", "bare", nil, nil, &testProvider{PName: "bare"}) + + cl := NewClient("", "", "", WithRegistry(registry)) + + if _, err := cl.SNMP(context.Background()); err == nil { + t.Fatal("expected an error when no provider implements SNMPConfigurer") + } +} diff --git a/providers/providers.go b/providers/providers.go index eadcd4d4..346f20a2 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -103,4 +103,58 @@ const ( // FeatureAllowCustomSecureBootKeys means an implementation that can enable/disable acceptance of custom UEFI Secure Boot keys FeatureAllowCustomSecureBootKeys registrar.Feature = "allowcustomsecurebootkeys" + + // FeatureThermalRead means an implementation can read thermal sensors (temperatures, fans) + FeatureThermalRead registrar.Feature = "thermalread" + + // FeaturePowerRead means an implementation can read power metrics (consumed watts, power supplies) + FeaturePowerRead registrar.Feature = "powerread" + + // FeaturePowerCap means an implementation can set a chassis power limit (power capping) + FeaturePowerCap registrar.Feature = "powercap" + + // FeatureVolumeRead means an implementation can read storage controllers and volumes + FeatureVolumeRead registrar.Feature = "volumeread" + + // FeatureVolumeManagement means an implementation can create/initialize/update/delete storage volumes + FeatureVolumeManagement registrar.Feature = "volumemanagement" + + // FeatureLicenseManagement means an implementation can read/install/delete BMC licenses + FeatureLicenseManagement registrar.Feature = "licensemanagement" + + // FeatureSecureKeyLifecycle means an implementation can read/configure the Secure Key Lifecycle service + FeatureSecureKeyLifecycle registrar.Feature = "securekeylifecycle" + + // FeatureNetworkInterfaceRead means an implementation can read BMC/server ethernet interfaces + FeatureNetworkInterfaceRead registrar.Feature = "networkinterfaceread" + + // FeatureNetworkInterfaceSet means an implementation can configure BMC ethernet/host interfaces + FeatureNetworkInterfaceSet registrar.Feature = "networkinterfaceset" + + // FeatureNetworkProtocolRead means an implementation can read manager network protocols + FeatureNetworkProtocolRead registrar.Feature = "networkprotocolread" + + // FeatureNetworkProtocolSet means an implementation can configure manager network protocols + FeatureNetworkProtocolSet registrar.Feature = "networkprotocolset" + + // FeatureSerialRead means an implementation can read BMC serial interfaces + FeatureSerialRead registrar.Feature = "serialread" + + // FeatureSerialSet means an implementation can configure BMC serial interfaces + FeatureSerialSet registrar.Feature = "serialset" + + // FeatureEventSubscription means an implementation can read the event service and manage event subscriptions + FeatureEventSubscription registrar.Feature = "eventsubscription" + + // FeatureTelemetry means an implementation can read telemetry metric reports and definitions + FeatureTelemetry registrar.Feature = "telemetry" + + // FeatureJobManagement means an implementation can read jobs and update job schedules + FeatureJobManagement registrar.Feature = "jobmanagement" + + // FeatureCertificateManagement means an implementation can manage BMC certificates + FeatureCertificateManagement registrar.Feature = "certificatemanagement" + + // FeatureSNMP means an implementation can read and configure SNMP traps + FeatureSNMP registrar.Feature = "snmp" ) From 061175837a35e95341d9534fd035d85c6a1c36df Mon Sep 17 00:00:00 2001 From: nuxster Date: Fri, 26 Jun 2026 03:46:28 -0400 Subject: [PATCH 2/4] Add Lenovo XCC support for the optional bmc core interfaces Implements the optional bmc core interfaces in the Lenovo XCC provider (thermal, secure boot, power capping, volume management, license, secure key lifecycle, network, serial, events, telemetry, jobs, certificates, SNMP), advertises the corresponding feature flags, and adds the lenovo-smoketest hardware example and provider tests. Assisted-by: Anthropic Claude (Claude Code, Opus model) --- .../lenovo-smoketest/HARDWARE-TEST-PLAN.md | 752 +++++++++++++++ examples/lenovo-smoketest/RUNBOOK.md | 124 +++ examples/lenovo-smoketest/dump-xcc.sh | 173 ++++ examples/lenovo-smoketest/main.go | 904 ++++++++++++++++++ providers/lenovo/bmc_management_test.go | 91 ++ providers/lenovo/certificates.go | 155 +++ providers/lenovo/errors.go | 35 + providers/lenovo/events.go | 140 +++ providers/lenovo/events_telemetry_test.go | 164 ++++ providers/lenovo/inventory_storage_test.go | 161 ++++ providers/lenovo/inventory_test.go | 34 - providers/lenovo/jobs.go | 96 ++ providers/lenovo/jobs_certs_snmp_test.go | 199 ++++ providers/lenovo/lenovo.go | 21 + providers/lenovo/license.go | 85 ++ providers/lenovo/main_test.go | 138 +++ providers/lenovo/network.go | 195 ++++ providers/lenovo/network_serial_test.go | 151 +++ providers/lenovo/power_cap.go | 81 ++ providers/lenovo/redfishget.go | 31 + providers/lenovo/serial.go | 61 ++ providers/lenovo/sklm.go | 83 ++ providers/lenovo/snmp.go | 90 ++ providers/lenovo/storage.go | 228 +++++ providers/lenovo/telemetry.go | 107 +++ providers/lenovo/thermal.go | 58 ++ providers/lenovo/thermal_power_test.go | 68 ++ 27 files changed, 4391 insertions(+), 34 deletions(-) create mode 100644 examples/lenovo-smoketest/HARDWARE-TEST-PLAN.md create mode 100644 examples/lenovo-smoketest/RUNBOOK.md create mode 100755 examples/lenovo-smoketest/dump-xcc.sh create mode 100644 examples/lenovo-smoketest/main.go create mode 100644 providers/lenovo/certificates.go create mode 100644 providers/lenovo/events.go create mode 100644 providers/lenovo/events_telemetry_test.go create mode 100644 providers/lenovo/inventory_storage_test.go delete mode 100644 providers/lenovo/inventory_test.go create mode 100644 providers/lenovo/jobs.go create mode 100644 providers/lenovo/jobs_certs_snmp_test.go create mode 100644 providers/lenovo/license.go create mode 100644 providers/lenovo/network.go create mode 100644 providers/lenovo/network_serial_test.go create mode 100644 providers/lenovo/power_cap.go create mode 100644 providers/lenovo/serial.go create mode 100644 providers/lenovo/sklm.go create mode 100644 providers/lenovo/snmp.go create mode 100644 providers/lenovo/storage.go create mode 100644 providers/lenovo/telemetry.go create mode 100644 providers/lenovo/thermal.go create mode 100644 providers/lenovo/thermal_power_test.go diff --git a/examples/lenovo-smoketest/HARDWARE-TEST-PLAN.md b/examples/lenovo-smoketest/HARDWARE-TEST-PLAN.md new file mode 100644 index 00000000..01523b62 --- /dev/null +++ b/examples/lenovo-smoketest/HARDWARE-TEST-PLAN.md @@ -0,0 +1,752 @@ +# Lenovo XCC provider — per-feature hardware test plan + +This document is the **per-feature test catalog** for validating the bmclib +`lenovo` provider against a real Lenovo XClarity Controller (XCC). It is the +companion to [`RUNBOOK.md`](./RUNBOOK.md): the runbook describes *how to drive +the `lenovo-smoketest` harness*; this plan defines *what to verify for every one +of the 44 advertised features*, including the mutations the harness does not yet +flag. + +All unit tests run against fixtures. **Hardware is the only place the XCC +firmware/OEM variance is actually exercised** — that is what this plan is for. + +--- + +## How to use this document + +- Each feature has a test case `TC--`. +- **Driver** column says how to run it: + - `harness:-flag` → covered by `lenovo-smoketest` (see RUNBOOK §2/§3). + - `manual:curl` / `manual:go` → not in the harness; run the raw Redfish call + or a small Go snippet (templates in [Appendix A](#appendix-a--manual-call-templates)). +- **Independent verification** = a second, provider-independent way to confirm + the change really took effect (XCC web UI path, or a raw Redfish GET). +- **Risk tier**: + - 🟢 **R0 read-only** — safe on any box, including production. + - 🟡 **R1 reversible write** — changes config; reversible; lab box only. + - 🟠 **R2 disruptive** — reboots/resets host or BMC, or interrupts I/O. + - 🔴 **R3 destructive / high-risk** — data loss or bricking potential + (firmware, volume delete/init, factory reset). Dedicated lab box only, + with a recovery plan. + +A test **PASSES** when: the call returns no error, the returned/affected state +matches **Expected**, AND the **Independent verification** confirms it. + +> **Instance IDs** below (`Systems/1`, `Managers/1`, `Chassis/1`, +> `Storage/RAID_Slot1`, …) are the *typical* XCC values used in the fixtures. +> On real hardware, always discover the actual IDs from the parent collection +> (`GET …/Systems`, `…/Managers`, etc.) before hand-running a `manual:curl` +> case. Pass `-v` to the harness to see the live URIs. + +--- + +## Prerequisites + +1. A lab Lenovo server with XCC reachable over HTTPS (default port 443). +2. Credentials with **Supervisor**/admin privilege (user create, BMC reset, + firmware, factory reset all require it). +3. A second host the **XCC itself** can reach over HTTP/HTTPS/NFS for: + - a bootable ISO (virtual media tests), + - the SNMP/syslog trap receiver (event tests), + - the EventService subscription destination (event subscription tests). +4. A genuine Lenovo firmware payload (`lnvgy_fw_*.uxz`) for the firmware test. +5. `curl` with `-k` (XCC ships a self-signed cert by default) for independent + verification and `manual:curl` cases. Export once: + ```bash + export XCC=https:// + export CURL='curl -sk -u :' # XCC supports HTTP Basic + ``` +6. Run the **read-only sweep first** (RUNBOOK §1) and capture it with `-json`. + Do not proceed to writes until the read sweep is `0 FAIL`. +7. To clarify exact paths/fields/AllowableValues/OEM structures before testing, + dump every resource the provider touches (read-only): + ```bash + ./dump-xcc.sh xcc-dump + ``` + Produces one JSON per resource + `_index.tsv` of HTTP codes (so absent + resources like LicenseService show up as 404). With `jq` installed it also + expands collection members and resolves the **BIOS attribute registry** + (every valid `-set-bios` key, its type and allowable values). + +--- + +## Coverage matrix (44 features) + +| # | Feature (registry) | Test case | Driver | Risk | +|---|--------------------|-----------|--------|------| +| 1 | *(connection: Compatible/Open/Close)* | TC-FND-1 | harness (always) | 🟢 | +| 2 | *(ServiceRoot)* | TC-FND-2 | harness `serviceroot.read` | 🟢 | +| 3 | `PowerState` | TC-PWR-1 | harness `power.state` | 🟢 | +| 4 | `PowerSet` | TC-PWR-2 | harness `-power` | 🟠 | +| 5 | `BootProgress` | TC-PWR-3 | harness `boot.progress` | 🟢 | +| 6 | `BootDeviceSet` (+ override read) | TC-PWR-4 | harness `-bootdev` | 🟡 | +| 7 | `GetBiosConfiguration` | TC-BIO-1 | harness `bios.read` | 🟢 | +| 8 | `SetBiosConfiguration` | TC-BIO-2 | harness `-set-bios` | 🟡 | +| 9 | `ResetBiosConfiguration` | TC-BIO-3 | harness `-reset-bios` | 🟡 | +| 10 | `SecureBoot` (get) | TC-SEC-1 | harness `secureboot.read` | 🟢 | +| 11 | `SecureBoot` (set) | TC-SEC-2 | harness `-secureboot` | 🟡 | +| 12 | `SecureBoot` (ResetKeys) | TC-SEC-3 | manual:go | 🟡 | +| 13 | `ThermalRead` | TC-ENV-1 | harness `thermal.read` | 🟢 | +| 14 | `PowerRead` | TC-ENV-2 | harness `power.metrics` | 🟢 | +| 15 | `PowerCap` | TC-ENV-3 | harness `-powercap` | 🟡 | +| 16 | `InventoryRead` | TC-INV-1 | harness `inventory.read` | 🟢 | +| 17 | `VolumeRead` (controllers/volumes) | TC-STO-1 | harness `storage.*` | 🟢 | +| 18 | `VolumeManagement` (create) | TC-STO-2 | manual:go | 🔴 | +| 19 | `VolumeManagement` (initialize) | TC-STO-3 | manual:go | 🔴 | +| 20 | `VolumeManagement` (update) | TC-STO-4 | manual:go | 🟡 | +| 21 | `VolumeManagement` (delete) | TC-STO-5 | manual:go | 🔴 | +| 22 | `FirmwareUpload` + `FirmwareInstall*` | TC-FW-1 | harness `-firmware` | 🔴 | +| 23 | `FirmwareInstallSteps` | TC-FW-2 | harness `firmware.steps` | 🟢 | +| 24 | `FirmwareInstallStatus` / `FirmwareTaskStatus` | TC-FW-3 | harness (firmware poll) | 🟢 | +| 25 | `FirmwareInstallUploaded` / UploadInitiate | TC-FW-4 | manual:go | 🔴 | +| 26 | `SimpleUpdate` (URI install) | TC-FW-5 | manual:go | 🔴 | +| 27 | `VirtualMedia` (insert/eject) | TC-VM-1 | harness `-vmedia-url`/`-vmedia-eject` | 🟡 | +| 28 | `UnmountFloppyImage` (+ Mount=unsupported) | TC-VM-2 | manual:go | 🟡 | +| 29 | `UserRead` | TC-USR-1 | harness `users.read` | 🟢 | +| 30 | `UserCreate` | TC-USR-2 | manual:go | 🟡 | +| 31 | `UserUpdate` | TC-USR-3 | manual:go | 🟡 | +| 32 | `UserDelete` | TC-USR-4 | manual:go | 🟡 | +| 33 | *(Roles read / RoleCreate)* | TC-USR-5 | manual:go | 🟡 | +| 34 | `GetSystemEventLog` | TC-LOG-1 | harness `sel.read` | 🟢 | +| 35 | `GetSystemEventLogRaw` | TC-LOG-2 | manual:go | 🟢 | +| 36 | `ClearSystemEventLog` | TC-LOG-3 | harness `-clear-sel` | 🟡 | +| 37 | *(EventLog / audit log read)* | TC-LOG-4 | harness `log.audit` | 🟢 | +| 38 | `BmcReset` | TC-BMC-1 | harness `-bmc-reset` | 🟠 | +| 39 | *(ResetToFactoryDefaults)* | TC-BMC-2 | manual:go | 🔴 | +| 40 | *(UpdateManager)* | TC-BMC-3 | manual:go | 🟡 | +| 41 | `LicenseManagement` (read) | TC-BMC-4 | harness `license.read` | 🟢 | +| 42 | `LicenseManagement` (install/delete) | TC-BMC-5 | manual:go | 🟡 | +| 43 | `SecureKeyLifecycle` (get) | TC-BMC-6 | harness `sklm.read` | 🟢 | +| 44 | `SecureKeyLifecycle` (set servers) | TC-BMC-7 | manual:go | 🟡 | +| 45 | `NetworkInterfaceRead` (BMC/server) | TC-NET-1 | harness `network.bmc`/`network.server` | 🟢 | +| 46 | `NetworkInterfaceSet` (NIC) | TC-NET-2 | manual:go | 🟠 | +| 47 | `NetworkInterfaceSet` (host iface) | TC-NET-3 | manual:go | 🟡 | +| 48 | `NetworkProtocolRead` | TC-NET-4 | harness `network.protocols` | 🟢 | +| 49 | `NetworkProtocolSet` | TC-NET-5 | manual:go | 🟠 | +| 50 | `SerialRead` | TC-SER-1 | harness `serial.read` | 🟢 | +| 51 | `SerialSet` | TC-SER-2 | manual:go | 🟡 | +| 52 | `EventSubscription` (service/list read) | TC-EVT-1 | harness `event.*` | 🟢 | +| 53 | `EventSubscription` (create/delete) | TC-EVT-2 | manual:go | 🟡 | +| 54 | `EventSubscription` (SubmitTestEvent) | TC-EVT-3 | manual:go | 🟡 | +| 55 | `EventSubscription` (SetEventService) | TC-EVT-4 | manual:go | 🟡 | +| 56 | `Telemetry` (service/reports/defs read) | TC-TEL-1 | harness `telemetry.*` | 🟢 | +| 57 | `Telemetry` (single report/def read) | TC-TEL-2 | manual:go | 🟢 | +| 58 | `Telemetry` (SubmitTestMetricReport) | TC-TEL-3 | manual:go | 🟡 | +| 59 | `JobManagement` (service/list read) | TC-JOB-1 | harness `job.*` | 🟢 | +| 60 | `JobManagement` (single read) | TC-JOB-2 | manual:go | 🟢 | +| 61 | `JobManagement` (update schedule) | TC-JOB-3 | manual:go | 🟡 | +| 62 | `CertificateManagement` (locations/read) | TC-CRT-1 | harness `cert.*` | 🟢 | +| 63 | `CertificateManagement` (GenerateCSR) | TC-CRT-2 | manual:go | 🟢 | +| 64 | `CertificateManagement` (ReplaceCertificate) | TC-CRT-3 | manual:go | 🟠 | +| 65 | `CertificateManagement` (Rekey/Renew) | TC-CRT-4 | manual:go | 🟠 | +| 66 | `SNMP` (read) | TC-SNM-1 | harness `snmp.read` | 🟢 | +| 67 | `SNMP` (v1/v3 trap enable) | TC-SNM-2 | harness `-snmp-v1-trap`/`-snmp-v3-trap` | 🟡 | +| 68 | `SNMP` (SetSNMPAlertFilter) | TC-SNM-3 | manual:go | 🟡 | + +> The 44 *registry features* map to more test cases than 44 because several +> features (firmware, volumes, certificates, events, SNMP, secure boot) bundle +> multiple distinct provider methods that each need their own hardware check. + +--- + +## TC-FND — Foundation + +### TC-FND-1 — Vendor compatibility + session lifecycle 🟢 +- **Methods:** `Compatible`, `Open`, `Close` +- **Driver:** harness (runs unconditionally; the first two report lines) +- **Procedure:** `go run ./examples/lenovo-smoketest -host $H -user $U -pass $P` +- **Expected:** `connection.compatible PASS` (device identifies as Lenovo XCC) and + `connection.open PASS` (session established). With `-basic`, repeat to confirm + HTTP Basic auth also works. +- **Independent verification:** `$CURL $XCC/redfish/v1/ | jq '.Vendor,.Product'` + → contains `Lenovo`. Check the XCC active-session count does **not** climb + after repeated runs (session is released on Close — XCC caps at 16). +- **Pass criteria:** both PASS; non-Lenovo box correctly reported NOT compatible. + +### TC-FND-2 — Service root 🟢 +- **Method:** `ServiceRoot` +- **Driver:** harness `serviceroot.read` +- **Expected:** `vendor="Lenovo" product= redfish=<>=1.15.0>`. +- **Independent verification:** `$CURL $XCC/redfish/v1/ | jq '.RedfishVersion'`. + +--- + +## TC-PWR — Power & boot + +### TC-PWR-1 — Power state read 🟢 +- **Method:** `PowerStateGet` — **Driver:** harness `power.state` +- **Expected:** `state=on` / `off` matching the box. +- **Verify:** XCC UI → *Power* tile; or `$CURL $XCC/redfish/v1/Systems/1 | jq .PowerState`. + +### TC-PWR-2 — Power set 🟠 +- **Method:** `PowerSet(state)` — **Driver:** `harness -allow-writes -power ` +- **Procedure:** test each transition on a lab box; allow the host to settle + between states. Note: `on` is a no-op (returns ok) when already on. +- **Expected:** `power.set PASS`; host changes state. +- **Verify:** observe POST to `…/Systems/1/Actions/ComputerSystem.Reset` (`-v`); + confirm physical/UI power state after each call. Map: on→`On`, off→`ForceOff`, + soft→`GracefulShutdown`, cycle→`ForceRestart`/`PowerCycle`, reset→`ForceRestart`. +- **Risk:** abrupt off/cycle/reset interrupt the OS. + +### TC-PWR-3 — Boot progress 🟢 +- **Method:** `GetBootProgress` — **Driver:** harness `boot.progress` +- **Note:** requires RedfishVersion ≥ 1.13.0 (XCC reports 1.15.0). WARN if the + model omits `BootProgress`. +- **Expected:** `lastState=`. + +### TC-PWR-4 — Boot device override 🟡 +- **Methods:** `BootDeviceOverrideGet`, `BootDeviceSet` +- **Driver:** read = harness `boot.override`; write = `harness -allow-writes -bootdev [-bootdev-efi]` +- **Procedure:** set one-time legacy PXE, re-read; set one-time UEFI cdrom, re-read. +- **⚠️ XCC allows ONE-TIME only:** `BootSourceOverrideEnabled@Redfish.AllowableValues` + is `{Once, Disabled}` — `Continuous` is rejected (PropertyValueNotInList). + The provider rejects `-bootdev-persistent` up front with a clear error; + persistent boot is managed via the `BootOrder`, not the override. + (`-bootdev` also accepts CD/DVD/USBStick aliases → cdrom/usb.) +- **Expected:** `boot.set PASS`; subsequent `boot.override` reflects device and + `efi`, with `persistent=false`. +- **Verify:** XCC UI → *Server Configuration → Boot Options*; or + `$CURL $XCC/redfish/v1/Systems/1 | jq .Boot` → + `BootSourceOverrideTarget/Enabled/Mode`. Reboot once to confirm a one-time + override clears itself. + +--- + +## TC-BIO — BIOS + +### TC-BIO-1 — BIOS read 🟢 +- **Method:** `GetBiosConfiguration` — **Driver:** harness `bios.read` +- **Expected:** `N attributes` (N > 0). +- **Verify:** `$CURL $XCC/redfish/v1/Systems/1/Bios | jq '.Attributes|length'`. + +### TC-BIO-2 — BIOS set 🟡 +- **Method:** `SetBiosConfiguration(map)` — **Driver:** `harness -allow-writes -set-bios "Key=Value,..."` +- **⚠️ Use a real attribute name from THIS box's registry.** Read TC-BIO-1 first + and copy exact attribute keys — names are model/firmware-specific. On a + ThinkSystem SR630 V2 the AMI-style `QuietBoot` does **not** exist and XCC + returns `Base.1.11.PropertyUnknown`; `BootModes_SystemBootMode=UEFIMode` is + valid there. Don't copy the example key blindly. +- **Apply-time note (provider fix):** XCC's BIOS settings resource **rejects** + the `@Redfish.SettingsApplyTime`/`ApplyTime` annotation that the shared + redfishwrapper sends. The lenovo provider overrides `SetBiosConfiguration` to + PATCH `Attributes` only (no apply-time); XCC stages the change for the next + host reset implicitly. (Hardware-discovered 2026-06-09 on SR630 V2.) +- **Expected:** `bios.set PASS` (provider GETs the settings target for the ETag, + then PATCHes Attributes only). +- **Verify:** `$CURL $XCC/redfish/v1/Systems/1/Bios | jq '."@Redfish.Settings".SettingsObject."@odata.id"'` + to find the settings target, GET it for pending `Attributes`; after a host + reboot confirm the active value changed. + +### TC-BIO-3 — BIOS reset to defaults 🟡 +- **Method:** `ResetBiosConfiguration` — **Driver:** `harness -allow-writes -reset-bios` +- **Expected:** `bios.reset PASS` (POST `…/Bios/Actions/Bios.ResetBios`). +- **Verify:** UI shows a pending "restore defaults" on next boot; reboot and spot + check a previously-changed attribute returned to default. + +--- + +## TC-SEC — Secure boot + +### TC-SEC-1 — Secure boot read 🟢 +- **Method:** `GetSecureBoot` — **Driver:** harness `secureboot.read` +- **Expected:** `enabled=`. +- **Verify:** `$CURL $XCC/redfish/v1/Systems/1/SecureBoot | jq`. + +### TC-SEC-2 — Secure boot enable/disable 🟡 +- **Method:** `SetSecureBoot(bool)` — **Driver:** `harness -allow-writes -secureboot ` +- **Expected:** `secureboot.set PASS`; re-read shows new `enabled`. +- **Verify:** UI → *Security → Secure Boot*; effective after host reboot. + +### TC-SEC-3 — Reset secure-boot keys 🟡 *(not in harness)* +- **Method:** `ResetSecureBootKeys(resetType)` — **Driver:** `manual:go` +- **Procedure:** call with `bmc.ResetSecureBootKeysTypeResetAllKeysToDefault` (also valid: + `...DeleteAllKeys`, `...DeletePK`). See [Appendix A](#appendix-a--manual-call-templates). +- **Expected:** no error; POST to `…/SecureBoot/Actions/SecureBoot.ResetKeys`. +- **Verify:** re-read secure boot; UI key store reflects the reset. + +--- + +## TC-ENV — Thermal & power metrics + +### TC-ENV-1 — Thermal read 🟢 +- **Method:** `Thermal` — **Driver:** harness `thermal.read` +- **Expected:** `N temps, M fans` (both > 0 on a powered box). +- **Verify:** `$CURL $XCC/redfish/v1/Chassis/1/Thermal | jq '.Temperatures|length,.Fans|length'`. + +### TC-ENV-2 — Power metrics 🟢 +- **Method:** `ReadPower` — **Driver:** harness `power.metrics` +- **Expected:** `consumed= capacity= supplies=`. +- **Verify:** `$CURL $XCC/redfish/v1/Chassis/1/Power | jq '.PowerControl,.PowerSupplies|length'`. + +### TC-ENV-3 — Power cap set/clear 🟡 +- **Method:** `SetPowerCap(*float64)` — **Driver:** `harness -allow-writes -powercap ` +- **Procedure:** set a cap above current draw (e.g. `-powercap 1200`), re-read + `power.metrics`, then `-powercap off` to disable. +- **Expected:** `power.cap PASS`; cap reflected then cleared. +- **Verify:** `$CURL $XCC/redfish/v1/Chassis/1/Power | jq '.PowerControl[0].PowerLimit'`; + UI → *Power → Power Capping*. + +--- + +## TC-INV — Inventory + +### TC-INV-1 — Hardware inventory 🟢 +- **Method:** `Inventory` (delegates to redfishwrapper) — **Driver:** harness `inventory.read` +- **Expected:** `vendor="Lenovo" model=<> cpus>0 dimms>0 drives>=0 nics>0`. +- **Note:** record the XCC **firmware version + model** from this line for the + release-gate evidence. +- **Verify:** spot-check counts against UI → *Inventory*. If `WithFailInventoryOnError` + is set, a single component error fails the whole read — test both modes. + +--- + +## TC-STO — Storage & volumes + +### TC-STO-1 — Storage controllers + volumes read 🟢 +- **Methods:** `StorageControllers`, `Volumes(storageID)` — **Driver:** harness `storage.controllers` / `storage.volumes` +- **Expected:** `N controllers`; for the first controller, `M volumes`. +- **Verify:** `$CURL $XCC/redfish/v1/Systems/1/Storage | jq '.Members'` then a + controller's `/Volumes`. + +> ⚠️ **TC-STO-2..5 are 🔴 destructive RAID operations.** Run only on a lab box +> with **no data you care about**. Always capture controller + volume state +> before and after, and have the RAID layout documented for restore. + +### TC-STO-2 — Volume create 🔴 *(not in harness)* +- **Method:** `VolumeCreate(storageID, VolumeCreateRequest)` — **Driver:** `manual:go` +- **Procedure:** build a `bmc.VolumeCreateRequest` (RAID type, drive IDs, capacity) + from drives discovered in TC-STO-1; call `VolumeCreate`. +- **Expected:** returns a non-empty volume ID (from `Location` header or body `Id`). +- **Verify:** `GET …/Storage//Volumes` lists the new volume; UI → *Storage*. + +### TC-STO-3 — Volume initialize 🔴 *(not in harness)* +- **Method:** `VolumeInitialize(storageID, volumeID, initType)` — `initType` ∈ `Fast|Slow` +- **Expected:** no error; POST `…/Volumes//Actions/Volume.Initialize` with only `InitializeType`. +- **Verify:** volume `Status`/operation shows initializing → OK in UI. + +### TC-STO-4 — Volume update 🟡 *(not in harness)* +- **Method:** `VolumeUpdate(storageID, volumeID, settings map)` +- **Procedure:** PATCH a benign property (e.g. volume name / write-cache policy). +- **Verify:** re-read the volume; property changed. + +### TC-STO-5 — Volume delete 🔴 *(not in harness)* +- **Method:** `VolumeDelete(storageID, volumeID)` +- **Expected:** no error; volume gone from the collection. +- **Verify:** `GET …/Volumes` no longer lists it; UI confirms. + +--- + +## TC-FW — Firmware (🔴 dedicated lab box only) + +### TC-FW-1 — Push-protocol firmware install 🔴 +- **Methods:** `FirmwareInstall` (claim → push `/mfwupdate` or `/fwupdate` → poll Task → release) +- **Driver:** `harness -allow-writes -firmware -firmware-component -firmware-applytime -timeout 30m` +- **Procedure:** see RUNBOOK §3. Use a real `lnvgy_fw_*.uxz`; prefer a backup + target (`-firmware-component BMC-Backup`) for the first run. +- **Expected:** `firmware.install PASS` (pushed; taskID=…) then `firmware.task PASS` + (task completed). The harness **polls the Task resource, never the + TaskMonitor URI** (a GET on TaskMonitor deletes a finished task on XCC). +- **Verify:** UI → *Firmware Update* shows the new version after apply; + `HttpPushUriTargetsBusy` returns to `false` (claim released). Confirm a second + install can start (busy flag not stuck). +- **Pass criteria:** task reaches `Complete`; version updated; busy released even + on a failed/aborted push. + +### TC-FW-2 — Install step plan (read) 🟢 +- **Method:** `FirmwareInstallSteps(component)` — **Driver:** harness `firmware.steps` +- **Expected:** `N install steps` (the ordered plan: upload → install status …). + +### TC-FW-3 — Task status polling 🟢 +- **Methods:** `FirmwareTaskStatus`, `FirmwareInstallStatus` +- **Driver:** exercised inside TC-FW-1's poll loop; can also be called standalone + against a known task ID. +- **Expected:** maps XCC task state → `initializing|running|complete|failed`. +- **Verify:** cross-check against `$CURL $XCC/redfish/v1/TaskService/Tasks/`. + +### TC-FW-4 — Upload-then-install (two-phase) 🔴 *(not in harness)* +- **Methods:** `FirmwareUpload` / `FirmwareInstallUploadAndInitiate` → `FirmwareInstallUploaded(component, uploadTaskID)` +- **Driver:** `manual:go` — upload returns a task id; pass it to the uploaded-install call. +- **Use when:** validating `OnStartUpdateRequest` apply-time / staged installs. + +### TC-FW-5 — SimpleUpdate (install from URI) 🔴 *(not in harness)* +- **Method:** `SimpleUpdate(imageURI, transferProtocol)` — **Driver:** `manual:go` +- **Procedure:** host the image on a reachable HTTP/TFTP server; call with the + URI and protocol (`HTTP`/`HTTPS`/`TFTP`). +- **Expected:** POST `…/UpdateService/Actions/UpdateService.SimpleUpdate`; task id returned. +- **Verify:** poll the task (TC-FW-3) to completion; version updated. + +--- + +## TC-VM — Virtual media + +### TC-VM-1 — Insert / eject virtual media 🟡 +- **Method:** `SetVirtualMedia(kind, url)` (insert) / `SetVirtualMedia(kind, "")` (eject) +- **Driver:** `harness -allow-writes -vmedia-url -vmedia-kind CD` + then `harness -allow-writes -vmedia-eject -vmedia-kind CD` +- **Critical hardware note:** real XCC slots (EXT1-4/Remote1-4/RDOC1-2) **do NOT + expose the Redfish `InsertMedia`/`EjectMedia` actions** — the provider inserts + via **PATCH** on the VirtualMedia resource (`Image/Inserted/WriteProtected/ + TransferProtocolType`). This is the single most important XCC-variance test. +- **Procedure:** + 1. Insert ISO → confirm exactly **one** slot occupied. + 2. Insert again (same kind) → must **replace**, not create a second mount. + 3. Eject → slot cleared. +- **Expected:** each call PASS; re-mount does not accumulate mounts. +- **Verify:** `$CURL $XCC/redfish/v1/Managers/1/VirtualMedia/ | jq '.Image,.Inserted'`; + UI → *Remote Console → Mount Media*. Optionally boot the host from the mounted ISO. + +### TC-VM-2 — Floppy mount/unmount 🟡 *(mount = unsupported by design)* +- **Methods:** `MountFloppyImage` (returns "unsupported"), `UnmountFloppyImage` +- **Driver:** `manual:go` +- **Expected:** `MountFloppyImage` returns the explicit *unsupported* error (XCC + has no raw floppy upload in its Redfish API — by design, not a bug); + `UnmountFloppyImage` ejects the Floppy slot (idempotent if none inserted). +- **Verify:** Floppy slot `Inserted=false` after unmount. + +--- + +## TC-USR — Users, accounts, roles + +### TC-USR-1 — User read 🟢 +- **Method:** `UserRead` — **Driver:** harness `users.read` +- **Expected:** `N accounts` (includes the built-in USERID/Administrator). +- **Verify:** `$CURL $XCC/redfish/v1/AccountService/Accounts | jq '.Members|length'`. + +### TC-USR-2 — User create 🟡 *(not in harness)* +- **Method:** `UserCreate(user, pass, role)` — **Driver:** `manual:go` +- **Procedure:** create `smoketest` with role `Administrator` (or `Operator`). + Provider tries Redfish `CreateAccount` first, then falls back to PATCH of an + empty slot (Intel-Purley style) — both paths should be exercised if possible. +- **Expected:** no error; new account appears. +- **Verify:** `GET …/Accounts`; **log in as the new user** to confirm the + password/role actually work (don't trust the POST alone). + +### TC-USR-3 — User update 🟡 *(not in harness)* +- **Method:** `UserUpdate(user, pass, role)` +- **Procedure:** change the `smoketest` user's password and role. +- **Verify:** log in with the new password; confirm new privilege. + +### TC-USR-4 — User delete 🟡 *(not in harness)* +- **Method:** `UserDelete(user)` +- **Expected:** account removed; login as that user now fails. +- **Verify:** `GET …/Accounts` no longer lists it. **Clean up the test user here.** + +### TC-USR-5 — Roles read + custom role create 🟡 *(not in harness)* +- **Methods:** `Roles`, `RoleCreate(roleID, privileges)` +- **Expected:** `Roles` lists predefined roles; `RoleCreate` POSTs a custom role + to `…/AccountService/Roles`. +- **Verify:** `GET …/AccountService/Roles` lists the new role with the privileges. +- **Note (DEFERRED):** AccountService lockout/LDAP read+PATCH is **not + implemented** (OpenSpec task 2.3, no spec scenario) — out of scope for this gate. + +--- + +## TC-LOG — Logs & SEL + +### TC-LOG-1 — SEL read (parsed) 🟢 +- **Method:** `GetSystemEventLog` — **Driver:** harness `sel.read` +- **Note:** aggregates **all** manager log services (Sel + AuditLog) → may return + more than just SEL rows. +- **Verify:** `$CURL $XCC/redfish/v1/Managers/1/LogServices/Sel/Entries | jq '.Members|length'`. + +### TC-LOG-2 — SEL read (raw) 🟢 *(not in harness)* +- **Method:** `GetSystemEventLogRaw` — **Driver:** `manual:go` +- **Expected:** raw entries payload returned. + +### TC-LOG-3 — SEL clear 🟡 +- **Method:** `ClearSystemEventLog` — **Driver:** `harness -allow-writes -clear-sel` +- **Caveat:** SEL get reads via *Managers* path; clear acts via *Chassis* + `…/LogServices/Sel/Actions/LogService.ClearLog` (wrapper design). +- **Expected:** `sel.clear PASS`. +- **Verify:** re-read SEL → entry count drops (UI → *Event Log*). + +### TC-LOG-4 — Audit / OEM log services read 🟢 +- **Method:** `EventLog(ctx, logServiceID)` — **Driver:** harness `log.audit` + (`lenovo.LogServiceAudit`) +- **Other IDs:** `LogServiceActive`, `LogServicePlatform`, `LogServiceMaintenance`, + `LogServiceServiceAdvisor`, `LogServiceDiagnostic` — run `manual:go` for each + the model exposes. + +--- + +## TC-BMC — BMC management + +### TC-BMC-1 — BMC reset 🟠 +- **Method:** `BmcReset(resetType)` — **Driver:** `harness -allow-writes -bmc-reset ` +- **Expected:** `bmc.reset PASS`; **the session drops** as the BMC restarts. +- **Verify:** XCC pings back after ~1–3 min; web UI reachable again. Host power + state is unaffected. + +### TC-BMC-2 — Reset to factory defaults 🔴 *(not in harness)* +- **Method:** `ResetToFactoryDefaults(resetType)` — `resetType` default `ResetAll` +- **Driver:** `manual:go` — **last test on the box**; wipes BMC config (network, + users, certs). Have console/physical access to re-IP afterward. +- **Verify:** BMC returns to defaults (DHCP/default creds per the chosen scope). + +### TC-BMC-3 — Update manager config 🟡 *(not in harness)* +- **Method:** `UpdateManager(properties map)` +- **Procedure:** PATCH a benign OEM/timezone property (e.g. `DateTimeLocalOffset`). +- **Verify:** `GET …/Managers/1` shows the new value; UI → *BMC Configuration*. + +### TC-BMC-4 — License read 🟢 +- **Method:** `Licenses` — **Driver:** harness `license.read` +- **Verify:** `$CURL $XCC/redfish/v1/LicenseService/Licenses | jq '.Members'`. +- **Hardware finding (2026-06-09, SR630 V2):** this firmware returned **404 + RequestUriNotFound** for `/redfish/v1/LicenseService/Licenses`. The provider + now treats an absent LicenseService (404) as **"no licenses"** — `Licenses` + returns an empty slice with no error, so `license.read` reports **PASS + (0 licenses)** instead of WARN. Other errors still propagate. FOLLOW-UP + (deferred): confirm whether such models expose licenses under a different path + (e.g. `Managers//Oem/Lenovo`) and read from there instead of stubbing. + +### TC-BMC-5 — License install / delete 🟡 *(not in harness)* +- **Methods:** `LicenseInstall(string)`, `LicenseDelete(id)` — **Driver:** `manual:go` +- **Procedure:** install a valid XCC license string (e.g. XCC Advanced trial), + confirm, then delete it. +- **Expected:** install adds a license member; delete removes it. +- **Verify:** UI → *BMC Configuration → License* before/after. + +### TC-BMC-6 — SKLM read 🟢 +- **Method:** `GetSecureKeyLifecycle` — **Driver:** harness `sklm.read` +- **Expected:** `deviceGroup=<> keyServers=` (WARN if SKLM not licensed/present). + +### TC-BMC-7 — SKLM set key-repo servers 🟡 *(not in harness)* +- **Method:** `SetSecureKeyRepoServers([]SecureKeyRepoServer)` — **Driver:** `manual:go` +- **Procedure:** PATCH a test `HostName`/`Port` list to + `…/Managers/1/Oem/Lenovo/SecureKeyLifecycleService`. +- **Verify:** re-read SKLM config shows the servers. + +--- + +## TC-NET — Network & TC-SER — Serial + +### TC-NET-1 — NIC read (BMC + server) 🟢 +- **Methods:** `BMCNetworkInterfaces`, `ServerNetworkInterfaces` — **Driver:** harness `network.bmc`/`network.server` +- **Verify:** `$CURL $XCC/redfish/v1/Managers/1/EthernetInterfaces` and + `…/Systems/1/EthernetInterfaces`. + +### TC-NET-2 — BMC NIC set 🟠 *(not in harness)* +- **Method:** `SetBMCNetworkInterface(id, attrs)` — **Driver:** `manual:go` +- **⚠️ Self-lockout risk:** changing the BMC IP/VLAN can cut your connection. + Test a non-management property first, or have console access. +- **Verify:** re-read the interface; UI → *Network*. + +### TC-NET-3 — Host interface enable 🟡 *(not in harness)* +- **Method:** `SetHostInterfaceEnabled(id, enabled)` +- **Verify:** `GET …/Managers/1/HostInterfaces/` → `InterfaceEnabled`. + +### TC-NET-4 — Network protocols read 🟢 +- **Method:** `NetworkProtocols` — **Driver:** harness `network.protocols` +- **Expected:** services list (HTTP/HTTPS/SSH/IPMI/SNMP/NTP/KVMIP/VirtualMedia/SSDP/Telnet) with port + enabled. +- **Verify:** `$CURL $XCC/redfish/v1/Managers/1/NetworkProtocol | jq`. + +### TC-NET-5 — Network protocols set 🟠 *(not in harness)* +- **Method:** `SetNetworkProtocols(attrs)` — **Driver:** `manual:go` +- **⚠️** Disabling HTTPS/SSH can lock you out. Toggle a low-risk one (e.g. SSDP) + and revert. +- **Verify:** re-read protocols; the toggled service changed. + +### TC-SER-1 — Serial read 🟢 +- **Method:** `SerialInterfaces` — **Driver:** harness `serial.read` +- **Verify:** `$CURL $XCC/redfish/v1/Managers/1/SerialInterfaces | jq '.Members'`. + +### TC-SER-2 — Serial set 🟡 *(not in harness)* +- **Method:** `SetSerialInterface(id, attrs)` — **Driver:** `manual:go` +- **Procedure:** PATCH `BitRate`/`Parity` on a serial interface and revert. +- **Verify:** re-read shows the new value. + +--- + +## TC-EVT — Events & TC-TEL — Telemetry + +### TC-EVT-1 — Event service + subscriptions read 🟢 +- **Methods:** `EventService`, `EventSubscriptions` — **Driver:** harness `event.service`/`event.subscriptions` +- **Verify:** `$CURL $XCC/redfish/v1/EventService` and `…/EventService/Subscriptions`. + +### TC-EVT-2 — Subscription create / delete 🟡 *(not in harness)* +- **Methods:** `EventSubscriptionCreate(req)`, `EventSubscriptionDelete(id)` — **Driver:** `manual:go` +- **Procedure:** create a subscription with `Destination` = a reachable HTTP + listener you control; capture the returned id; delete it. +- **Expected:** create returns a non-empty id (from `Location`); delete removes it. +- **Verify:** `GET …/EventService/Subscriptions` before/after; if you also run + TC-EVT-3, confirm your listener receives the test event. + +### TC-EVT-3 — Submit test event 🟡 *(not in harness)* +- **Method:** `SubmitTestEvent(messageID)` — **Driver:** `manual:go` +- **Verify:** the subscribed destination receives the event payload. + +### TC-EVT-4 — Set event service 🟡 *(not in harness)* +- **Method:** `SetEventService(attrs)` — PATCH e.g. `ServiceEnabled` / delivery-retry. +- **Verify:** re-read EventService. + +### TC-TEL-1 — Telemetry service + reports + definitions read 🟢 +- **Methods:** `TelemetryService`, `MetricReports`, `MetricReportDefinitions`, `MetricDefinitions` +- **Driver:** harness `telemetry.service`/`telemetry.reports`/`telemetry.definitions` +- **Expected:** WARN acceptable if the model has no TelemetryService. + +### TC-TEL-2 — Single metric report / definition read 🟢 *(not in harness)* +- **Methods:** `MetricReport(id)`, `MetricReportDefinitions` (then `MetricReport` per id) +- **Verify:** `$CURL $XCC/redfish/v1/TelemetryService/MetricReports/`. + +### TC-TEL-3 — Submit test metric report 🟡 *(not in harness)* +- **Method:** `SubmitTestMetricReport(reportName)` — **Driver:** `manual:go` +- **Verify:** POST `…/TelemetryService.SubmitTestMetricReport` succeeds; report appears. + +--- + +## TC-JOB — Jobs + +### TC-JOB-1 — Job service + list read 🟢 +- **Methods:** `JobService`, `Jobs` — **Driver:** harness `job.service`/`job.list` + +### TC-JOB-2 — Single job read 🟢 *(not in harness)* +- **Method:** `Job(id)` — read a real job id from TC-JOB-1. + +### TC-JOB-3 — Update job schedule 🟡 *(not in harness)* +- **Method:** `JobUpdateSchedule(id, schedule)` — **Driver:** `manual:go` +- **Verify:** re-read the job; schedule changed. + +--- + +## TC-CRT — Certificates + +### TC-CRT-1 — Certificate locations + read 🟢 +- **Methods:** `CertificateLocations`, `Certificate(location)` — **Driver:** harness `cert.locations`/`cert.read` +- **Verify:** `$CURL $XCC/redfish/v1/CertificateService/CertificateLocations`. + +### TC-CRT-2 — Generate CSR 🟢 *(not in harness)* +- **Method:** `GenerateCSR(*CSRRequest)` — **Driver:** `manual:go` +- **Expected:** returns a PEM CSR string. +- **Verify:** `openssl req -in csr.pem -noout -text` parses the subject you sent. + +### TC-CRT-3 — Replace certificate 🟠 *(not in harness)* +- **Method:** `ReplaceCertificate(certificatePEM, targetURI)` +- **⚠️** Replacing the HTTPS cert resets TLS — the browser/clients will see the + new cert; an invalid PEM can break HTTPS access. Test with a valid signed cert. +- **Verify:** browser shows the new cert; `openssl s_client -connect :443`. + +### TC-CRT-4 — Rekey / renew certificate 🟠 *(not in harness)* +- **Methods:** `RekeyCertificate(certURI)`, `RenewCertificate(certURI)` +- **Verify:** new cert serial/validity on the HTTPS endpoint. + +--- + +## TC-SNM — SNMP + +### TC-SNM-1 — SNMP read 🟢 +- **Method:** `SNMP` — **Driver:** harness `snmp.read` +- **Expected:** `v1Trap=<> v3Trap=<> port=<>`. + +### TC-SNM-2 — Enable/disable v1 / v3 trap 🟡 +- **Methods:** `EnableSNMPv1Trap(bool)`, `EnableSNMPv3Trap(bool)` +- **Driver:** `harness -allow-writes -snmp-v1-trap ` / `-snmp-v3-trap …` +- **Verify:** re-read SNMP; UI → *BMC Configuration → SNMP*; a configured trap + receiver gets a test trap if you also fire one. + +### TC-SNM-3 — Set SNMP alert filter 🟡 *(not in harness)* +- **Method:** `SetSNMPAlertFilter(attrs)` — **Driver:** `manual:go` +- **Verify:** re-read; PATCH applied to `…/NetworkProtocol/Oem/Lenovo/SNMP`. + +--- + +## Appendix A — manual-call templates + +For `manual:go` cases, drop a throwaway `main()` next to the harness (or extend +it with a new flag). Skeleton — connect like the harness, then call the method: + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/bmc-toolbox/bmclib/v2/bmc" + "github.com/bmc-toolbox/bmclib/v2/providers/lenovo" + logrusr "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + logger := logrusr.New(logrus.New()) + c := lenovo.New("", "", "", logger, lenovo.WithPort("443")) + if !c.Compatible(ctx) { panic("not a Lenovo XCC") } + if err := c.Open(ctx); err != nil { panic(err) } + defer c.Close(ctx) + + // --- pick the call under test, e.g.: --- + + // TC-SEC-3 + // err := c.ResetSecureBootKeys(ctx, bmc.ResetSecureBootKeysTypeResetAllKeysToDefault) + + // TC-USR-2 / 3 / 4 + // err := c.UserCreate(ctx, "smoketest", "S3cret!Passw0rd", "Administrator") + // err := c.UserUpdate(ctx, "smoketest", "New!Passw0rd", "Operator") + // err := c.UserDelete(ctx, "smoketest") + + // TC-USR-5 + // err := c.RoleCreate(ctx, "custom", []string{"Login", "ConfigureManager"}) + // roles, err := c.Roles(ctx) + + // TC-STO-2..5 + // id, err := c.VolumeCreate(ctx, "", bmc.VolumeCreateRequest{ /* RAIDType, Drives, CapacityBytes */ }) + // err := c.VolumeInitialize(ctx, "", id, "Fast") + // err := c.VolumeUpdate(ctx, "", id, map[string]any{"Name": "newname"}) + // err := c.VolumeDelete(ctx, "", id) + + // TC-FW-5 + // taskID, err := c.SimpleUpdate(ctx, "http://10.0.0.2/lnvgy_fw.uxz", "HTTP") + + // TC-BMC-2 / 3 / 5 / 7 + // err := c.ResetToFactoryDefaults(ctx, "ResetAll") // LAST — wipes config + // err := c.UpdateManager(ctx, map[string]any{"DateTimeLocalOffset": "+00:00"}) + // err := c.LicenseInstall(ctx, ""); err = c.LicenseDelete(ctx, "") + // err := c.SetSecureKeyRepoServers(ctx, []bmc.SecureKeyRepoServer{{HostName: "kmip.lab", Port: 5696}}) + + // TC-NET-2 / 3 / 5, TC-SER-2 + // err := c.SetBMCNetworkInterface(ctx, "eth0", map[string]any{ /* ... */ }) + // err := c.SetHostInterfaceEnabled(ctx, "1", true) + // err := c.SetNetworkProtocols(ctx, map[string]any{"SSDP": map[string]any{"ProtocolEnabled": false}}) + // err := c.SetSerialInterface(ctx, "1", map[string]any{"BitRate": "115200"}) + + // TC-EVT-2 / 3 / 4 + // id, err := c.EventSubscriptionCreate(ctx, bmc.EventSubscriptionRequest{ /* Destination, Protocol, RegistryPrefixes */ }) + // err := c.SubmitTestEvent(ctx, "") + // err := c.EventSubscriptionDelete(ctx, id) + // err := c.SetEventService(ctx, map[string]any{"ServiceEnabled": true}) + + // TC-TEL-3, TC-SNM-3, TC-CRT-2..4, TC-LOG-2 + // err := c.SubmitTestMetricReport(ctx, "") + // err := c.SetSNMPAlertFilter(ctx, map[string]any{ /* ... */ }) + // csr, err := c.GenerateCSR(ctx, &bmc.CSRRequest{ /* CommonName, Org, ... */ }) + // err := c.ReplaceCertificate(ctx, "", "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/1") + // err := c.RekeyCertificate(ctx, ""); err = c.RenewCertificate(ctx, "") + // entries, err := c.GetSystemEventLogRaw(ctx) + + fmt.Println("done") +} +``` + +Raw Redfish independent verification (no provider): every read above has a +`$CURL $XCC/redfish/v1/...` form shown inline. XCC accepts HTTP Basic on all +resources except it requires auth beyond `/redfish/v1/`. + +--- + +## Release-gate sign-off + +Record per the RUNBOOK "What to record" list, plus this table: + +| TC | Run? | Result (PASS/WARN/FAIL/NA) | Evidence (log / UI screenshot / curl) | Notes | +|----|------|----------------------------|---------------------------------------|-------| +| TC-FND-1 … TC-SNM-3 | | | | | + +**Gate to ship:** every 🟢 read = PASS or justified WARN; every 🟡/🟠 mutation +exercised on the lab box = PASS with independent verification; TC-FW-1 (firmware +push) + TC-VM-1 (vmedia PATCH) = PASS (these are the two highest-variance XCC +behaviours the fixtures cannot prove). 🔴 destructive cases (TC-STO-2/3/5, +TC-BMC-2) run last, on a throwaway box, with a documented recovery path. diff --git a/examples/lenovo-smoketest/RUNBOOK.md b/examples/lenovo-smoketest/RUNBOOK.md new file mode 100644 index 00000000..fb2b80cf --- /dev/null +++ b/examples/lenovo-smoketest/RUNBOOK.md @@ -0,0 +1,124 @@ +# Lenovo XCC provider — real-hardware smoke test runbook + +`lenovo-smoketest` validates the bmclib `lenovo` provider against a real Lenovo +XClarity Controller (XCC). The unit tests run entirely against fixtures; this +harness is the bridge to actual hardware. + +## Safety model + +- **Read-only by default.** A plain invocation performs only GET/read calls and + never changes the BMC or host. +- **Mutations are double-gated.** A mutating operation runs only when BOTH the + master `-allow-writes` flag AND that operation's own flag are given. Otherwise + it is reported as `SKIP`. +- **Exit code.** `0` when there are no `FAIL` results, `1` otherwise. `WARN` + (a capability the model does not expose) does not fail the run. + +## Result statuses + +| Status | Meaning | +|--------|---------| +| `PASS` | the check succeeded | +| `WARN` | a non-core read failed — the XCC model may not expose it (telemetry, jobs, SKLM, audit log, …) | +| `FAIL` | a core capability failed (compatibility, service root, power state, inventory) or a requested mutation failed | +| `SKIP` | a mutation whose flag (or `-allow-writes`) was not provided | + +## 1. Read-only sweep (run this first) + +```bash +go run ./examples/lenovo-smoketest -host -user -pass +# add -v for HTTP debug logging, -json to capture the report for CI +``` + +Expected: a list of `[PASS]`/`[WARN]`/`[SKIP]` lines and a summary with +`0 FAIL`. Capture the output. Investigate every `FAIL`; review `WARN`s to +confirm they correspond to genuinely-absent capabilities on the model under +test. + +Reads exercised: serviceroot, power state, boot progress + override, BIOS, +secure boot, thermal, power metrics, inventory, storage controllers + volumes, +users, SEL, audit log, licenses, SKLM, BMC/server NICs, network protocols, +serial, event service + subscriptions, telemetry service + reports + definitions, +job service + list, certificate locations + read, SNMP, firmware install steps. + +## 2. Targeted mutation checks (opt-in, one at a time) + +Run against a lab/non-production server. Each needs `-allow-writes` plus its flag. + +```bash +# power (use a state safe for the box under test) +go run ./examples/lenovo-smoketest -host -user -pass

-allow-writes -power on + +# next-boot device (one-time override). NOTE: XCC allows only a one-time (Once) +# override — it rejects -bootdev-persistent (Continuous); manage persistent boot +# via the BootOrder instead. +... -allow-writes -bootdev pxe # one-time, legacy +... -allow-writes -bootdev cdrom -bootdev-efi # one-time, UEFI (boot a mounted ISO) + +# BIOS set / reset +# BIOS attribute NAMES are model/firmware-specific. Discover the exact keys on +# THIS box first (one unknown key fails the whole PATCH with PropertyUnknown): +# go run ./examples/lenovo-smoketest -host -user -pass

-v 2>/dev/null | grep "bios attr" +# then set a key copied verbatim from that list, e.g. on a ThinkSystem SR630 V2: +... -allow-writes -set-bios "BootModes_SystemBootMode=UEFIMode" +... -allow-writes -reset-bios + +# secure boot / power cap +... -allow-writes -secureboot enable +... -allow-writes -powercap 1200 # or: -powercap off + +# virtual media (host the ISO somewhere reachable by the XCC) +... -allow-writes -vmedia-url http://10.0.0.2/boot.iso -vmedia-kind CD +... -allow-writes -vmedia-eject -vmedia-kind CD + +# logs / SNMP / BMC reset / NMI +... -allow-writes -clear-sel +... -allow-writes -snmp-v1-trap enable +... -allow-writes -bmc-reset GracefulRestart # disconnects the session +... -allow-writes -nmi +``` + +## 2b. Virtual-media boot flow (ordered: mount → set boot → power) + +A focused mode (folded in from the former `lenovo-vmedia-boot` example) that +mounts an image, points the next boot at it, and powers the host on — in that +order. It replaces the read/write sweep and is gated by `-allow-writes`. + +```bash +go run ./examples/lenovo-smoketest -host -user -pass

\ + -allow-writes -vmedia-boot \ + -vmedia-url http://10.0.0.2/installer.iso -vmedia-kind CD \ + -bootdev-efi -power on +``` + +Notes: the boot device is derived from `-vmedia-kind` (CD/DVD→`cdrom`, +Floppy→`floppy`, USBStick→`usb`); the override is one-time (XCC rejects a +persistent/`Continuous` override — `-bootdev-persistent` will error); pass +`-power on` explicitly (empty `-power` mounts + sets boot but skips powering on). + +## 3. Firmware install (highest-risk — dedicated lab box only) + +The firmware path validates the XCC push protocol (claim `HttpPushUriTargetsBusy` +→ multipart `/mfwupdate` or raw `/fwupdate` → poll the Task → release). This is +the protocol variance the unit tests can only fixture-test. + +```bash +go run ./examples/lenovo-smoketest -host -user -pass

\ + -allow-writes \ + -firmware /path/to/lnvgy_fw_*.uxz \ + -firmware-component BMC-Backup \ + -firmware-applytime OnReset \ + -timeout 30m -firmware-poll 15s +``` + +The harness pushes the image, prints the task id, then polls the Task resource +(never the TaskMonitor URI) until `complete`/`failed`. Use a long `-timeout`. + +## What to record for the release gate + +1. The full read-only sweep output (`-json` recommended) with `0 FAIL`. +2. XCC firmware version / model (from the `inventory.read` line). +3. For each mutation exercised: the `PASS` line and independent confirmation on + the BMC UI/CLI that the change took effect. +4. The firmware task transcript (`pushed; taskID=…` → `task completed`). +5. Any `WARN`/`FAIL` with notes on whether it is expected for the model. diff --git a/examples/lenovo-smoketest/dump-xcc.sh b/examples/lenovo-smoketest/dump-xcc.sh new file mode 100755 index 00000000..0c2a898e --- /dev/null +++ b/examples/lenovo-smoketest/dump-xcc.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# +# dump-xcc.sh — READ-ONLY Redfish dumper for the Lenovo XCC bmclib provider. +# +# GETs every Redfish resource the `lenovo` provider touches and saves the raw +# JSON under an output directory, so you can verify against real hardware the +# paths, field names, OEM structures, action targets and *@Redfish.AllowableValues +# the provider assumes. It performs ONLY GETs — it never mutates the BMC. +# +# Usage: +# ./dump-xcc.sh [outdir] +# XCC_INSECURE=0 ./dump-xcc.sh ... # verify TLS (default: -k, insecure) +# TOP=5 ./dump-xcc.sh ... # cap log-entry pages at $top=5 (default 5) +# +# Requires: curl. Optional: jq (enables collection-member expansion + pretty +# printing + the BIOS attribute registry resolve; without it you get top-level +# resources only). Output: one .json per resource + an _index.tsv of HTTP codes. +# +set -u + +HOST="${1:-}"; USERNAME="${2:-}"; PASSWORD="${3:-}"; OUTDIR="${4:-xcc-dump}" +if [[ -z "$HOST" || -z "$USERNAME" || -z "$PASSWORD" ]]; then + echo "usage: $0 [outdir]" >&2 + exit 2 +fi + +XCC="https://${HOST}" +TOP="${TOP:-5}" +CURL_INSECURE="-k"; [[ "${XCC_INSECURE:-1}" == "0" ]] && CURL_INSECURE="" +AUTH=(-u "${USERNAME}:${PASSWORD}") +HAVE_JQ=0; command -v jq >/dev/null 2>&1 && HAVE_JQ=1 + +mkdir -p "$OUTDIR" +INDEX="$OUTDIR/_index.tsv" +: > "$INDEX" +[[ $HAVE_JQ -eq 0 ]] && echo "WARN: jq not found — collections will not be expanded and output is raw" >&2 + +# get — GET $XCC, save pretty JSON to a file named after the path, +# and record "HTTP_CODE\tpath" in the index. Safe to call on absent resources. +get() { + local path="$1" + local name="${path#/redfish/v1/}"; name="${name//\//__}"; name="${name%__}" + [[ -z "$name" ]] && name="serviceroot" + local out="$OUTDIR/${name}.json" + local code + code=$(curl -s $CURL_INSECURE "${AUTH[@]}" -H 'Accept: application/json' \ + --connect-timeout 10 --max-time 60 \ + -o "${out}.raw" -w '%{http_code}' "${XCC}${path}") + printf '%s\t%s\n' "$code" "$path" | tee -a "$INDEX" + if [[ ! -s "${out}.raw" ]]; then + # No body (connection failure, or an empty response): leave a placeholder. + rm -f "${out}.raw"; : > "$out"; return 0 + fi + if [[ $HAVE_JQ -eq 1 ]] && jq . "${out}.raw" >"$out" 2>/dev/null; then + rm -f "${out}.raw" + else + mv -f "${out}.raw" "$out" + fi +} + +# members — print each Members[].@odata.id of a collection (needs jq). +members() { + [[ $HAVE_JQ -eq 0 ]] && return 0 + local out="$OUTDIR/$(echo "${1#/redfish/v1/}" | sed 's#/#__#g; s#__$##').json" + [[ -f "$out" ]] || return 0 + jq -r '.Members[]?."@odata.id" // empty' "$out" 2>/dev/null +} + +# get_members — dump the collection, then every member. +get_members() { get "$1"; local m; for m in $(members "$1"); do get "$m"; done; } + +section() { echo; echo "### $1"; } + +# -------------------------------------------------------------------------- +section "Service root, registries, metadata" +get "/redfish/v1/" +get_members "/redfish/v1/Registries" +# Resolve and dump the BIOS attribute registry (all valid attribute names, +# types and AllowableValues — the source of truth for -set-bios keys). +if [[ $HAVE_JQ -eq 1 && -f "$OUTDIR/Registries.json" ]]; then + for r in $(members "/redfish/v1/Registries"); do + rid=$(basename "$r") + case "$rid" in + *BiosAttributeRegistry*|*LenovoBiosAttributeRegistry*) + get "$r" + loc=$(jq -r '.Location[]?.Uri // empty' "$OUTDIR/$(echo "${r#/redfish/v1/}" | sed 's#/#__#g').json" 2>/dev/null | head -1) + [[ -n "$loc" ]] && get "$loc" + ;; + esac + done +fi + +section "Systems: power, boot, BIOS, secure boot, NICs" +get_members "/redfish/v1/Systems" +sys=$(members "/redfish/v1/Systems" | head -1); sys="${sys:-/redfish/v1/Systems/1}" +get "${sys}/Bios" +get "${sys}/Bios/Pending" # @Redfish.Settings target (set-bios) +get "${sys}/Bios/SD" # alt settings target on some FW +get "${sys}/Bios/ChangePasswordActionInfo" # ChangePassword allowable values +get "${sys}/SecureBoot" +get "${sys}/ResetActionInfo" # ComputerSystem.Reset allowable values +get_members "${sys}/EthernetInterfaces" + +section "Chassis: thermal, power metrics, power cap" +get_members "/redfish/v1/Chassis" +ch=$(members "/redfish/v1/Chassis" | head -1); ch="${ch:-/redfish/v1/Chassis/1}" +get "${ch}/Thermal" +get "${ch}/Power" + +section "Storage and volumes" +get_members "${sys}/Storage" +for st in $(members "${sys}/Storage"); do + get_members "${st}/Volumes" + get_members "${st}/Drives" +done + +section "Managers: BMC, network, serial, vmedia, SKLM, SNMP, logs" +get_members "/redfish/v1/Managers" +mgr=$(members "/redfish/v1/Managers" | head -1); mgr="${mgr:-/redfish/v1/Managers/1}" +get "${mgr}/NetworkProtocol" +get "${mgr}/NetworkProtocol/Oem/Lenovo/SNMP" +get "${mgr}/Oem/Lenovo/SecureKeyLifecycleService" +get_members "${mgr}/EthernetInterfaces" +get_members "${mgr}/HostInterfaces" +get_members "${mgr}/SerialInterfaces" +get_members "${mgr}/VirtualMedia" +# HTTPS certificate collection (cert replace/rekey/renew targets live here) +get_members "${mgr}/NetworkProtocol/HTTPS/Certificates" + +section "Log services (SEL, audit, ...) — first \$top=$TOP entries each" +get_members "${mgr}/LogServices" +for ls in $(members "${mgr}/LogServices"); do + get "${ls}" + get "${ls}/Entries?\$top=${TOP}" +done +get_members "${ch}/LogServices" +for ls in $(members "${ch}/LogServices"); do get "${ls}"; done + +section "Firmware: update service, inventory, tasks" +get "/redfish/v1/UpdateService" +get_members "/redfish/v1/UpdateService/FirmwareInventory" +get_members "/redfish/v1/TaskService/Tasks" +get "/redfish/v1/TaskService" + +section "Accounts and roles" +get "/redfish/v1/AccountService" +get_members "/redfish/v1/AccountService/Accounts" +get_members "/redfish/v1/AccountService/Roles" + +section "Events and telemetry" +get "/redfish/v1/EventService" +get_members "/redfish/v1/EventService/Subscriptions" +get "/redfish/v1/TelemetryService" +get_members "/redfish/v1/TelemetryService/MetricReports" +get_members "/redfish/v1/TelemetryService/MetricReportDefinitions" +get_members "/redfish/v1/TelemetryService/MetricDefinitions" + +section "Jobs" +get "/redfish/v1/JobService" +get_members "/redfish/v1/JobService/Jobs" + +section "Certificates" +get "/redfish/v1/CertificateService" +get "/redfish/v1/CertificateService/CertificateLocations" + +section "License (404 on firmware levels without LicenseService)" +get "/redfish/v1/LicenseService" +get_members "/redfish/v1/LicenseService/Licenses" + +echo +echo "done. dumps in: $OUTDIR/ index: $INDEX" +echo "non-200 responses (absent/locked resources):" +awk -F'\t' '$1!=200 {printf " %s %s\n", $1, $2}' "$INDEX" || true diff --git a/examples/lenovo-smoketest/main.go b/examples/lenovo-smoketest/main.go new file mode 100644 index 00000000..74aa25fb --- /dev/null +++ b/examples/lenovo-smoketest/main.go @@ -0,0 +1,904 @@ +// Command lenovo-smoketest runs an opt-in, real-hardware smoke test of the +// Lenovo XCC bmclib provider, and (in -vmedia-boot mode) the ordered +// virtual-media boot flow. +// +// By default it is READ-ONLY: it connects, runs the vendor compatibility check, +// and exercises every read capability (power/boot/BIOS/secure-boot/thermal/ +// power/inventory/storage/users/logs/license/SKLM/network/serial/events/ +// telemetry/jobs/certificates/SNMP/firmware-steps), printing a PASS/WARN/FAIL/ +// SKIP report. +// +// Mutating operations are NEVER performed unless the master -allow-writes flag +// is set AND the specific operation's flag is provided. This makes accidental +// changes to production hardware impossible with a default invocation. +// +// Read failures on capabilities a given XCC model may not expose (telemetry, +// jobs, SKLM, ...) are reported as WARN (non-fatal). Failures of the core +// capabilities (compatibility, service root, power state, inventory) are FAIL +// and set a non-zero exit code. +// +// Usage: +// +// # read-only sweep +// go run ./examples/lenovo-smoketest -host -user -pass +// +// # include selected mutations (each still requires its own flag) +// go run ./examples/lenovo-smoketest -host -user -pass \ +// -allow-writes -power on -bootdev pxe +// +// # virtual-media boot: mount an ISO, set it as next boot device, power on +// # (one ordered flow; requires -allow-writes; skips the read/write sweep) +// go run ./examples/lenovo-smoketest -host -user -pass \ +// -allow-writes -vmedia-boot -vmedia-url http://10.0.0.2/installer.iso \ +// -vmedia-kind CD -bootdev-efi -power on +// +// This is intended for manual validation against real hardware; it is not part +// of the unit-test suite (which runs entirely against fixtures). See RUNBOOK.md +// and HARDWARE-TEST-PLAN.md. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + logrusr "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" + + "github.com/bmc-toolbox/bmclib/v2/constants" + "github.com/bmc-toolbox/bmclib/v2/providers/lenovo" +) + +// --------------------------------------------------------------------------- +// harness +// --------------------------------------------------------------------------- + +const ( + statusPass = "PASS" + statusWarn = "WARN" + statusFail = "FAIL" + statusSkip = "SKIP" +) + +// result is a single smoke-test check outcome. +type result struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +// harness drives the checks against a connected XCC and accumulates results. +type harness struct { + conn *lenovo.Conn + ctx context.Context + verbose bool + results []result +} + +// record appends a result and prints it as it happens. +func (h *harness) record(name, status, detail string) { + h.results = append(h.results, result{Name: name, Status: status, Detail: detail}) + fmt.Printf("[%s] %-34s %s\n", status, name, detail) +} + +// read runs a read-only check. On error the status is FAIL when critical (core +// capability), otherwise WARN (the XCC model may not expose the resource). +func (h *harness) read(name string, critical bool, fn func() (string, error)) { + summary, err := fn() + if err != nil { + status := statusWarn + if critical { + status = statusFail + } + h.record(name, status, err.Error()) + return + } + h.record(name, statusPass, summary) +} + +// action runs a mutating check. It is skipped unless enabled is true (the +// operation's flag was given together with -allow-writes). +func (h *harness) action(name string, enabled bool, skipReason string, fn func() error) { + if !enabled { + h.record(name, statusSkip, skipReason) + return + } + if err := fn(); err != nil { + h.record(name, statusFail, err.Error()) + return + } + h.record(name, statusPass, "ok") +} + +// --------------------------------------------------------------------------- +// flags +// --------------------------------------------------------------------------- + +// options holds the parsed command-line configuration. +type options struct { + host, user, pass, port string + basic bool + verbose bool + jsonOut bool + timeout time.Duration + + // write gate + per-operation flags (all empty/false => skipped). + allowWrites bool + power string + bootDevice string + bootPersistent bool + bootEFI bool + nmi bool + bmcReset string + setBIOS string + resetBIOS bool + secureBoot string + powerCap string + vmediaURL string + vmediaKind string + vmediaEject bool + vmediaBoot bool + clearSEL bool + snmpV1Trap string + snmpV3Trap string + + firmwareFile string + firmwareComponent string + firmwareApplyTime string + firmwarePollEvery time.Duration +} + +func parseFlags() *options { + var o options + flag.StringVar(&o.host, "host", "", "XCC hostname/IP (required)") + flag.StringVar(&o.user, "user", "", "username (required)") + flag.StringVar(&o.pass, "pass", "", "password (required)") + flag.StringVar(&o.port, "port", "443", "XCC Redfish port") + flag.BoolVar(&o.basic, "basic", false, "use HTTP Basic auth instead of a session") + flag.BoolVar(&o.verbose, "v", false, "verbose: dump full payloads for read checks") + flag.BoolVar(&o.jsonOut, "json", false, "emit the result report as JSON") + flag.DurationVar(&o.timeout, "timeout", 5*time.Minute, "overall timeout") + + flag.BoolVar(&o.allowWrites, "allow-writes", false, "MASTER SWITCH: permit mutating operations (each still needs its own flag)") + flag.StringVar(&o.power, "power", "", "set power state: on|off|soft|cycle|reset") + flag.StringVar(&o.bootDevice, "bootdev", "", "set boot device: pxe|disk|cdrom|bios|usb|floppy|remote_drive|... (CD/DVD/USBStick aliases accepted)") + flag.BoolVar(&o.bootPersistent, "bootdev-persistent", false, "make -bootdev / -vmedia-boot persistent (Continuous)") + flag.BoolVar(&o.bootEFI, "bootdev-efi", false, "make -bootdev / -vmedia-boot UEFI mode") + flag.BoolVar(&o.nmi, "nmi", false, "send an NMI to the host") + flag.StringVar(&o.bmcReset, "bmc-reset", "", "reset the BMC: GracefulRestart|ForceRestart") + flag.StringVar(&o.setBIOS, "set-bios", "", "set BIOS attributes: \"Key=Value,Key2=Value2\"") + flag.BoolVar(&o.resetBIOS, "reset-bios", false, "reset BIOS settings to defaults") + flag.StringVar(&o.secureBoot, "secureboot", "", "set Secure Boot: enable|disable") + flag.StringVar(&o.powerCap, "powercap", "", "set power cap watts, or \"off\" to disable") + flag.StringVar(&o.vmediaURL, "vmedia-url", "", "insert virtual media from this URL") + flag.StringVar(&o.vmediaKind, "vmedia-kind", "CD", "virtual media kind for -vmedia-url / -vmedia-boot: CD|DVD|Floppy|USBStick") + flag.BoolVar(&o.vmediaEject, "vmedia-eject", false, "eject virtual media of -vmedia-kind") + flag.BoolVar(&o.vmediaBoot, "vmedia-boot", false, "ordered flow: mount -vmedia-url, set it as next boot device, power on (skips the read/write sweep)") + flag.BoolVar(&o.clearSEL, "clear-sel", false, "clear the System Event Log") + flag.StringVar(&o.snmpV1Trap, "snmp-v1-trap", "", "enable|disable the SNMPv1 trap") + flag.StringVar(&o.snmpV3Trap, "snmp-v3-trap", "", "enable|disable the SNMPv3 trap") + flag.StringVar(&o.firmwareFile, "firmware", "", "firmware image file to install (XCC push protocol)") + flag.StringVar(&o.firmwareComponent, "firmware-component", "", "firmware target (FirmwareInventory id, e.g. BMC-Backup); empty = auto-detect") + flag.StringVar(&o.firmwareApplyTime, "firmware-applytime", "OnReset", "firmware OperationApplyTime: Immediate|OnReset|OnStartUpdateRequest") + flag.DurationVar(&o.firmwarePollEvery, "firmware-poll", 15*time.Second, "firmware task poll interval") + flag.Parse() + + return &o +} + +func main() { + o := parseFlags() + if o.host == "" || o.user == "" || o.pass == "" { + fmt.Fprintln(os.Stderr, "error: -host, -user and -pass are required") + flag.Usage() + os.Exit(2) + } + + l := logrus.New() + if o.verbose { + l.Level = logrus.DebugLevel + } else { + l.Level = logrus.InfoLevel + } + logger := logrusr.New(l) + + ctx, cancel := context.WithTimeout(context.Background(), o.timeout) + defer cancel() + + opts := []lenovo.Option{lenovo.WithPort(o.port)} + if o.basic { + opts = append(opts, lenovo.WithUseBasicAuth(true)) + } + conn := lenovo.New(o.host, o.user, o.pass, logger, opts...) + + h := &harness{conn: conn, ctx: ctx, verbose: o.verbose} + + // Compatibility opens and closes its own session. + if !conn.Compatible(ctx) { + h.record("connection.compatible", statusFail, "device is NOT compatible with the lenovo provider") + h.finish(o) + return + } + h.record("connection.compatible", statusPass, "device identifies as Lenovo XCC") + + if err := conn.Open(ctx); err != nil { + h.record("connection.open", statusFail, err.Error()) + h.finish(o) + return + } + h.record("connection.open", statusPass, "session established") + defer func() { _ = conn.Close(ctx) }() + + // -vmedia-boot is a focused, ordered mutation flow; it replaces the + // read/write sweep rather than running alongside it. + if o.vmediaBoot { + runVMediaBoot(h, o) + h.finish(o) + return + } + + runReadChecks(h) + runWriteChecks(h, o) + + h.finish(o) +} + +// finish prints the report (text or JSON) and exits with the right code. +func (h *harness) finish(o *options) { + if o.jsonOut { + _ = json.NewEncoder(os.Stdout).Encode(h.results) + } + + var pass, warn, fail, skip int + for _, r := range h.results { + switch r.Status { + case statusPass: + pass++ + case statusWarn: + warn++ + case statusFail: + fail++ + case statusSkip: + skip++ + } + } + + fmt.Printf("\nsummary: %d PASS, %d WARN, %d FAIL, %d SKIP (%d checks)\n", + pass, warn, fail, skip, len(h.results)) + + if fail > 0 { + os.Exit(1) + } +} + +// --------------------------------------------------------------------------- +// read checks +// --------------------------------------------------------------------------- + +// runReadChecks exercises every read capability of the provider. Core +// capabilities are marked critical (failure => non-zero exit); the rest are +// non-critical (a given XCC model may not expose them, reported as WARN). +func runReadChecks(h *harness) { + readPlatformChecks(h) + readInventoryChecks(h) + readNetworkEventChecks(h) + readCertSNMPFirmwareChecks(h) +} + +// readPlatformChecks runs the read-only checks for service root, power, boot, BIOS, Secure Boot and thermal/power metrics. +func readPlatformChecks(h *harness) { + ctx := h.ctx + c := h.conn + + h.read("serviceroot.read", true, func() (string, error) { + root, err := c.ServiceRoot(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("vendor=%q product=%q redfish=%s", root.Vendor, root.Product, root.RedfishVersion), nil + }) + + // --- power / boot --- + h.read("power.state", true, func() (string, error) { + state, err := c.PowerStateGet(ctx) + return "state=" + state, err + }) + h.read("boot.progress", false, func() (string, error) { + bp, err := c.GetBootProgress() + if err != nil { + return "", err + } + return "lastState=" + string(bp.LastState), nil + }) + h.read("boot.override", false, func() (string, error) { + o, err := c.BootDeviceOverrideGet(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("device=%s persistent=%t efi=%t", o.Device, o.IsPersistent, o.IsEFIBoot), nil + }) + + // --- bios / secure boot --- + h.read("bios.read", false, func() (string, error) { + cfg, err := c.GetBiosConfiguration(ctx) + if err != nil { + return "", err + } + // In verbose mode dump every attribute name=value so the operator can + // copy exact keys for -set-bios (attribute names are model/firmware + // specific; an unknown key fails the whole PATCH with PropertyUnknown). + if h.verbose { + keys := make([]string, 0, len(cfg)) + for k := range cfg { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" bios attr %s = %s\n", k, cfg[k]) + } + } + return fmt.Sprintf("%d attributes", len(cfg)), nil + }) + h.read("secureboot.read", false, func() (string, error) { + enabled, err := c.GetSecureBoot(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("enabled=%t", enabled), nil + }) + + // --- thermal / power metrics --- + h.read("thermal.read", false, func() (string, error) { + t, err := c.Thermal(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%d temps, %d fans", len(t.Temperatures), len(t.Fans)), nil + }) + h.read("power.metrics", false, func() (string, error) { + p, err := c.ReadPower(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("consumed=%.0fW capacity=%.0fW supplies=%d", p.ConsumedWatts, p.CapacityWatts, len(p.PowerSupplies)), nil + }) +} + +// readInventoryChecks runs the read-only checks for inventory, storage, users, logs and BMC management. +func readInventoryChecks(h *harness) { + ctx := h.ctx + c := h.conn + + // --- inventory / storage --- + h.read("inventory.read", true, func() (string, error) { + dev, err := c.Inventory(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("vendor=%q model=%q cpus=%d dimms=%d drives=%d nics=%d", + dev.Vendor, dev.Model, len(dev.CPUs), len(dev.Memory), len(dev.Drives), len(dev.NICs)), nil + }) + + var firstController string + h.read("storage.controllers", false, func() (string, error) { + ctrls, err := c.StorageControllers(ctx) + if err != nil { + return "", err + } + if len(ctrls) > 0 { + firstController = ctrls[0].ID + } + return fmt.Sprintf("%d controllers", len(ctrls)), nil + }) + if firstController != "" { + h.read("storage.volumes", false, func() (string, error) { + vols, err := c.Volumes(ctx, firstController) + if err != nil { + return "", err + } + return fmt.Sprintf("controller %s: %d volumes", firstController, len(vols)), nil + }) + } else { + h.record("storage.volumes", statusSkip, "no storage controller to query") + } + + // --- users --- + h.read("users.read", false, func() (string, error) { + users, err := c.UserRead(ctx) + return fmt.Sprintf("%d accounts", len(users)), err + }) + + // --- logs --- + h.read("sel.read", false, func() (string, error) { + entries, err := c.GetSystemEventLog(ctx) + return fmt.Sprintf("%d SEL entries", len(entries)), err + }) + h.read("log.audit", false, func() (string, error) { + entries, err := c.EventLog(ctx, lenovo.LogServiceAudit) + return fmt.Sprintf("%d audit entries", len(entries)), err + }) + + // --- bmc management --- + h.read("license.read", false, func() (string, error) { + lics, err := c.Licenses(ctx) + return fmt.Sprintf("%d licenses", len(lics)), err + }) + h.read("sklm.read", false, func() (string, error) { + cfg, err := c.GetSecureKeyLifecycle(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("deviceGroup=%q keyServers=%d", cfg.DeviceGroup, len(cfg.KeyRepoServers)), nil + }) +} + +// readNetworkEventChecks runs the read-only checks for network, serial, events, telemetry and jobs. +func readNetworkEventChecks(h *harness) { + ctx := h.ctx + c := h.conn + + // --- network / serial --- + h.read("network.bmc", false, func() (string, error) { + ifaces, err := c.BMCNetworkInterfaces(ctx) + return fmt.Sprintf("%d BMC NICs", len(ifaces)), err + }) + h.read("network.server", false, func() (string, error) { + ifaces, err := c.ServerNetworkInterfaces(ctx) + return fmt.Sprintf("%d server NICs", len(ifaces)), err + }) + h.read("network.protocols", false, func() (string, error) { + ps, err := c.NetworkProtocols(ctx) + return fmt.Sprintf("%d services", len(ps)), err + }) + h.read("serial.read", false, func() (string, error) { + ifaces, err := c.SerialInterfaces(ctx) + return fmt.Sprintf("%d serial interfaces", len(ifaces)), err + }) + + // --- events / telemetry --- + h.read("event.service", false, func() (string, error) { + info, err := c.EventService(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("enabled=%t sse=%q", info.ServiceEnabled, info.SSEFilterURI), nil + }) + h.read("event.subscriptions", false, func() (string, error) { + subs, err := c.EventSubscriptions(ctx) + return fmt.Sprintf("%d subscriptions", len(subs)), err + }) + h.read("telemetry.service", false, func() (string, error) { + info, err := c.TelemetryService(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("enabled=%t maxReports=%d", info.ServiceEnabled, info.MaxReports), nil + }) + h.read("telemetry.reports", false, func() (string, error) { + ids, err := c.MetricReports(ctx) + return fmt.Sprintf("%d metric reports", len(ids)), err + }) + h.read("telemetry.definitions", false, func() (string, error) { + ids, err := c.MetricDefinitions(ctx) + return fmt.Sprintf("%d metric definitions", len(ids)), err + }) + + // --- jobs --- + h.read("job.service", false, func() (string, error) { + info, err := c.JobService(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("enabled=%t", info.ServiceEnabled), nil + }) + h.read("job.list", false, func() (string, error) { + jobs, err := c.Jobs(ctx) + return fmt.Sprintf("%d jobs", len(jobs)), err + }) +} + +// readCertSNMPFirmwareChecks runs the read-only checks for certificates, SNMP and the read-only firmware step plan. +func readCertSNMPFirmwareChecks(h *harness) { + ctx := h.ctx + c := h.conn + + // --- certificates --- + var firstCert string + h.read("cert.locations", false, func() (string, error) { + locs, err := c.CertificateLocations(ctx) + if err != nil { + return "", err + } + if len(locs) > 0 { + firstCert = locs[0] + } + return fmt.Sprintf("%d certificates", len(locs)), nil + }) + if firstCert != "" { + h.read("cert.read", false, func() (string, error) { + cert, err := c.Certificate(ctx, firstCert) + if err != nil { + return "", err + } + return fmt.Sprintf("subject=%q notAfter=%s", cert.SubjectCommonName, cert.ValidNotAfter), nil + }) + } else { + h.record("cert.read", statusSkip, "no certificate locations to read") + } + + // --- snmp --- + h.read("snmp.read", false, func() (string, error) { + cfg, err := c.SNMP(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("v1Trap=%t v3Trap=%t port=%d", cfg.V1TrapEnabled, cfg.V3TrapEnabled, cfg.TrapPort), nil + }) + + // --- firmware (read-only step plan) --- + h.read("firmware.steps", false, func() (string, error) { + steps, err := c.FirmwareInstallSteps(ctx, "bmc") + return fmt.Sprintf("%d install steps", len(steps)), err + }) +} + +// --------------------------------------------------------------------------- +// write checks +// --------------------------------------------------------------------------- + +// gate returns whether a mutating operation may run. An operation runs only when +// its own flag is set AND the master -allow-writes switch is on. +func (o *options) gate(flagSet bool) (enabled bool, reason string) { + switch { + case !flagSet: + return false, "flag not set" + case !o.allowWrites: + return false, "-allow-writes not set" + default: + return true, "" + } +} + +// runWriteChecks performs the gated mutating operations. With a default +// invocation (no -allow-writes) every check here is SKIPped. +func runWriteChecks(h *harness, o *options) { + ctx := h.ctx + c := h.conn + + enabled, reason := o.gate(o.power != "") + h.action("power.set", enabled, reason, func() error { + ok, err := c.PowerSet(ctx, o.power) + if err == nil && !ok { + return fmt.Errorf("power set returned ok=false") + } + return err + }) + + enabled, reason = o.gate(o.bootDevice != "") + h.action("boot.set", enabled, reason, func() error { + dev, perr := normalizeBootDevice(o.bootDevice) + if perr != nil { + return perr + } + ok, err := c.BootDeviceSet(ctx, dev, o.bootPersistent, o.bootEFI) + if err == nil && !ok { + return fmt.Errorf("boot device set returned ok=false") + } + return err + }) + + enabled, reason = o.gate(o.nmi) + h.action("power.nmi", enabled, reason, func() error { + return c.SendNMI(ctx) + }) + + enabled, reason = o.gate(o.bmcReset != "") + h.action("bmc.reset", enabled, reason, func() error { + ok, err := c.BmcReset(ctx, o.bmcReset) + if err == nil && !ok { + return fmt.Errorf("bmc reset returned ok=false") + } + return err + }) + + enabled, reason = o.gate(o.setBIOS != "") + h.action("bios.set", enabled, reason, func() error { + attrs, perr := parseKV(o.setBIOS) + if perr != nil { + return perr + } + // Pre-validate attribute names against this system's BIOS registry. XCC + // validates the PATCH atomically: a single unknown key fails the whole + // request with an opaque PropertyUnknown/InternalError 500. Catch it + // early with an actionable message (names are model/firmware-specific). + if cur, cerr := c.GetBiosConfiguration(ctx); cerr == nil && len(cur) > 0 { + var unknown []string + for k := range attrs { + if _, ok := cur[k]; !ok { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return fmt.Errorf("unknown BIOS attribute(s) %v on this system "+ + "(names are model-specific; run with -v and grep 'bios attr' for valid keys)", unknown) + } + } + return c.SetBiosConfiguration(ctx, attrs) + }) + + enabled, reason = o.gate(o.resetBIOS) + h.action("bios.reset", enabled, reason, func() error { + return c.ResetBiosConfiguration(ctx) + }) + + enabled, reason = o.gate(o.secureBoot != "") + h.action("secureboot.set", enabled, reason, func() error { + on, perr := parseEnable(o.secureBoot) + if perr != nil { + return perr + } + return c.SetSecureBoot(ctx, on) + }) + + enabled, reason = o.gate(o.powerCap != "") + h.action("power.cap", enabled, reason, func() error { + limit, perr := parsePowerCap(o.powerCap) + if perr != nil { + return perr + } + return c.SetPowerCap(ctx, limit) + }) + + writeMediaLogSNMPChecks(h, o) +} + +// writeMediaLogSNMPChecks runs the gated mutations for virtual media, the SEL, +// SNMP traps and firmware. +func writeMediaLogSNMPChecks(h *harness, o *options) { + ctx := h.ctx + c := h.conn + + enabled, reason := o.gate(o.vmediaURL != "") + h.action("vmedia.insert", enabled, reason, func() error { + ok, err := c.SetVirtualMedia(ctx, o.vmediaKind, o.vmediaURL) + if err == nil && !ok { + return fmt.Errorf("set virtual media returned ok=false") + } + return err + }) + + enabled, reason = o.gate(o.vmediaEject) + h.action("vmedia.eject", enabled, reason, func() error { + ok, err := c.SetVirtualMedia(ctx, o.vmediaKind, "") + if err == nil && !ok { + return fmt.Errorf("eject virtual media returned ok=false") + } + return err + }) + + enabled, reason = o.gate(o.clearSEL) + h.action("sel.clear", enabled, reason, func() error { + return c.ClearSystemEventLog(ctx) + }) + + enabled, reason = o.gate(o.snmpV1Trap != "") + h.action("snmp.v1trap", enabled, reason, func() error { + on, perr := parseEnable(o.snmpV1Trap) + if perr != nil { + return perr + } + return c.EnableSNMPv1Trap(ctx, on) + }) + + enabled, reason = o.gate(o.snmpV3Trap != "") + h.action("snmp.v3trap", enabled, reason, func() error { + on, perr := parseEnable(o.snmpV3Trap) + if perr != nil { + return perr + } + return c.EnableSNMPv3Trap(ctx, on) + }) + + runFirmwareCheck(h, o) +} + +// runFirmwareCheck performs the XCC firmware push (claim -> push -> poll task -> +// release) and polls the task to a terminal state. It is the key real-hardware +// validation for the firmware protocol variance. +func runFirmwareCheck(h *harness, o *options) { + enabled, reason := o.gate(o.firmwareFile != "") + if !enabled { + h.record("firmware.install", statusSkip, reason) + return + } + + f, err := os.Open(o.firmwareFile) + if err != nil { + h.record("firmware.install", statusFail, "open image: "+err.Error()) + return + } + defer func() { _ = f.Close() }() + + taskID, err := h.conn.FirmwareInstall(h.ctx, o.firmwareComponent, o.firmwareApplyTime, false, f) + if err != nil { + h.record("firmware.install", statusFail, "push: "+err.Error()) + return + } + h.record("firmware.install", statusPass, "pushed; taskID="+taskID) + + // Poll the Task resource to a terminal state. + for { + select { + case <-h.ctx.Done(): + h.record("firmware.task", statusFail, "timed out polling task "+taskID+": "+h.ctx.Err().Error()) + return + default: + } + + state, status, terr := h.conn.FirmwareTaskStatus(h.ctx, constants.FirmwareInstallStepInstallStatus, o.firmwareComponent, taskID, "") + if terr != nil { + h.record("firmware.task", statusFail, "poll: "+terr.Error()) + return + } + + fmt.Printf(" firmware task %s: state=%s %s\n", taskID, state, status) + + switch state { + case constants.Complete: + h.record("firmware.task", statusPass, "task completed: "+status) + return + case constants.Failed: + h.record("firmware.task", statusFail, "task failed: "+status) + return + } + + time.Sleep(o.firmwarePollEvery) + } +} + +// --------------------------------------------------------------------------- +// virtual-media boot flow (formerly the lenovo-vmedia-boot example) +// --------------------------------------------------------------------------- + +// runVMediaBoot performs the ordered virtual-media boot flow against the XCC: +// mount the image, set it as the next boot device, then power on. The order +// matters (insert -> set next boot -> power), which is why it is a dedicated +// mode rather than relying on the fixed order of runWriteChecks. Like every +// other mutation it is gated behind -allow-writes. +func runVMediaBoot(h *harness, o *options) { + ctx := h.ctx + c := h.conn + + if !o.allowWrites { + h.record("vmediaboot", statusSkip, "-allow-writes not set") + return + } + if o.vmediaURL == "" { + h.record("vmediaboot", statusFail, "-vmedia-boot requires -vmedia-url") + return + } + + // Step 1: mount the remote image into the virtual media slot. A non-empty + // URL inserts (ejecting anything already in the slot first); the + // TransferProtocolType is derived from the URL scheme. + h.action("vmediaboot.mount", true, "", func() error { + ok, err := c.SetVirtualMedia(ctx, o.vmediaKind, o.vmediaURL) + if err == nil && !ok { + return fmt.Errorf("set virtual media returned ok=false") + } + return err + }) + + // Step 2: set the next boot device to match the mounted media kind. With + // -bootdev-persistent it boots the media every time; otherwise it is a + // one-time override. -bootdev-efi selects UEFI vs legacy. + bootDevice := "cdrom" + switch o.vmediaKind { + case "Floppy": + bootDevice = "floppy" + case "USBStick": + bootDevice = "usb" + } + h.action("vmediaboot.bootdev", true, "", func() error { + ok, err := c.BootDeviceSet(ctx, bootDevice, o.bootPersistent, o.bootEFI) + if err == nil && !ok { + return fmt.Errorf("boot device set returned ok=false") + } + return err + }) + + // Step 3: power the server on (or cycle/reset). -power "" skips this. + if o.power == "" { + h.record("vmediaboot.power", statusSkip, "-power \"\" (pass -power on to boot now)") + return + } + h.action("vmediaboot.power", true, "", func() error { + ok, err := c.PowerSet(ctx, o.power) + if err == nil && !ok { + return fmt.Errorf("power %q returned ok=false", o.power) + } + return err + }) +} + +// --------------------------------------------------------------------------- +// parse helpers +// --------------------------------------------------------------------------- + +// parseKV parses "Key=Value,Key2=Value2" into a map. +func parseKV(s string) (map[string]string, error) { + out := map[string]string{} + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + k, v, ok := strings.Cut(pair, "=") + if !ok { + return nil, fmt.Errorf("invalid key=value pair: %q", pair) + } + out[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil, fmt.Errorf("no key=value pairs parsed from %q", s) + } + return out, nil +} + +// parseEnable parses "enable"/"disable" (and on/off, true/false) into a bool. +func parseEnable(s string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "enable", "enabled", "on", "true": + return true, nil + case "disable", "disabled", "off", "false": + return false, nil + default: + return false, fmt.Errorf("expected enable|disable, got %q", s) + } +} + +// parsePowerCap parses a watt value or "off" (nil => disable capping). +func parsePowerCap(s string) (*float64, error) { + if strings.EqualFold(strings.TrimSpace(s), "off") { + return nil, nil + } + var watts float64 + if _, err := fmt.Sscanf(strings.TrimSpace(s), "%f", &watts); err != nil { + return nil, fmt.Errorf("invalid power cap %q: %w", s, err) + } + return &watts, nil +} + +// validBootDevices are the bmclib boot-device names BootDeviceSet accepts. +var validBootDevices = []string{ + "bios", "cdrom", "diag", "floppy", "disk", "none", + "pxe", "remote_drive", "sd_card", "usb", "utilities", +} + +// normalizeBootDevice validates the -bootdev value against the bmclib set, +// mapping the common -vmedia-kind aliases (CD/DVD/USBStick) so users who reuse +// the virtual-media vocabulary get a working boot device instead of the opaque +// "invalid boot device" error from the wrapper. +func normalizeBootDevice(s string) (string, error) { + d := strings.ToLower(strings.TrimSpace(s)) + switch d { + case "cd", "dvd", "cdrom": + return "cdrom", nil + case "usbstick", "usb": + return "usb", nil + case "hdd", "harddisk": + return "disk", nil + } + for _, v := range validBootDevices { + if d == v { + return d, nil + } + } + return "", fmt.Errorf("invalid boot device %q; valid: %s", s, strings.Join(validBootDevices, " ")) +} diff --git a/providers/lenovo/bmc_management_test.go b/providers/lenovo/bmc_management_test.go index 3454017e..7de27f62 100644 --- a/providers/lenovo/bmc_management_test.go +++ b/providers/lenovo/bmc_management_test.go @@ -3,6 +3,8 @@ package lenovo import ( "context" "testing" + + "github.com/bmc-toolbox/bmclib/v2/bmc" ) // Requirement: BMC reset. @@ -31,3 +33,92 @@ func TestResetToFactoryDefaults(t *testing.T) { t.Error("expected a Manager.ResetToDefaults action") } } + +// Requirement: License management — read installed licenses. +func TestLicenses(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + licenses, err := c.Licenses(context.Background()) + if err != nil { + t.Fatalf("Licenses: %v", err) + } + if len(licenses) != 1 { + t.Fatalf("got %d licenses, want 1", len(licenses)) + } + if licenses[0].ID != "XCC_Advanced" || licenses[0].LicenseType != "Production" || !licenses[0].Removable { + t.Errorf("unexpected license: %+v", licenses[0]) + } +} + +// Requirement: License management — an absent LicenseService (404 on firmware +// levels without it) is reported as "no licenses", not an error. +func TestLicensesNotFound(t *testing.T) { + ts := newTestServer(t, testServerOpts{licenseServiceNotFound: true}) + c := ts.openedClient(t) + + licenses, err := c.Licenses(context.Background()) + if err != nil { + t.Fatalf("Licenses on a box without LicenseService should not error, got: %v", err) + } + if len(licenses) != 0 { + t.Fatalf("got %d licenses, want 0", len(licenses)) + } +} + +// Requirement: License management — install a license. +func TestLicenseInstall(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.LicenseInstall(context.Background(), "QUJDREVGRw=="); err != nil { + t.Fatalf("LicenseInstall: %v", err) + } + if !ts.didInstallLicense() { + t.Error("expected a POST to the License collection") + } +} + +// Requirement: License management — delete a license. +func TestLicenseDelete(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.LicenseDelete(context.Background(), "XCC_Advanced"); err != nil { + t.Fatalf("LicenseDelete: %v", err) + } + if !ts.didDeleteLicense() { + t.Error("expected a DELETE of the license") + } +} + +// Requirement: Secure Key Lifecycle — read properties. +func TestGetSecureKeyLifecycle(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + cfg, err := c.GetSecureKeyLifecycle(context.Background()) + if err != nil { + t.Fatalf("GetSecureKeyLifecycle: %v", err) + } + if cfg.DeviceGroup != "TKLM_DEV_GROUP" { + t.Errorf("DeviceGroup = %q, want %q", cfg.DeviceGroup, "TKLM_DEV_GROUP") + } + if len(cfg.KeyRepoServers) != 2 || cfg.KeyRepoServers[0].Port != 5696 { + t.Errorf("unexpected key repo servers: %+v", cfg.KeyRepoServers) + } +} + +// Requirement: Secure Key Lifecycle — update key servers. +func TestSetSecureKeyRepoServers(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + servers := []bmc.SecureKeyRepoServer{{HostName: "10.0.0.20", Port: 5696}} + if err := c.SetSecureKeyRepoServers(context.Background(), servers); err != nil { + t.Fatalf("SetSecureKeyRepoServers: %v", err) + } + if !ts.didPatchSKLM() { + t.Error("expected a PATCH of the SecureKeyLifecycleService") + } +} diff --git a/providers/lenovo/certificates.go b/providers/lenovo/certificates.go new file mode 100644 index 00000000..757bb745 --- /dev/null +++ b/providers/lenovo/certificates.go @@ -0,0 +1,155 @@ +package lenovo + +import ( + "context" + "net/http" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +const ( + certificateServiceURI = "/redfish/v1/CertificateService" + certificateLocationsURI = certificateServiceURI + "/CertificateLocations" + generateCSRURI = certificateServiceURI + "/Actions/CertificateService.GenerateCSR" + replaceCertificateURI = certificateServiceURI + "/Actions/CertificateService.ReplaceCertificate" + // defaultCertificateCollection is the XCC BMC HTTPS certificate collection. + defaultCertificateCollection = "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.CertificateManager = (*Conn)(nil) + +// CertificateLocations returns the @odata.id locations of installed certificates. +// +// Implements bmc.CertificateManager. +func (c *Conn) CertificateLocations(ctx context.Context) ([]string, error) { + var doc struct { + Links struct { + Certificates []odataID `json:"Certificates"` + } `json:"Links"` + } + if err := c.getJSON(certificateLocationsURI, &doc); err != nil { + return nil, err + } + + out := make([]string, 0, len(doc.Links.Certificates)) + for _, cert := range doc.Links.Certificates { + out = append(out, cert.ODataID) + } + + return out, nil +} + +// Certificate returns a certificate's properties by its @odata.id location. +// +// Implements bmc.CertificateManager. +func (c *Conn) Certificate(ctx context.Context, location string) (bmc.CertificateInfo, error) { + var doc struct { + ID string `json:"Id"` + Subject struct { + CommonName string `json:"CommonName"` + } `json:"Subject"` + Issuer struct { + CommonName string `json:"CommonName"` + } `json:"Issuer"` + ValidNotBefore string `json:"ValidNotBefore"` + ValidNotAfter string `json:"ValidNotAfter"` + } + if err := c.getJSON(location, &doc); err != nil { + return bmc.CertificateInfo{}, err + } + + return bmc.CertificateInfo{ + ID: doc.ID, + SubjectCommonName: doc.Subject.CommonName, + IssuerCommonName: doc.Issuer.CommonName, + ValidNotBefore: doc.ValidNotBefore, + ValidNotAfter: doc.ValidNotAfter, + }, nil +} + +// GenerateCSR generates a CSR via CertificateService.GenerateCSR and returns the +// PEM CSR string. +// +// Implements bmc.CertificateManager. +func (c *Conn) GenerateCSR(ctx context.Context, req *bmc.CSRRequest) (string, error) { + collection := req.CertificateCollection + if collection == "" { + collection = defaultCertificateCollection + } + + payload := map[string]any{ + "CommonName": req.CommonName, + "CertificateCollection": map[string]string{"@odata.id": collection}, + } + addIfSet(payload, "Country", req.Country) + addIfSet(payload, "State", req.State) + addIfSet(payload, "City", req.City) + addIfSet(payload, "Organization", req.Organization) + addIfSet(payload, "OrganizationalUnit", req.OrganizationalUnit) + addIfSet(payload, "Email", req.Email) + + resp, err := c.redfishwrapper.PostWithHeaders(ctx, generateCSRURI, payload, nil) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", parseRedfishError(resp) + } + + var out struct { + CSRString string `json:"CSRString"` + } + if err := decodeJSONBody(resp, &out); err != nil { + return "", err + } + + return out.CSRString, nil +} + +// ReplaceCertificate replaces the certificate at targetURI with the given PEM. +// +// Implements bmc.CertificateManager. +func (c *Conn) ReplaceCertificate(ctx context.Context, certificatePEM, targetURI string) error { + payload := map[string]any{ + "CertificateString": certificatePEM, + "CertificateType": "PEM", + "CertificateUri": map[string]string{"@odata.id": targetURI}, + } + + return c.postChecked(ctx, replaceCertificateURI, payload) +} + +// RekeyCertificate generates a new key pair and CSR for the certificate at +// certURI via Certificate.Rekey. +// +// Implements bmc.CertificateManager. +func (c *Conn) RekeyCertificate(ctx context.Context, certURI string) error { + target, err := url.JoinPath(certURI, "Actions/Certificate.Rekey") + if err != nil { + return err + } + return c.postChecked(ctx, target, map[string]any{}) +} + +// RenewCertificate generates a CSR using the existing key for the certificate at +// certURI via Certificate.Renew. +// +// Implements bmc.CertificateManager. +func (c *Conn) RenewCertificate(ctx context.Context, certURI string) error { + target, err := url.JoinPath(certURI, "Actions/Certificate.Renew") + if err != nil { + return err + } + return c.postChecked(ctx, target, map[string]any{}) +} + +// addIfSet adds k=v to m when v is non-empty. +func addIfSet(m map[string]any, k, v string) { + if v != "" { + m[k] = v + } +} diff --git a/providers/lenovo/errors.go b/providers/lenovo/errors.go index 3a15d2bb..c2452b38 100644 --- a/providers/lenovo/errors.go +++ b/providers/lenovo/errors.go @@ -1,6 +1,7 @@ package lenovo import ( + "context" "encoding/json" "errors" "fmt" @@ -123,3 +124,37 @@ func redfishErrorDetail(body []byte) string { return "" } } + +// postChecked POSTs payload to uri and reduces the response to an error: nil on +// a 2xx status, the parsed Redfish error otherwise. The body is always closed. +func (c *Conn) postChecked(ctx context.Context, uri string, payload any) error { + resp, err := c.redfishwrapper.PostWithHeaders(ctx, uri, payload, nil) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + return checkResponse(resp, nil) +} + +// patchChecked is postChecked for PATCH. +func (c *Conn) patchChecked(ctx context.Context, uri string, payload any) error { + resp, err := c.redfishwrapper.PatchWithHeaders(ctx, uri, payload, nil) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + return checkResponse(resp, nil) +} + +// deleteChecked DELETEs uri and reduces the response the same way. +func (c *Conn) deleteChecked(uri string) error { + resp, err := c.redfishwrapper.Delete(uri) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + return checkResponse(resp, nil) +} diff --git a/providers/lenovo/events.go b/providers/lenovo/events.go new file mode 100644 index 00000000..a094ac5c --- /dev/null +++ b/providers/lenovo/events.go @@ -0,0 +1,140 @@ +package lenovo + +import ( + "context" + "net/http" + "net/url" + "path" + "strings" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +const ( + eventServiceURI = "/redfish/v1/EventService" + eventSubscriptionsURI = eventServiceURI + "/Subscriptions" + submitTestEventURI = eventServiceURI + "/Actions/EventService.SubmitTestEvent" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.EventSubscriber = (*Conn)(nil) + +// EventService returns the XCC event-service configuration. +// +// Implements bmc.EventSubscriber. +func (c *Conn) EventService(ctx context.Context) (bmc.EventServiceInfo, error) { + var doc struct { + ServiceEnabled bool `json:"ServiceEnabled"` + DeliveryRetryAttempts int `json:"DeliveryRetryAttempts"` + DeliveryRetryIntervalSeconds int `json:"DeliveryRetryIntervalSeconds"` + ServerSentEventURI string `json:"ServerSentEventUri"` + } + if err := c.getJSON(eventServiceURI, &doc); err != nil { + return bmc.EventServiceInfo{}, err + } + + return bmc.EventServiceInfo{ + ServiceEnabled: doc.ServiceEnabled, + DeliveryRetryAttempts: doc.DeliveryRetryAttempts, + DeliveryRetryIntervalSeconds: doc.DeliveryRetryIntervalSeconds, + SSEFilterURI: doc.ServerSentEventURI, + }, nil +} + +// EventSubscriptions lists the XCC event subscriptions. +// +// Implements bmc.EventSubscriber. +func (c *Conn) EventSubscriptions(ctx context.Context) ([]bmc.EventSubscription, error) { + members, err := c.collectionMembers(eventSubscriptionsURI) + if err != nil { + return nil, err + } + + out := make([]bmc.EventSubscription, 0, len(members)) + for _, m := range members { + var sub struct { + ID string `json:"Id"` + Destination string `json:"Destination"` + Protocol string `json:"Protocol"` + Context string `json:"Context"` + } + if err := c.getJSON(m.ODataID, &sub); err != nil { + return nil, err + } + out = append(out, bmc.EventSubscription{ + ID: sub.ID, + Destination: sub.Destination, + Protocol: sub.Protocol, + Context: sub.Context, + }) + } + + return out, nil +} + +// EventSubscriptionCreate creates a subscription and returns its Id. +// +// Implements bmc.EventSubscriber. +func (c *Conn) EventSubscriptionCreate(ctx context.Context, req bmc.EventSubscriptionRequest) (string, error) { + protocol := req.Protocol + if protocol == "" { + protocol = "Redfish" + } + + payload := map[string]any{ + "Destination": req.Destination, + "Protocol": protocol, + } + if req.Context != "" { + payload["Context"] = req.Context + } + if len(req.RegistryPrefixes) > 0 { + payload["RegistryPrefixes"] = req.RegistryPrefixes + } + + resp, err := c.redfishwrapper.PostWithHeaders(ctx, eventSubscriptionsURI, payload, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", parseRedfishError(resp) + } + + if loc := resp.Header.Get("Location"); loc != "" { + return path.Base(strings.TrimRight(loc, "/")), nil + } + + return "", nil +} + +// EventSubscriptionDelete deletes a subscription by Id. +// +// Implements bmc.EventSubscriber. +func (c *Conn) EventSubscriptionDelete(ctx context.Context, id string) error { + target, err := url.JoinPath(eventSubscriptionsURI, id) + if err != nil { + return err + } + return checkResponse(c.redfishwrapper.Delete(target)) +} + +// SubmitTestEvent submits a test event via EventService.SubmitTestEvent. +// +// Implements bmc.EventSubscriber. +func (c *Conn) SubmitTestEvent(ctx context.Context, messageID string) error { + payload := map[string]any{} + if messageID != "" { + payload["MessageId"] = messageID + } + + return checkResponse(c.redfishwrapper.PostWithHeaders(ctx, submitTestEventURI, payload, nil)) +} + +// SetEventService PATCHes the event-service properties. +// +// Implements bmc.EventSubscriber. +func (c *Conn) SetEventService(ctx context.Context, attrs map[string]any) error { + return checkResponse(c.redfishwrapper.PatchWithHeaders(ctx, eventServiceURI, attrs, nil)) +} diff --git a/providers/lenovo/events_telemetry_test.go b/providers/lenovo/events_telemetry_test.go new file mode 100644 index 00000000..99f63f6e --- /dev/null +++ b/providers/lenovo/events_telemetry_test.go @@ -0,0 +1,164 @@ +package lenovo + +import ( + "context" + "testing" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// Requirement: Read event service. +func TestEventService(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + info, err := c.EventService(context.Background()) + if err != nil { + t.Fatalf("EventService: %v", err) + } + if !info.ServiceEnabled || info.DeliveryRetryAttempts != 3 { + t.Errorf("unexpected event service: %+v", info) + } + if info.SSEFilterURI == "" { + t.Error("expected an SSE URI") + } +} + +// Requirement: List subscriptions. +func TestEventSubscriptions(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + subs, err := c.EventSubscriptions(context.Background()) + if err != nil { + t.Fatalf("EventSubscriptions: %v", err) + } + if len(subs) != 1 || subs[0].Destination == "" { + t.Fatalf("unexpected subscriptions: %+v", subs) + } +} + +// Requirement: Create a webhook subscription. +func TestEventSubscriptionCreate(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + id, err := c.EventSubscriptionCreate(context.Background(), bmc.EventSubscriptionRequest{ + Destination: "https://listener.example.com/hook", + Context: "ctx", + }) + if err != nil { + t.Fatalf("EventSubscriptionCreate: %v", err) + } + if id != "2" { + t.Errorf("subscription id = %q, want %q", id, "2") + } + if !ts.didCreateSubscription() { + t.Error("expected a POST to the subscriptions collection") + } +} + +// Requirement: Delete a subscription. +func TestEventSubscriptionDelete(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.EventSubscriptionDelete(context.Background(), "1"); err != nil { + t.Fatalf("EventSubscriptionDelete: %v", err) + } + if !ts.didDeleteSubscription() { + t.Error("expected a DELETE of the subscription") + } +} + +// Requirement: Submit a test event. +func TestSubmitTestEvent(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.SubmitTestEvent(context.Background(), "Base.1.0.TestMessage"); err != nil { + t.Fatalf("SubmitTestEvent: %v", err) + } + if !ts.didSubmitTestEvent() { + t.Error("expected a SubmitTestEvent action") + } +} + +// Requirement: Configure event service. +func TestSetEventService(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.SetEventService(context.Background(), map[string]any{"DeliveryRetryAttempts": 5}); err != nil { + t.Fatalf("SetEventService: %v", err) + } + if !ts.didPatchEventService() { + t.Error("expected a PATCH of the EventService") + } +} + +// Requirement: Read telemetry service. +func TestTelemetryService(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + info, err := c.TelemetryService(context.Background()) + if err != nil { + t.Fatalf("TelemetryService: %v", err) + } + if !info.ServiceEnabled || info.MaxReports != 10 { + t.Errorf("unexpected telemetry service: %+v", info) + } +} + +// Requirement: Read a metric report. +func TestMetricReport(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + ids, err := c.MetricReports(context.Background()) + if err != nil { + t.Fatalf("MetricReports: %v", err) + } + if len(ids) != 1 || ids[0] != "PowerMetrics" { + t.Fatalf("unexpected report ids: %v", ids) + } + + report, err := c.MetricReport(context.Background(), "PowerMetrics") + if err != nil { + t.Fatalf("MetricReport: %v", err) + } + // XCC identifies each reading by MetricProperty (MetricId is typically empty). + if len(report.Values) != 2 || + report.Values[0].MetricProperty != "/redfish/v1/Chassis/1/Power#/PowerControl/0/PowerMetrics/MaxConsumedWatts" || + report.Values[0].Value != "287" { + t.Errorf("unexpected report values: %+v", report.Values) + } +} + +// Requirement: List metric definitions. +func TestMetricDefinitions(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + defs, err := c.MetricDefinitions(context.Background()) + if err != nil { + t.Fatalf("MetricDefinitions: %v", err) + } + if len(defs) != 2 { + t.Fatalf("got %d metric definitions, want 2", len(defs)) + } +} + +// Requirement: Submit a test metric report. +func TestSubmitTestMetricReport(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.SubmitTestMetricReport(context.Background(), "PowerMetrics"); err != nil { + t.Fatalf("SubmitTestMetricReport: %v", err) + } + if !ts.didSubmitTestMetric() { + t.Error("expected a SubmitTestMetricReport action") + } +} diff --git a/providers/lenovo/inventory_storage_test.go b/providers/lenovo/inventory_storage_test.go new file mode 100644 index 00000000..e2852fe9 --- /dev/null +++ b/providers/lenovo/inventory_storage_test.go @@ -0,0 +1,161 @@ +package lenovo + +import ( + "context" + "testing" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// Requirement: Aggregate hardware and firmware inventory + partial-failure +// tolerance (default failInventoryOnError=false). +func TestInventory(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + device, err := c.Inventory(context.Background()) + if err != nil { + t.Fatalf("Inventory: %v", err) + } + if device == nil { + t.Fatal("Inventory returned a nil device") + } +} + +// Requirement: Partial-failure tolerance — fail-fast mode surfaces the error. +// +// With FailInventoryOnError set and the mock not serving UpdateService, the +// first sub-resource read fails and Inventory returns an error. +func TestInventoryFailFast(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t, WithFailInventoryOnError(true)) + + if _, err := c.Inventory(context.Background()); err == nil { + t.Fatal("expected Inventory to fail fast when a sub-resource is unavailable") + } +} + +// Requirement: Storage pool and controller reads. +func TestStorageControllers(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + controllers, err := c.StorageControllers(context.Background()) + if err != nil { + t.Fatalf("StorageControllers: %v", err) + } + if len(controllers) != 1 { + t.Fatalf("got %d controllers, want 1", len(controllers)) + } + got := controllers[0] + if got.ID != "RAID_Slot1" { + t.Errorf("controller ID = %q, want %q", got.ID, "RAID_Slot1") + } + if got.DriveCount != 2 { + t.Errorf("DriveCount = %d, want 2", got.DriveCount) + } + if got.Model == "" { + t.Error("controller Model is empty") + } +} + +// Requirement: List volumes of a controller. +func TestVolumes(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + volumes, err := c.Volumes(context.Background(), "RAID_Slot1") + if err != nil { + t.Fatalf("Volumes: %v", err) + } + if len(volumes) != 1 { + t.Fatalf("got %d volumes, want 1", len(volumes)) + } + if volumes[0].RAIDType != "RAID1" { + t.Errorf("RAIDType = %q, want %q", volumes[0].RAIDType, "RAID1") + } + if volumes[0].CapacityBytes != 999653638144 { + t.Errorf("CapacityBytes = %d, want 999653638144", volumes[0].CapacityBytes) + } +} + +// Requirement: Volume creation. +func TestVolumeCreate(t *testing.T) { + t.Run("create returns the new volume id", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + id, err := c.VolumeCreate(context.Background(), "RAID_Slot1", bmcVolumeReq()) + if err != nil { + t.Fatalf("VolumeCreate: %v", err) + } + if id != "2" { + t.Errorf("new volume id = %q, want %q", id, "2") + } + if !ts.didCreateVolume() { + t.Fatal("expected a POST to the Volumes collection") + } + }) + + t.Run("unknown controller errors", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if _, err := c.VolumeCreate(context.Background(), "NoSuchController", bmcVolumeReq()); err == nil { + t.Fatal("expected an error for an unknown controller") + } + }) +} + +// Requirement: Volume initialize, update, delete. +func TestVolumeLifecycleActions(t *testing.T) { + t.Run("initialize", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.VolumeInitialize(context.Background(), "RAID_Slot1", "1", "Fast"); err != nil { + t.Fatalf("VolumeInitialize: %v", err) + } + if !ts.didInitializeVolume() { + t.Fatal("expected the Volume.Initialize action to be posted") + } + }) + + t.Run("update", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.VolumeUpdate(context.Background(), "RAID_Slot1", "1", map[string]any{"Encrypted": true}); err != nil { + t.Fatalf("VolumeUpdate: %v", err) + } + if !ts.didUpdateVolume() { + t.Fatal("expected the Volume to be PATCHed") + } + }) + + t.Run("delete", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.VolumeDelete(context.Background(), "RAID_Slot1", "1"); err != nil { + t.Fatalf("VolumeDelete: %v", err) + } + if !ts.didDeleteVolume() { + t.Fatal("expected the Volume to be DELETEd") + } + }) + + t.Run("unknown volume errors", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.VolumeDelete(context.Background(), "RAID_Slot1", "999"); err == nil { + t.Fatal("expected an error for an unknown volume") + } + }) +} + +func bmcVolumeReq() bmc.VolumeCreateRequest { + return bmc.VolumeCreateRequest{ + Name: "newvol", + RAIDType: "RAID0", + CapacityBytes: 1000000000, + Drives: []string{"/redfish/v1/Systems/1/Storage/RAID_Slot1/Drives/Disk.0"}, + } +} diff --git a/providers/lenovo/inventory_test.go b/providers/lenovo/inventory_test.go deleted file mode 100644 index b80a7060..00000000 --- a/providers/lenovo/inventory_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package lenovo - -import ( - "context" - "testing" -) - -// Requirement: Aggregate hardware and firmware inventory + partial-failure -// tolerance (default failInventoryOnError=false). -func TestInventory(t *testing.T) { - ts := newTestServer(t, testServerOpts{}) - c := ts.openedClient(t) - - device, err := c.Inventory(context.Background()) - if err != nil { - t.Fatalf("Inventory: %v", err) - } - if device == nil { - t.Fatal("Inventory returned a nil device") - } -} - -// Requirement: Partial-failure tolerance — fail-fast mode surfaces the error. -// -// With FailInventoryOnError set and the mock not serving UpdateService, the -// first sub-resource read fails and Inventory returns an error. -func TestInventoryFailFast(t *testing.T) { - ts := newTestServer(t, testServerOpts{}) - c := ts.openedClient(t, WithFailInventoryOnError(true)) - - if _, err := c.Inventory(context.Background()); err == nil { - t.Fatal("expected Inventory to fail fast when a sub-resource is unavailable") - } -} diff --git a/providers/lenovo/jobs.go b/providers/lenovo/jobs.go new file mode 100644 index 00000000..cd153bea --- /dev/null +++ b/providers/lenovo/jobs.go @@ -0,0 +1,96 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +const ( + jobServiceURI = "/redfish/v1/JobService" + jobsURI = jobServiceURI + "/Jobs" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.JobManager = (*Conn)(nil) + +// JobService returns the XCC job-service configuration. +// +// Implements bmc.JobManager. +func (c *Conn) JobService(ctx context.Context) (bmc.JobServiceInfo, error) { + var doc struct { + ServiceEnabled bool `json:"ServiceEnabled"` + } + if err := c.getJSON(jobServiceURI, &doc); err != nil { + return bmc.JobServiceInfo{}, err + } + + return bmc.JobServiceInfo{ServiceEnabled: doc.ServiceEnabled}, nil +} + +// Jobs lists the XCC jobs. +// +// Implements bmc.JobManager. +func (c *Conn) Jobs(ctx context.Context) ([]bmc.JobInfo, error) { + members, err := c.collectionMembers(jobsURI) + if err != nil { + return nil, err + } + + out := make([]bmc.JobInfo, 0, len(members)) + for _, m := range members { + job, err := c.jobAt(m.ODataID) + if err != nil { + return nil, err + } + out = append(out, job) + } + + return out, nil +} + +// Job returns a job by Id. +// +// Implements bmc.JobManager. +func (c *Conn) Job(ctx context.Context, id string) (bmc.JobInfo, error) { + target, err := url.JoinPath(jobsURI, id) + if err != nil { + return bmc.JobInfo{}, err + } + return c.jobAt(target) +} + +// JobUpdateSchedule PATCHes a job's Schedule. +// +// Implements bmc.JobManager. +func (c *Conn) JobUpdateSchedule(ctx context.Context, id string, schedule map[string]any) error { + target, err := url.JoinPath(jobsURI, id) + if err != nil { + return err + } + payload := map[string]any{"Schedule": schedule} + return checkResponse(c.redfishwrapper.PatchWithHeaders(ctx, target, payload, nil)) +} + +// jobAt reads a single job resource. +func (c *Conn) jobAt(url string) (bmc.JobInfo, error) { + var doc struct { + ID string `json:"Id"` + Name string `json:"Name"` + JobState string `json:"JobState"` + PercentComplete int `json:"PercentComplete"` + StartTime string `json:"StartTime"` + } + if err := c.getJSON(url, &doc); err != nil { + return bmc.JobInfo{}, err + } + + return bmc.JobInfo{ + ID: doc.ID, + Name: doc.Name, + JobState: doc.JobState, + PercentComplete: doc.PercentComplete, + StartTime: doc.StartTime, + }, nil +} diff --git a/providers/lenovo/jobs_certs_snmp_test.go b/providers/lenovo/jobs_certs_snmp_test.go new file mode 100644 index 00000000..3ed06346 --- /dev/null +++ b/providers/lenovo/jobs_certs_snmp_test.go @@ -0,0 +1,199 @@ +package lenovo + +import ( + "context" + "strings" + "testing" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// Requirement: Read job service. +func TestJobService(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + info, err := c.JobService(context.Background()) + if err != nil { + t.Fatalf("JobService: %v", err) + } + if !info.ServiceEnabled { + t.Error("expected the job service to be enabled") + } +} + +// Requirement: Read a job. +func TestJob(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + job, err := c.Job(context.Background(), "Restart") + if err != nil { + t.Fatalf("Job: %v", err) + } + if job.ID != "Restart" || job.JobState != "Scheduled" { + t.Errorf("unexpected job: %+v", job) + } +} + +// Requirement: Update a job schedule. +func TestJobUpdateSchedule(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.JobUpdateSchedule(context.Background(), "Restart", map[string]any{"RecurrenceInterval": "P1D"}); err != nil { + t.Fatalf("JobUpdateSchedule: %v", err) + } + if !ts.didUpdateJobSchedule() { + t.Error("expected a PATCH of the job schedule") + } +} + +// Requirement: Unknown job errors. +func TestJobUnknown(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if _, err := c.Job(context.Background(), "DoesNotExist"); err == nil { + t.Fatal("expected an error for an unknown job") + } +} + +// Requirement: Read certificate locations. +func TestCertificateLocations(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + locs, err := c.CertificateLocations(context.Background()) + if err != nil { + t.Fatalf("CertificateLocations: %v", err) + } + if len(locs) != 1 || !strings.Contains(locs[0], "Certificates/1") { + t.Fatalf("unexpected locations: %v", locs) + } +} + +// Requirement: Read a certificate. +func TestCertificate(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + cert, err := c.Certificate(context.Background(), "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/1") + if err != nil { + t.Fatalf("Certificate: %v", err) + } + if cert.SubjectCommonName != "XCC-7Z60-SN" || cert.ValidNotAfter == "" { + t.Errorf("unexpected certificate: %+v", cert) + } +} + +// Requirement: Generate a CSR. +func TestGenerateCSR(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + csr, err := c.GenerateCSR(context.Background(), &bmc.CSRRequest{CommonName: "XCC-test", Country: "US"}) + if err != nil { + t.Fatalf("GenerateCSR: %v", err) + } + if !strings.Contains(csr, "BEGIN CERTIFICATE REQUEST") { + t.Errorf("unexpected CSR: %q", csr) + } + if !ts.didGenerateCSR() { + t.Error("expected a GenerateCSR action") + } +} + +// Requirement: Replace a certificate. +func TestReplaceCertificate(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + err := c.ReplaceCertificate(context.Background(), "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----", + "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/1") + if err != nil { + t.Fatalf("ReplaceCertificate: %v", err) + } + if !ts.didReplaceCertificate() { + t.Error("expected a ReplaceCertificate action") + } +} + +// Requirement: Rekey and renew a certificate. +func TestRekeyRenewCertificate(t *testing.T) { + certURI := "/redfish/v1/Managers/1/NetworkProtocol/HTTPS/Certificates/1" + + t.Run("rekey", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.RekeyCertificate(context.Background(), certURI); err != nil { + t.Fatalf("RekeyCertificate: %v", err) + } + if !ts.didRekeyCertificate() { + t.Error("expected a Certificate.Rekey action") + } + }) + + t.Run("renew", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.RenewCertificate(context.Background(), certURI); err != nil { + t.Fatalf("RenewCertificate: %v", err) + } + if !ts.didRenewCertificate() { + t.Error("expected a Certificate.Renew action") + } + }) +} + +// Requirement: Read SNMP config. +func TestSNMP(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + cfg, err := c.SNMP(context.Background()) + if err != nil { + t.Fatalf("SNMP: %v", err) + } + // CommunityNames lives at the top level of the XCC OEM SNMP resource (not + // inside SNMPTraps); assert it is read from there. + if cfg.TrapPort != 162 || len(cfg.CommunityNames) != 1 || cfg.CommunityNames[0] != "public" { + t.Errorf("unexpected SNMP config: %+v", cfg) + } +} + +// Requirement: Configure the alert filter + enable v1/v3 traps. +func TestSNMPConfigure(t *testing.T) { + t.Run("alert filter", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.SetSNMPAlertFilter(context.Background(), map[string]any{"SNMPv1TrapEnabled": true}); err != nil { + t.Fatalf("SetSNMPAlertFilter: %v", err) + } + if !ts.didPatchSNMP() { + t.Error("expected a PATCH of the SNMP resource") + } + }) + + t.Run("enable v1 trap", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.EnableSNMPv1Trap(context.Background(), true); err != nil { + t.Fatalf("EnableSNMPv1Trap: %v", err) + } + if !ts.didPatchSNMP() { + t.Error("expected a PATCH enabling the SNMPv1 trap") + } + }) + + t.Run("enable v3 trap", func(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + if err := c.EnableSNMPv3Trap(context.Background(), true); err != nil { + t.Fatalf("EnableSNMPv3Trap: %v", err) + } + if !ts.didPatchSNMP() { + t.Error("expected a PATCH enabling the SNMPv3 trap") + } + }) +} diff --git a/providers/lenovo/lenovo.go b/providers/lenovo/lenovo.go index 963017a3..60f26df5 100644 --- a/providers/lenovo/lenovo.go +++ b/providers/lenovo/lenovo.go @@ -82,8 +82,13 @@ var Features = registrar.Features{ providers.FeatureResetSecureBootKeys, providers.FeatureResetSecureBootDatabaseKeys, providers.FeatureImportSecureBootCertificate, + providers.FeatureThermalRead, + providers.FeaturePowerRead, + providers.FeaturePowerCap, // inventory-storage providers.FeatureInventoryRead, + providers.FeatureVolumeRead, + providers.FeatureVolumeManagement, // firmware-tasks providers.FeatureFirmwareInstall, providers.FeatureFirmwareInstallStatus, @@ -106,6 +111,22 @@ var Features = registrar.Features{ providers.FeatureClearSystemEventLog, // bmc-management providers.FeatureBmcReset, + providers.FeatureLicenseManagement, + providers.FeatureSecureKeyLifecycle, + // network-serial + providers.FeatureNetworkInterfaceRead, + providers.FeatureNetworkInterfaceSet, + providers.FeatureNetworkProtocolRead, + providers.FeatureNetworkProtocolSet, + providers.FeatureSerialRead, + providers.FeatureSerialSet, + // events-telemetry + providers.FeatureEventSubscription, + providers.FeatureTelemetry, + // jobs-certs-snmp + providers.FeatureJobManagement, + providers.FeatureCertificateManagement, + providers.FeatureSNMP, } // Conn is a connection to a Lenovo XCC BMC. diff --git a/providers/lenovo/license.go b/providers/lenovo/license.go new file mode 100644 index 00000000..d5a36ba1 --- /dev/null +++ b/providers/lenovo/license.go @@ -0,0 +1,85 @@ +package lenovo + +import ( + "context" + "errors" + "fmt" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// licensesURI is the XCC License collection. +const licensesURI = "/redfish/v1/LicenseService/Licenses" + +// compile-time assertion that the provider implements the interface. +var _ bmc.LicenseManager = (*Conn)(nil) + +// Licenses returns the installed XCC licenses. +// +// Implements bmc.LicenseManager. +func (c *Conn) Licenses(ctx context.Context) ([]bmc.LicenseInfo, error) { + var collection struct { + Members []odataID `json:"Members"` + } + if err := c.getJSON(licensesURI, &collection); err != nil { + // Some XCC firmware levels do not expose the LicenseService collection + // and return 404 here. Treat an absent service as "no licenses" rather + // than failing the read. TODO: resolve the per-firmware LicenseService + // path variance (see examples/lenovo-smoketest/HARDWARE-TEST-PLAN.md + // TC-BMC-4); other errors still propagate. + if errors.Is(err, errResourceNotFound) { + return []bmc.LicenseInfo{}, nil + } + return nil, err + } + + out := make([]bmc.LicenseInfo, 0, len(collection.Members)) + for _, m := range collection.Members { + var lic struct { + ID string `json:"Id"` + EntitlementID string `json:"EntitlementId"` + LicenseType string `json:"LicenseType"` + Removable bool `json:"Removable"` + Status struct { + State string `json:"State"` + } `json:"Status"` + } + if err := c.getJSON(m.ODataID, &lic); err != nil { + return nil, err + } + + out = append(out, bmc.LicenseInfo{ + ID: lic.ID, + EntitlementID: lic.EntitlementID, + LicenseType: lic.LicenseType, + State: lic.Status.State, + Removable: lic.Removable, + }) + } + + return out, nil +} + +// LicenseInstall installs a license by POSTing its license string to the +// License collection. +// +// Implements bmc.LicenseManager. +func (c *Conn) LicenseInstall(ctx context.Context, license string) error { + payload := map[string]any{"LicenseString": license} + return c.postChecked(ctx, licensesURI, payload) +} + +// LicenseDelete removes an installed license by Id. +// +// Implements bmc.LicenseManager. +func (c *Conn) LicenseDelete(ctx context.Context, licenseID string) error { + if licenseID == "" { + return fmt.Errorf("license id is required") + } + target, err := url.JoinPath(licensesURI, licenseID) + if err != nil { + return err + } + return c.deleteChecked(target) +} diff --git a/providers/lenovo/main_test.go b/providers/lenovo/main_test.go index 7c2dfb55..20f5b990 100644 --- a/providers/lenovo/main_test.go +++ b/providers/lenovo/main_test.go @@ -704,6 +704,36 @@ func (ts *testServer) didPatchBios() bool { return ts.biosPatched } +func (ts *testServer) didPatchPower() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.powerPatched +} + +func (ts *testServer) didCreateVolume() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.volumeCreated +} + +func (ts *testServer) didInitializeVolume() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.volumeInitialized +} + +func (ts *testServer) didUpdateVolume() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.volumeUpdated +} + +func (ts *testServer) didDeleteVolume() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.volumeDeleted +} + func (ts *testServer) didClaimBusy() bool { ts.mu.Lock() defer ts.mu.Unlock() @@ -793,3 +823,111 @@ func (ts *testServer) didFactoryReset() bool { defer ts.mu.Unlock() return ts.factoryReset } + +func (ts *testServer) didInstallLicense() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.licenseInstalled +} + +func (ts *testServer) didDeleteLicense() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.licenseDeleted +} + +func (ts *testServer) didPatchSKLM() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.sklmPatched +} + +func (ts *testServer) didPatchBMCEth() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.bmcEthPatched +} + +func (ts *testServer) didPatchHostIface() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.hostIfacePatched +} + +func (ts *testServer) didPatchNetProto() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.netProtoPatched +} + +func (ts *testServer) didPatchSerial() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.serialPatched +} + +func (ts *testServer) didCreateSubscription() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.subscriptionCreated +} + +func (ts *testServer) didDeleteSubscription() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.subscriptionDeleted +} + +func (ts *testServer) didSubmitTestEvent() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.testEventSubmitted +} + +func (ts *testServer) didPatchEventService() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.eventServicePatched +} + +func (ts *testServer) didSubmitTestMetric() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.testMetricSubmitted +} + +func (ts *testServer) didUpdateJobSchedule() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.jobScheduleUpdated +} + +func (ts *testServer) didGenerateCSR() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.csrGenerated +} + +func (ts *testServer) didReplaceCertificate() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.certReplaced +} + +func (ts *testServer) didRekeyCertificate() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.certRekeyed +} + +func (ts *testServer) didRenewCertificate() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.certRenewed +} + +func (ts *testServer) didPatchSNMP() bool { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.snmpPatched +} diff --git a/providers/lenovo/network.go b/providers/lenovo/network.go new file mode 100644 index 00000000..fd940e48 --- /dev/null +++ b/providers/lenovo/network.go @@ -0,0 +1,195 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/stmcginnis/gofish/schemas" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// compile-time assertions that the provider implements the interfaces. +var ( + _ bmc.NetworkInterfaceGetter = (*Conn)(nil) + _ bmc.NetworkInterfaceSetter = (*Conn)(nil) + _ bmc.NetworkProtocolGetter = (*Conn)(nil) + _ bmc.NetworkProtocolSetter = (*Conn)(nil) +) + +// BMCNetworkInterfaces returns the BMC (manager) ethernet interfaces. +// +// Implements bmc.NetworkInterfaceGetter. +func (c *Conn) BMCNetworkInterfaces(ctx context.Context) ([]bmc.NetworkInterface, error) { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return nil, err + } + + ifaces, err := manager.EthernetInterfaces() + if err != nil { + return nil, err + } + + return mapEthernetInterfaces(ifaces), nil +} + +// ServerNetworkInterfaces returns the server (system) ethernet interfaces. +// +// Implements bmc.NetworkInterfaceGetter. +func (c *Conn) ServerNetworkInterfaces(ctx context.Context) ([]bmc.NetworkInterface, error) { + system, err := c.redfishwrapper.System() + if err != nil { + return nil, err + } + + ifaces, err := system.EthernetInterfaces() + if err != nil { + return nil, err + } + + return mapEthernetInterfaces(ifaces), nil +} + +// SetBMCNetworkInterface PATCHes a BMC ethernet interface. +// +// Implements bmc.NetworkInterfaceSetter. +func (c *Conn) SetBMCNetworkInterface(ctx context.Context, id string, attrs map[string]any) error { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return err + } + + target, err := url.JoinPath(manager.ODataID, "EthernetInterfaces/"+id) + if err != nil { + return err + } + + return c.patchChecked(ctx, target, attrs) +} + +// SetHostInterfaceEnabled enables or disables a host interface. +// +// Implements bmc.NetworkInterfaceSetter. +func (c *Conn) SetHostInterfaceEnabled(ctx context.Context, id string, enabled bool) error { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return err + } + + target, err := url.JoinPath(manager.ODataID, "HostInterfaces/"+id) + if err != nil { + return err + } + payload := map[string]any{"InterfaceEnabled": enabled} + + return c.patchChecked(ctx, target, payload) +} + +// NetworkProtocols returns the manager network services and their state. +// +// Implements bmc.NetworkProtocolGetter. +func (c *Conn) NetworkProtocols(ctx context.Context) ([]bmc.NetworkProtocol, error) { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return nil, err + } + + path, err := url.JoinPath(manager.ODataID, "NetworkProtocol") + if err != nil { + return nil, err + } + + type protoSetting struct { + Port *int `json:"Port"` + ProtocolEnabled bool `json:"ProtocolEnabled"` + } + // Each protocol is a pointer so an absent key (nil) is distinguished from a + // present one: XCC only publishes the services it supports (e.g. SR630 V2 + // exposes no Telnet), and we must not report phantom disabled services. + var doc struct { + HTTP *protoSetting `json:"HTTP"` + HTTPS *protoSetting `json:"HTTPS"` + SSH *protoSetting `json:"SSH"` + IPMI *protoSetting `json:"IPMI"` + SNMP *protoSetting `json:"SNMP"` + NTP *protoSetting `json:"NTP"` + KVMIP *protoSetting `json:"KVMIP"` + VirtualMedia *protoSetting `json:"VirtualMedia"` + SSDP *protoSetting `json:"SSDP"` + Telnet *protoSetting `json:"Telnet"` + } + if err := c.getJSON(path, &doc); err != nil { + return nil, err + } + + services := []struct { + name string + set *protoSetting + }{ + {"HTTP", doc.HTTP}, + {"HTTPS", doc.HTTPS}, + {"SSH", doc.SSH}, + {"IPMI", doc.IPMI}, + {"SNMP", doc.SNMP}, + {"NTP", doc.NTP}, + {"KVMIP", doc.KVMIP}, + {"VirtualMedia", doc.VirtualMedia}, + {"SSDP", doc.SSDP}, + {"Telnet", doc.Telnet}, + } + + out := make([]bmc.NetworkProtocol, 0, len(services)) + for _, s := range services { + if s.set == nil { + continue // protocol not exposed by this XCC + } + port := 0 + if s.set.Port != nil { + port = *s.set.Port + } + out = append(out, bmc.NetworkProtocol{Name: s.name, Enabled: s.set.ProtocolEnabled, Port: port}) + } + + return out, nil +} + +// SetNetworkProtocols PATCHes the ManagerNetworkProtocol resource. +// +// Implements bmc.NetworkProtocolSetter. +func (c *Conn) SetNetworkProtocols(ctx context.Context, attrs map[string]any) error { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return err + } + + target, err := url.JoinPath(manager.ODataID, "NetworkProtocol") + if err != nil { + return err + } + + return c.patchChecked(ctx, target, attrs) +} + +// mapEthernetInterfaces converts gofish ethernet interfaces to the neutral +// bmc.NetworkInterface shape. +func mapEthernetInterfaces(ifaces []*schemas.EthernetInterface) []bmc.NetworkInterface { + out := make([]bmc.NetworkInterface, 0, len(ifaces)) + for _, e := range ifaces { + ni := bmc.NetworkInterface{ + ID: e.ID, + MACAddress: e.MACAddress, + HostName: e.HostName, + Enabled: e.InterfaceEnabled, + } + for _, a := range e.IPv4Addresses { + ni.IPv4Addresses = append(ni.IPv4Addresses, a.Address) + } + for _, a := range e.IPv6Addresses { + ni.IPv6Addresses = append(ni.IPv6Addresses, a.Address) + } + out = append(out, ni) + } + + return out +} diff --git a/providers/lenovo/network_serial_test.go b/providers/lenovo/network_serial_test.go new file mode 100644 index 00000000..feb475be --- /dev/null +++ b/providers/lenovo/network_serial_test.go @@ -0,0 +1,151 @@ +package lenovo + +import ( + "context" + "testing" +) + +// Requirement: Read ethernet interfaces — BMC NIC config. +func TestBMCNetworkInterfaces(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + ifaces, err := c.BMCNetworkInterfaces(context.Background()) + if err != nil { + t.Fatalf("BMCNetworkInterfaces: %v", err) + } + if len(ifaces) != 1 { + t.Fatalf("got %d BMC interfaces, want 1", len(ifaces)) + } + got := ifaces[0] + if got.MACAddress == "" || got.HostName != "XCC-SR650" { + t.Errorf("unexpected BMC interface: %+v", got) + } + if len(got.IPv4Addresses) != 1 || got.IPv4Addresses[0] != "10.0.0.50" { + t.Errorf("unexpected IPv4: %v", got.IPv4Addresses) + } +} + +// Requirement: Read ethernet interfaces — server NIC config. +func TestServerNetworkInterfaces(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + ifaces, err := c.ServerNetworkInterfaces(context.Background()) + if err != nil { + t.Fatalf("ServerNetworkInterfaces: %v", err) + } + if len(ifaces) != 1 || ifaces[0].ID != "NIC.1" { + t.Fatalf("unexpected server interfaces: %+v", ifaces) + } +} + +// Requirement: Configure BMC ethernet — set a static IPv4 address. +func TestSetBMCNetworkInterface(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + attrs := map[string]any{ + "IPv4StaticAddresses": []map[string]any{ + {"Address": "10.0.0.51", "SubnetMask": "255.255.255.0", "Gateway": "10.0.0.1"}, + }, + } + if err := c.SetBMCNetworkInterface(context.Background(), "eth0", attrs); err != nil { + t.Fatalf("SetBMCNetworkInterface: %v", err) + } + if !ts.didPatchBMCEth() { + t.Error("expected a PATCH of the BMC ethernet interface") + } +} + +// Requirement: Configure BMC ethernet — disable the host interface. +func TestSetHostInterfaceEnabled(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + if err := c.SetHostInterfaceEnabled(context.Background(), "1", false); err != nil { + t.Fatalf("SetHostInterfaceEnabled: %v", err) + } + if !ts.didPatchHostIface() { + t.Error("expected a PATCH of the host interface") + } +} + +// Requirement: Manager network protocols — read network services. +func TestNetworkProtocols(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + protos, err := c.NetworkProtocols(context.Background()) + if err != nil { + t.Fatalf("NetworkProtocols: %v", err) + } + + byName := map[string]struct { + enabled bool + port int + }{} + for _, p := range protos { + byName[p.Name] = struct { + enabled bool + port int + }{p.Enabled, p.Port} + } + + if !byName["HTTPS"].enabled || byName["HTTPS"].port != 443 { + t.Errorf("unexpected HTTPS protocol: %+v", byName["HTTPS"]) + } + if byName["IPMI"].port != 623 { + t.Errorf("unexpected IPMI port: %d", byName["IPMI"].port) + } + // A protocol absent from the resource (the fixture, like real XCC, has no + // Telnet) must not be reported as a phantom disabled service. + if _, ok := byName["Telnet"]; ok { + t.Errorf("Telnet is absent from the resource but was reported: %+v", protos) + } +} + +// Requirement: Manager network protocols — disable a service. +func TestSetNetworkProtocols(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + attrs := map[string]any{"IPMI": map[string]any{"ProtocolEnabled": false}} + if err := c.SetNetworkProtocols(context.Background(), attrs); err != nil { + t.Fatalf("SetNetworkProtocols: %v", err) + } + if !ts.didPatchNetProto() { + t.Error("expected a PATCH of ManagerNetworkProtocol") + } +} + +// Requirement: Read serial interface. +func TestSerialInterfaces(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + ifaces, err := c.SerialInterfaces(context.Background()) + if err != nil { + t.Fatalf("SerialInterfaces: %v", err) + } + if len(ifaces) != 1 { + t.Fatalf("got %d serial interfaces, want 1", len(ifaces)) + } + if ifaces[0].BitRate != "115200" || ifaces[0].FlowControl != "None" { + t.Errorf("unexpected serial config: %+v", ifaces[0]) + } +} + +// Requirement: Configure serial interface — change baud rate. +func TestSetSerialInterface(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + attrs := map[string]any{"BitRate": "57600"} + if err := c.SetSerialInterface(context.Background(), "1", attrs); err != nil { + t.Fatalf("SetSerialInterface: %v", err) + } + if !ts.didPatchSerial() { + t.Error("expected a PATCH of the serial interface") + } +} diff --git a/providers/lenovo/power_cap.go b/providers/lenovo/power_cap.go new file mode 100644 index 00000000..e17fa494 --- /dev/null +++ b/providers/lenovo/power_cap.go @@ -0,0 +1,81 @@ +package lenovo + +import ( + "context" + "fmt" + + "github.com/stmcginnis/gofish" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// compile-time assertions that the provider implements the interfaces. +var ( + _ bmc.PowerReader = (*Conn)(nil) + _ bmc.PowerCapSetter = (*Conn)(nil) +) + +// ReadPower returns power metrics and supplies from the first chassis that +// exposes a Power resource. +// +// Implements bmc.PowerReader. +func (c *Conn) ReadPower(ctx context.Context) (bmc.PowerInfo, error) { + chassis, err := c.redfishwrapper.Chassis(ctx) + if err != nil { + return bmc.PowerInfo{}, fmt.Errorf("reading chassis collection: %w", err) + } + + for _, ch := range chassis { + power, err := ch.Power() + if err != nil || power == nil { + continue + } + + info := bmc.PowerInfo{} + if len(power.PowerControl) > 0 { + pc := power.PowerControl[0] + info.ConsumedWatts = float64(gofish.Deref(pc.PowerConsumedWatts)) + info.CapacityWatts = float64(gofish.Deref(pc.PowerCapacityWatts)) + info.LimitInWatts = pc.PowerLimit.LimitInWatts + } + for i := range power.PowerSupplies { + ps := &power.PowerSupplies[i] + info.PowerSupplies = append(info.PowerSupplies, bmc.PowerSupplyInfo{ + Name: ps.Name, + Health: string(ps.Status.Health), + CapacityWatts: float64(gofish.Deref(ps.PowerCapacityWatts)), + }) + } + + return info, nil + } + + return bmc.PowerInfo{}, fmt.Errorf("no chassis exposes a Power resource") +} + +// SetPowerCap sets the chassis power limit by PATCHing +// PowerControl[0].PowerLimit.LimitInWatts. A nil limitWatts disables power +// capping (sets LimitInWatts to null). Implements bmc.PowerCapSetter. +func (c *Conn) SetPowerCap(ctx context.Context, limitWatts *float64) error { + chassis, err := c.redfishwrapper.Chassis(ctx) + if err != nil { + return fmt.Errorf("reading chassis collection: %w", err) + } + + for _, ch := range chassis { + power, err := ch.Power() + if err != nil || power == nil { + continue + } + + payload := map[string]any{ + "PowerControl": []map[string]any{ + {"PowerLimit": map[string]any{"LimitInWatts": limitWatts}}, + }, + } + + return c.patchChecked(ctx, power.ODataID, payload) + } + + return fmt.Errorf("no chassis exposes a Power resource") +} diff --git a/providers/lenovo/redfishget.go b/providers/lenovo/redfishget.go index 85e7c573..ac09287b 100644 --- a/providers/lenovo/redfishget.go +++ b/providers/lenovo/redfishget.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "path" + "strings" ) // getJSON GETs a Redfish resource and unmarshals its body into out. It maps @@ -42,6 +44,19 @@ func (c *Conn) getJSON(url string, out any) error { return nil } +// decodeJSONBody reads and unmarshals an already-open HTTP response body into +// out. The caller is responsible for closing resp.Body. +func decodeJSONBody(resp *http.Response, out any) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%w: %v", errFailedToParseResponse, err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("%w: %v", errFailedToParseResponse, err) + } + return nil +} + // collectionMembers GETs a Redfish collection and returns its member links. func (c *Conn) collectionMembers(url string) ([]odataID, error) { var coll struct { @@ -53,3 +68,19 @@ func (c *Conn) collectionMembers(url string) ([]odataID, error) { return coll.Members, nil } + +// memberIDs GETs a Redfish collection and returns the trailing-segment Id of +// each member. +func (c *Conn) memberIDs(url string) ([]string, error) { + members, err := c.collectionMembers(url) + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(members)) + for _, m := range members { + ids = append(ids, path.Base(strings.TrimRight(m.ODataID, "/"))) + } + + return ids, nil +} diff --git a/providers/lenovo/serial.go b/providers/lenovo/serial.go new file mode 100644 index 00000000..29ee5227 --- /dev/null +++ b/providers/lenovo/serial.go @@ -0,0 +1,61 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// compile-time assertions that the provider implements the interfaces. +var ( + _ bmc.SerialInterfaceGetter = (*Conn)(nil) + _ bmc.SerialInterfaceSetter = (*Conn)(nil) +) + +// SerialInterfaces returns the BMC serial interfaces. +// +// Implements bmc.SerialInterfaceGetter. +func (c *Conn) SerialInterfaces(ctx context.Context) ([]bmc.SerialInterface, error) { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return nil, err + } + + ifaces, err := manager.SerialInterfaces() + if err != nil { + return nil, err + } + + out := make([]bmc.SerialInterface, 0, len(ifaces)) + for _, s := range ifaces { + out = append(out, bmc.SerialInterface{ + ID: s.ID, + Enabled: s.InterfaceEnabled, + BitRate: string(s.BitRate), + FlowControl: string(s.FlowControl), + Parity: string(s.Parity), + StopBits: string(s.StopBits), + DataBits: string(s.DataBits), + }) + } + + return out, nil +} + +// SetSerialInterface PATCHes a BMC serial interface. +// +// Implements bmc.SerialInterfaceSetter. +func (c *Conn) SetSerialInterface(ctx context.Context, id string, attrs map[string]any) error { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return err + } + + target, err := url.JoinPath(manager.ODataID, "SerialInterfaces/"+id) + if err != nil { + return err + } + + return c.patchChecked(ctx, target, attrs) +} diff --git a/providers/lenovo/sklm.go b/providers/lenovo/sklm.go new file mode 100644 index 00000000..981713c8 --- /dev/null +++ b/providers/lenovo/sklm.go @@ -0,0 +1,83 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// sklmSubPath is the XCC OEM Secure Key Lifecycle service, relative to the +// Manager resource. +const sklmSubPath = "Oem/Lenovo/SecureKeyLifecycleService" + +// compile-time assertion that the provider implements the interface. +var _ bmc.SecureKeyLifecycle = (*Conn)(nil) + +// keyRepoServer mirrors an XCC SecureKeyLifecycleService KeyRepoServers entry. +type keyRepoServer struct { + HostName string `json:"HostName"` + Port int `json:"Port"` +} + +// secureKeyLifecycleDoc is a partial model of the XCC SecureKeyLifecycleService. +type secureKeyLifecycleDoc struct { + DeviceGroup string `json:"DeviceGroup"` + KeyRepoServers []keyRepoServer `json:"KeyRepoServers"` +} + +// GetSecureKeyLifecycle reads the XCC Secure Key Lifecycle configuration. +// +// Implements bmc.SecureKeyLifecycle. +func (c *Conn) GetSecureKeyLifecycle(ctx context.Context) (bmc.SecureKeyLifecycleConfig, error) { + path, err := c.secureKeyLifecyclePath(ctx) + if err != nil { + return bmc.SecureKeyLifecycleConfig{}, err + } + + var doc secureKeyLifecycleDoc + if err := c.getJSON(path, &doc); err != nil { + return bmc.SecureKeyLifecycleConfig{}, err + } + + cfg := bmc.SecureKeyLifecycleConfig{DeviceGroup: doc.DeviceGroup} + for _, s := range doc.KeyRepoServers { + cfg.KeyRepoServers = append(cfg.KeyRepoServers, bmc.SecureKeyRepoServer{ + HostName: s.HostName, + Port: s.Port, + }) + } + + return cfg, nil +} + +// SetSecureKeyRepoServers replaces the configured key-repository servers by +// PATCHing the SecureKeyLifecycleService. +// +// Implements bmc.SecureKeyLifecycle. +func (c *Conn) SetSecureKeyRepoServers(ctx context.Context, servers []bmc.SecureKeyRepoServer) error { + path, err := c.secureKeyLifecyclePath(ctx) + if err != nil { + return err + } + + repo := make([]keyRepoServer, 0, len(servers)) + for _, s := range servers { + repo = append(repo, keyRepoServer{HostName: s.HostName, Port: s.Port}) + } + + payload := map[string]any{"KeyRepoServers": repo} + + return c.patchChecked(ctx, path, payload) +} + +// secureKeyLifecyclePath resolves the SecureKeyLifecycleService path from the +// managed Manager resource. +func (c *Conn) secureKeyLifecyclePath(ctx context.Context) (string, error) { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return "", err + } + + return url.JoinPath(manager.ODataID, sklmSubPath) +} diff --git a/providers/lenovo/snmp.go b/providers/lenovo/snmp.go new file mode 100644 index 00000000..580f66f3 --- /dev/null +++ b/providers/lenovo/snmp.go @@ -0,0 +1,90 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// snmpSubPath is the XCC OEM SNMP resource, relative to the Manager's +// NetworkProtocol resource. +const snmpSubPath = "NetworkProtocol/Oem/Lenovo/SNMP" + +// compile-time assertion that the provider implements the interface. +var _ bmc.SNMPConfigurer = (*Conn)(nil) + +// SNMP returns the XCC SNMP trap configuration. +// +// Implements bmc.SNMPConfigurer. +func (c *Conn) SNMP(ctx context.Context) (bmc.SNMPConfig, error) { + path, err := c.snmpPath(ctx) + if err != nil { + return bmc.SNMPConfig{}, err + } + + // On XCC the OEM SNMP resource carries CommunityNames at the top level (a + // sibling of SNMPTraps), not inside SNMPTraps. The trap enable/port live + // under SNMPTraps. + var doc struct { + CommunityNames []string `json:"CommunityNames"` + SNMPTraps struct { + SNMPv1TrapEnabled bool `json:"SNMPv1TrapEnabled"` + ProtocolEnabled bool `json:"ProtocolEnabled"` + Port int `json:"Port"` + } `json:"SNMPTraps"` + } + if err := c.getJSON(path, &doc); err != nil { + return bmc.SNMPConfig{}, err + } + + return bmc.SNMPConfig{ + V1TrapEnabled: doc.SNMPTraps.SNMPv1TrapEnabled, + V3TrapEnabled: doc.SNMPTraps.ProtocolEnabled, + TrapPort: doc.SNMPTraps.Port, + CommunityNames: doc.CommunityNames, + }, nil +} + +// SetSNMPAlertFilter PATCHes the SNMP trap (SNMPTraps) properties. +// +// Implements bmc.SNMPConfigurer. +func (c *Conn) SetSNMPAlertFilter(ctx context.Context, attrs map[string]any) error { + return c.patchSNMPTraps(ctx, attrs) +} + +// EnableSNMPv1Trap enables or disables the SNMPv1 trap. +// +// Implements bmc.SNMPConfigurer. +func (c *Conn) EnableSNMPv1Trap(ctx context.Context, enabled bool) error { + return c.patchSNMPTraps(ctx, map[string]any{"SNMPv1TrapEnabled": enabled}) +} + +// EnableSNMPv3Trap enables or disables the SNMPv3 trap. +// +// Implements bmc.SNMPConfigurer. +func (c *Conn) EnableSNMPv3Trap(ctx context.Context, enabled bool) error { + return c.patchSNMPTraps(ctx, map[string]any{"ProtocolEnabled": enabled}) +} + +// patchSNMPTraps PATCHes the SNMP resource with the given SNMPTraps attributes. +func (c *Conn) patchSNMPTraps(ctx context.Context, traps map[string]any) error { + path, err := c.snmpPath(ctx) + if err != nil { + return err + } + + payload := map[string]any{"SNMPTraps": traps} + + return c.patchChecked(ctx, path, payload) +} + +// snmpPath resolves the XCC OEM SNMP resource path from the managed Manager. +func (c *Conn) snmpPath(ctx context.Context) (string, error) { + manager, err := c.redfishwrapper.Manager(ctx) + if err != nil { + return "", err + } + + return url.JoinPath(manager.ODataID, snmpSubPath) +} diff --git a/providers/lenovo/storage.go b/providers/lenovo/storage.go new file mode 100644 index 00000000..33d3e637 --- /dev/null +++ b/providers/lenovo/storage.go @@ -0,0 +1,228 @@ +package lenovo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/schemas" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.VolumeManager = (*Conn)(nil) + +// StorageControllers lists the system's storage controllers. +// +// Implements bmc.VolumeManager. +func (c *Conn) StorageControllers(ctx context.Context) ([]bmc.StorageControllerInfo, error) { + storages, err := c.storageControllers() + if err != nil { + return nil, err + } + + out := make([]bmc.StorageControllerInfo, 0, len(storages)) + for _, s := range storages { + info := bmc.StorageControllerInfo{ + ID: s.ID, + Name: s.Name, + Health: string(s.Status.Health), + DriveCount: s.DrivesCount, + } + // XCC populates the deprecated inline StorageControllers array rather + // than the Controllers collection, so read the controller model here. + if sc := s.StorageControllers; len(sc) > 0 { //nolint:staticcheck // deprecated inline array, but the one the XCC fills in + info.Model = sc[0].Model + } + out = append(out, info) + } + + return out, nil +} + +// Volumes lists the volumes managed by the given storage controller. +// +// Implements bmc.VolumeManager. +func (c *Conn) Volumes(ctx context.Context, storageID string) ([]bmc.VolumeInfo, error) { + storage, err := c.storageByID(storageID) + if err != nil { + return nil, err + } + + volumes, err := storage.Volumes() + if err != nil { + return nil, fmt.Errorf("listing volumes for controller %q: %w", storageID, err) + } + + out := make([]bmc.VolumeInfo, 0, len(volumes)) + for _, v := range volumes { + out = append(out, bmc.VolumeInfo{ + ID: v.ID, + Name: v.Name, + RAIDType: string(v.RAIDType), + CapacityBytes: int64(gofish.Deref(v.CapacityBytes)), + Health: string(v.Status.Health), + }) + } + + return out, nil +} + +// VolumeCreate creates a volume on the given storage controller by POSTing to +// its Volumes collection, returning the new volume Id (from the Location header +// or response body). +// +// Implements bmc.VolumeManager. +func (c *Conn) VolumeCreate(ctx context.Context, storageID string, req bmc.VolumeCreateRequest) (string, error) { + storage, err := c.storageByID(storageID) + if err != nil { + return "", err + } + + payload := map[string]any{ + "Name": req.Name, + "RAIDType": req.RAIDType, + } + if req.CapacityBytes > 0 { + payload["CapacityBytes"] = req.CapacityBytes + } + if len(req.Drives) > 0 { + drives := make([]map[string]string, 0, len(req.Drives)) + for _, d := range req.Drives { + drives = append(drives, map[string]string{"@odata.id": d}) + } + payload["Links"] = map[string]any{"Drives": drives} + } + + volumesURL, err := url.JoinPath(storage.ODataID, "Volumes") + if err != nil { + return "", err + } + + resp, err := c.redfishwrapper.PostWithHeaders(ctx, volumesURL, payload, nil) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", parseRedfishError(resp) + } + + // Prefer the created resource location; fall back to the body Id. + if loc := resp.Header.Get("Location"); loc != "" { + return path.Base(strings.TrimRight(loc, "/")), nil + } + + var created struct { + ID string `json:"Id"` + } + if body, rerr := io.ReadAll(resp.Body); rerr == nil { + _ = json.Unmarshal(body, &created) + } + + return created.ID, nil +} + +// VolumeInitialize initializes a volume via the Volume.Initialize action. +// +// The Redfish action target is derived from the volume path; only the +// InitializeType is sent so the controller's default InitializeMethod applies. +// Implements bmc.VolumeManager. +func (c *Conn) VolumeInitialize(ctx context.Context, storageID, volumeID, initType string) error { + volume, err := c.volumeByID(storageID, volumeID) + if err != nil { + return err + } + + target, err := url.JoinPath(volume.ODataID, "Actions/Volume.Initialize") + if err != nil { + return err + } + payload := map[string]any{"InitializeType": initType} + + return c.postChecked(ctx, target, payload) +} + +// VolumeUpdate updates volume settings via a PATCH of the given attributes. +// +// Implements bmc.VolumeManager. +func (c *Conn) VolumeUpdate(ctx context.Context, storageID, volumeID string, settings map[string]any) error { + volume, err := c.volumeByID(storageID, volumeID) + if err != nil { + return err + } + + return c.patchChecked(ctx, volume.ODataID, settings) +} + +// VolumeDelete deletes a volume. +// +// Implements bmc.VolumeManager. +func (c *Conn) VolumeDelete(ctx context.Context, storageID, volumeID string) error { + volume, err := c.volumeByID(storageID, volumeID) + if err != nil { + return err + } + + return c.deleteChecked(volume.ODataID) +} + +// storageControllers returns the system's gofish Storage resources. +func (c *Conn) storageControllers() ([]*schemas.Storage, error) { + system, err := c.redfishwrapper.System() + if err != nil { + return nil, err + } + + storages, err := system.Storage() + if err != nil { + return nil, fmt.Errorf("listing storage controllers: %w", err) + } + + return storages, nil +} + +// storageByID resolves a gofish Storage resource by its Id. +func (c *Conn) storageByID(storageID string) (*schemas.Storage, error) { + storages, err := c.storageControllers() + if err != nil { + return nil, err + } + + for _, s := range storages { + if s.ID == storageID { + return s, nil + } + } + + return nil, fmt.Errorf("storage controller %q not found", storageID) +} + +// volumeByID resolves a gofish Volume resource by controller Id and volume Id. +func (c *Conn) volumeByID(storageID, volumeID string) (*schemas.Volume, error) { + storage, err := c.storageByID(storageID) + if err != nil { + return nil, err + } + + volumes, err := storage.Volumes() + if err != nil { + return nil, fmt.Errorf("listing volumes for controller %q: %w", storageID, err) + } + + for _, v := range volumes { + if v.ID == volumeID { + return v, nil + } + } + + return nil, fmt.Errorf("volume %q not found on controller %q", volumeID, storageID) +} diff --git a/providers/lenovo/telemetry.go b/providers/lenovo/telemetry.go new file mode 100644 index 00000000..899ff450 --- /dev/null +++ b/providers/lenovo/telemetry.go @@ -0,0 +1,107 @@ +package lenovo + +import ( + "context" + "net/url" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +const ( + telemetryServiceURI = "/redfish/v1/TelemetryService" + metricReportsURI = telemetryServiceURI + "/MetricReports" + metricReportDefinitionsURI = telemetryServiceURI + "/MetricReportDefinitions" + metricDefinitionsURI = telemetryServiceURI + "/MetricDefinitions" + submitTestMetricReportURI = telemetryServiceURI + "/Actions/TelemetryService.SubmitTestMetricReport" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.TelemetryReader = (*Conn)(nil) + +// TelemetryService returns the XCC telemetry-service configuration. +// +// Implements bmc.TelemetryReader. +func (c *Conn) TelemetryService(ctx context.Context) (bmc.TelemetryServiceInfo, error) { + var doc struct { + ServiceEnabled bool `json:"ServiceEnabled"` + MaxReports int `json:"MaxReports"` + MinCollectionInterval string `json:"MinCollectionInterval"` + } + if err := c.getJSON(telemetryServiceURI, &doc); err != nil { + return bmc.TelemetryServiceInfo{}, err + } + + return bmc.TelemetryServiceInfo{ + ServiceEnabled: doc.ServiceEnabled, + MaxReports: doc.MaxReports, + MinCollectionInterval: doc.MinCollectionInterval, + }, nil +} + +// MetricReports lists the metric report Ids. +// +// Implements bmc.TelemetryReader. +func (c *Conn) MetricReports(ctx context.Context) ([]string, error) { + return c.memberIDs(metricReportsURI) +} + +// MetricReport returns a metric report and its values by Id. +// +// Implements bmc.TelemetryReader. +func (c *Conn) MetricReport(ctx context.Context, id string) (bmc.MetricReportInfo, error) { + var doc struct { + ID string `json:"Id"` + Name string `json:"Name"` + MetricValues []struct { + MetricID string `json:"MetricId"` + MetricProperty string `json:"MetricProperty"` + MetricValue string `json:"MetricValue"` + Timestamp string `json:"Timestamp"` + } `json:"MetricValues"` + } + reportURL, err := url.JoinPath(metricReportsURI, id) + if err != nil { + return bmc.MetricReportInfo{}, err + } + if err := c.getJSON(reportURL, &doc); err != nil { + return bmc.MetricReportInfo{}, err + } + + report := bmc.MetricReportInfo{ID: doc.ID, Name: doc.Name} + for _, v := range doc.MetricValues { + report.Values = append(report.Values, bmc.MetricValue{ + MetricID: v.MetricID, + MetricProperty: v.MetricProperty, + Value: v.MetricValue, + Timestamp: v.Timestamp, + }) + } + + return report, nil +} + +// MetricReportDefinitions lists the metric report definition Ids. +// +// Implements bmc.TelemetryReader. +func (c *Conn) MetricReportDefinitions(ctx context.Context) ([]string, error) { + return c.memberIDs(metricReportDefinitionsURI) +} + +// MetricDefinitions lists the metric definition Ids. +// +// Implements bmc.TelemetryReader. +func (c *Conn) MetricDefinitions(ctx context.Context) ([]string, error) { + return c.memberIDs(metricDefinitionsURI) +} + +// SubmitTestMetricReport submits a test metric report. +// +// Implements bmc.TelemetryReader. +func (c *Conn) SubmitTestMetricReport(ctx context.Context, reportName string) error { + payload := map[string]any{} + if reportName != "" { + payload["MetricReportName"] = reportName + } + + return checkResponse(c.redfishwrapper.PostWithHeaders(ctx, submitTestMetricReportURI, payload, nil)) +} diff --git a/providers/lenovo/thermal.go b/providers/lenovo/thermal.go new file mode 100644 index 00000000..c9fc61a6 --- /dev/null +++ b/providers/lenovo/thermal.go @@ -0,0 +1,58 @@ +package lenovo + +import ( + "context" + "fmt" + + "github.com/stmcginnis/gofish" + + "github.com/bmc-toolbox/bmclib/v2/bmc" +) + +// compile-time assertion that the provider implements the interface. +var _ bmc.ThermalReader = (*Conn)(nil) + +// Thermal returns the temperature and fan readings from the first chassis that +// exposes a Thermal resource. +// +// Implements bmc.ThermalReader. +func (c *Conn) Thermal(ctx context.Context) (bmc.ThermalReading, error) { + chassis, err := c.redfishwrapper.Chassis(ctx) + if err != nil { + return bmc.ThermalReading{}, fmt.Errorf("reading chassis collection: %w", err) + } + + for _, ch := range chassis { + thermal, err := ch.Thermal() + if err != nil || thermal == nil { + continue + } + + reading := bmc.ThermalReading{} + for i := range thermal.Temperatures { + t := &thermal.Temperatures[i] + reading.Temperatures = append(reading.Temperatures, bmc.TemperatureReading{ + Name: t.Name, + ReadingCelsius: gofish.Deref(t.ReadingCelsius), + Health: string(t.Status.Health), + }) + } + for i := range thermal.Fans { + f := &thermal.Fans[i] + name := f.Name + if name == "" { + // Fall back to the deprecated FanName for pre-1.1.0 Fan schemas. + name = f.FanName //nolint:staticcheck // deprecated in the schema, still the only name on older XCC firmware + } + reading.Fans = append(reading.Fans, bmc.FanReading{ + Name: name, + Reading: gofish.Deref(f.Reading), + Health: string(f.Status.Health), + }) + } + + return reading, nil + } + + return bmc.ThermalReading{}, fmt.Errorf("no chassis exposes a Thermal resource") +} diff --git a/providers/lenovo/thermal_power_test.go b/providers/lenovo/thermal_power_test.go new file mode 100644 index 00000000..eea79206 --- /dev/null +++ b/providers/lenovo/thermal_power_test.go @@ -0,0 +1,68 @@ +package lenovo + +import ( + "context" + "testing" +) + +// Requirement: Thermal readings. +func TestThermal(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + reading, err := c.Thermal(context.Background()) + if err != nil { + t.Fatalf("Thermal: %v", err) + } + if len(reading.Temperatures) != 1 { + t.Fatalf("got %d temperatures, want 1", len(reading.Temperatures)) + } + if reading.Temperatures[0].Name != "Ambient Temp" || reading.Temperatures[0].ReadingCelsius != 23 { + t.Errorf("unexpected temperature: %+v", reading.Temperatures[0]) + } + if len(reading.Fans) != 1 { + t.Fatalf("got %d fans, want 1", len(reading.Fans)) + } + if reading.Fans[0].Reading != 6840 { + t.Errorf("fan reading = %d, want 6840", reading.Fans[0].Reading) + } +} + +// Requirement: Power metrics read. +func TestReadPower(t *testing.T) { + ts := newTestServer(t, testServerOpts{}) + c := ts.openedClient(t) + + info, err := c.ReadPower(context.Background()) + if err != nil { + t.Fatalf("ReadPower: %v", err) + } + if info.ConsumedWatts != 287 { + t.Errorf("ConsumedWatts = %v, want 287", info.ConsumedWatts) + } + if info.LimitInWatts != nil { + t.Errorf("LimitInWatts = %v, want nil (no cap)", *info.LimitInWatts) + } + if len(info.PowerSupplies) != 2 { + t.Fatalf("got %d power supplies, want 2", len(info.PowerSupplies)) + } +} + +// Requirement: Power capping — set a cap. +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") + } + + // nil limit disables capping; should also PATCH successfully. + if err := c.SetPowerCap(context.Background(), nil); err != nil { + t.Fatalf("SetPowerCap(nil): %v", err) + } +} From 9e6beb18e96ad18129d631ab92360c98696767c9 Mon Sep 17 00:00:00 2001 From: nuxster Date: Wed, 1 Jul 2026 18:27:13 +0300 Subject: [PATCH 3/4] redfishwrapper: add gofish service entry-point getters --- internal/redfishwrapper/system.go | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/internal/redfishwrapper/system.go b/internal/redfishwrapper/system.go index a1af1216..06baac48 100644 --- a/internal/redfishwrapper/system.go +++ b/internal/redfishwrapper/system.go @@ -31,6 +31,62 @@ func (c *Client) UpdateService() (*schemas.UpdateService, error) { return c.client.Service.UpdateService() } +// Systems returns the collection of ComputerSystem objects from the service +// root. Providers use this to reach system-scoped resources (SecureBoot, +// Storage, ...) via gofish rather than hard-coding URIs. +func (c *Client) Systems() ([]*schemas.ComputerSystem, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.Systems() +} + +// CertificateService gets the Redfish CertificateService. +func (c *Client) CertificateService() (*schemas.CertificateService, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.CertificateService() +} + +// JobService gets the Redfish JobService. +func (c *Client) JobService() (*schemas.JobService, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.JobService() +} + +// EventService gets the Redfish EventService. +func (c *Client) EventService() (*schemas.EventService, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.EventService() +} + +// LicenseService gets the Redfish LicenseService. +func (c *Client) LicenseService() (*schemas.LicenseService, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.LicenseService() +} + +// TelemetryService gets the Redfish TelemetryService. +func (c *Client) TelemetryService() (*schemas.TelemetryService, error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + return c.client.Service.TelemetryService() +} + // ServiceRoot returns the gofish service root ("/redfish/v1/"). It exposes the // service identity (Vendor, Product, RedfishVersion, ...) and resolves the // linked resource collections (Systems, Managers, UpdateService, ...) via From 3b70fa0ba65fd3fa09e39a0e092f056eba28cffb Mon Sep 17 00:00:00 2001 From: nuxster Date: Wed, 1 Jul 2026 18:28:48 +0300 Subject: [PATCH 4/4] Lenovo: read Job/Event/Telemetry services via redfishwrapper gofish getters --- providers/lenovo/events.go | 53 +++++++++++++++-------------------- providers/lenovo/jobs.go | 39 +++++++++++++++----------- providers/lenovo/telemetry.go | 18 ++++++------ 3 files changed, 53 insertions(+), 57 deletions(-) diff --git a/providers/lenovo/events.go b/providers/lenovo/events.go index a094ac5c..43a2cbce 100644 --- a/providers/lenovo/events.go +++ b/providers/lenovo/events.go @@ -23,21 +23,16 @@ var _ bmc.EventSubscriber = (*Conn)(nil) // // Implements bmc.EventSubscriber. func (c *Conn) EventService(ctx context.Context) (bmc.EventServiceInfo, error) { - var doc struct { - ServiceEnabled bool `json:"ServiceEnabled"` - DeliveryRetryAttempts int `json:"DeliveryRetryAttempts"` - DeliveryRetryIntervalSeconds int `json:"DeliveryRetryIntervalSeconds"` - ServerSentEventURI string `json:"ServerSentEventUri"` - } - if err := c.getJSON(eventServiceURI, &doc); err != nil { + es, err := c.redfishwrapper.EventService() + if err != nil { return bmc.EventServiceInfo{}, err } return bmc.EventServiceInfo{ - ServiceEnabled: doc.ServiceEnabled, - DeliveryRetryAttempts: doc.DeliveryRetryAttempts, - DeliveryRetryIntervalSeconds: doc.DeliveryRetryIntervalSeconds, - SSEFilterURI: doc.ServerSentEventURI, + ServiceEnabled: es.ServiceEnabled, + DeliveryRetryAttempts: es.DeliveryRetryAttempts, + DeliveryRetryIntervalSeconds: es.DeliveryRetryIntervalSeconds, + SSEFilterURI: es.ServerSentEventURI, }, nil } @@ -45,27 +40,23 @@ func (c *Conn) EventService(ctx context.Context) (bmc.EventServiceInfo, error) { // // Implements bmc.EventSubscriber. func (c *Conn) EventSubscriptions(ctx context.Context) ([]bmc.EventSubscription, error) { - members, err := c.collectionMembers(eventSubscriptionsURI) + es, err := c.redfishwrapper.EventService() + if err != nil { + return nil, err + } + + subs, err := es.Subscriptions() if err != nil { return nil, err } - out := make([]bmc.EventSubscription, 0, len(members)) - for _, m := range members { - var sub struct { - ID string `json:"Id"` - Destination string `json:"Destination"` - Protocol string `json:"Protocol"` - Context string `json:"Context"` - } - if err := c.getJSON(m.ODataID, &sub); err != nil { - return nil, err - } + out := make([]bmc.EventSubscription, 0, len(subs)) + for _, s := range subs { out = append(out, bmc.EventSubscription{ - ID: sub.ID, - Destination: sub.Destination, - Protocol: sub.Protocol, - Context: sub.Context, + ID: s.ID, + Destination: s.Destination, + Protocol: string(s.Protocol), + Context: s.Context, }) } @@ -96,7 +87,7 @@ func (c *Conn) EventSubscriptionCreate(ctx context.Context, req bmc.EventSubscri if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return "", parseRedfishError(resp) @@ -117,7 +108,7 @@ func (c *Conn) EventSubscriptionDelete(ctx context.Context, id string) error { if err != nil { return err } - return checkResponse(c.redfishwrapper.Delete(target)) + return c.deleteChecked(target) } // SubmitTestEvent submits a test event via EventService.SubmitTestEvent. @@ -129,12 +120,12 @@ func (c *Conn) SubmitTestEvent(ctx context.Context, messageID string) error { payload["MessageId"] = messageID } - return checkResponse(c.redfishwrapper.PostWithHeaders(ctx, submitTestEventURI, payload, nil)) + return c.postChecked(ctx, submitTestEventURI, payload) } // SetEventService PATCHes the event-service properties. // // Implements bmc.EventSubscriber. func (c *Conn) SetEventService(ctx context.Context, attrs map[string]any) error { - return checkResponse(c.redfishwrapper.PatchWithHeaders(ctx, eventServiceURI, attrs, nil)) + return c.patchChecked(ctx, eventServiceURI, attrs) } diff --git a/providers/lenovo/jobs.go b/providers/lenovo/jobs.go index cd153bea..66604a37 100644 --- a/providers/lenovo/jobs.go +++ b/providers/lenovo/jobs.go @@ -4,6 +4,8 @@ import ( "context" "net/url" + "github.com/stmcginnis/gofish" + "github.com/bmc-toolbox/bmclib/v2/bmc" ) @@ -19,32 +21,37 @@ var _ bmc.JobManager = (*Conn)(nil) // // Implements bmc.JobManager. func (c *Conn) JobService(ctx context.Context) (bmc.JobServiceInfo, error) { - var doc struct { - ServiceEnabled bool `json:"ServiceEnabled"` - } - if err := c.getJSON(jobServiceURI, &doc); err != nil { + js, err := c.redfishwrapper.JobService() + if err != nil { return bmc.JobServiceInfo{}, err } - return bmc.JobServiceInfo{ServiceEnabled: doc.ServiceEnabled}, nil + return bmc.JobServiceInfo{ServiceEnabled: js.ServiceEnabled}, nil } // Jobs lists the XCC jobs. // // Implements bmc.JobManager. func (c *Conn) Jobs(ctx context.Context) ([]bmc.JobInfo, error) { - members, err := c.collectionMembers(jobsURI) + js, err := c.redfishwrapper.JobService() + if err != nil { + return nil, err + } + + jobs, err := js.Jobs() if err != nil { return nil, err } - out := make([]bmc.JobInfo, 0, len(members)) - for _, m := range members { - job, err := c.jobAt(m.ODataID) - if err != nil { - return nil, err - } - out = append(out, job) + out := make([]bmc.JobInfo, 0, len(jobs)) + for _, j := range jobs { + out = append(out, bmc.JobInfo{ + ID: j.ID, + Name: j.Name, + JobState: string(j.JobState), + PercentComplete: int(gofish.Deref(j.PercentComplete)), + StartTime: j.StartTime, + }) } return out, nil @@ -70,11 +77,11 @@ func (c *Conn) JobUpdateSchedule(ctx context.Context, id string, schedule map[st return err } payload := map[string]any{"Schedule": schedule} - return checkResponse(c.redfishwrapper.PatchWithHeaders(ctx, target, payload, nil)) + return c.patchChecked(ctx, target, payload) } // jobAt reads a single job resource. -func (c *Conn) jobAt(url string) (bmc.JobInfo, error) { +func (c *Conn) jobAt(uri string) (bmc.JobInfo, error) { var doc struct { ID string `json:"Id"` Name string `json:"Name"` @@ -82,7 +89,7 @@ func (c *Conn) jobAt(url string) (bmc.JobInfo, error) { PercentComplete int `json:"PercentComplete"` StartTime string `json:"StartTime"` } - if err := c.getJSON(url, &doc); err != nil { + if err := c.getJSON(uri, &doc); err != nil { return bmc.JobInfo{}, err } diff --git a/providers/lenovo/telemetry.go b/providers/lenovo/telemetry.go index 899ff450..5717e26b 100644 --- a/providers/lenovo/telemetry.go +++ b/providers/lenovo/telemetry.go @@ -4,6 +4,8 @@ import ( "context" "net/url" + "github.com/stmcginnis/gofish" + "github.com/bmc-toolbox/bmclib/v2/bmc" ) @@ -22,19 +24,15 @@ var _ bmc.TelemetryReader = (*Conn)(nil) // // Implements bmc.TelemetryReader. func (c *Conn) TelemetryService(ctx context.Context) (bmc.TelemetryServiceInfo, error) { - var doc struct { - ServiceEnabled bool `json:"ServiceEnabled"` - MaxReports int `json:"MaxReports"` - MinCollectionInterval string `json:"MinCollectionInterval"` - } - if err := c.getJSON(telemetryServiceURI, &doc); err != nil { + ts, err := c.redfishwrapper.TelemetryService() + if err != nil { return bmc.TelemetryServiceInfo{}, err } return bmc.TelemetryServiceInfo{ - ServiceEnabled: doc.ServiceEnabled, - MaxReports: doc.MaxReports, - MinCollectionInterval: doc.MinCollectionInterval, + ServiceEnabled: ts.ServiceEnabled, + MaxReports: int(gofish.Deref(ts.MaxReports)), + MinCollectionInterval: ts.MinCollectionInterval, }, nil } @@ -103,5 +101,5 @@ func (c *Conn) SubmitTestMetricReport(ctx context.Context, reportName string) er payload["MetricReportName"] = reportName } - return checkResponse(c.redfishwrapper.PostWithHeaders(ctx, submitTestMetricReportURI, payload, nil)) + return c.postChecked(ctx, submitTestMetricReportURI, payload) }