diff --git a/bmc/firmware.go b/bmc/firmware.go index 15849fd0..e235bf20 100644 --- a/bmc/firmware.go +++ b/bmc/firmware.go @@ -4,26 +4,29 @@ import ( "context" "fmt" "io" + "os" + "github.com/bmc-toolbox/bmclib/v2/constants" + bconsts "github.com/bmc-toolbox/bmclib/v2/constants" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/hashicorp/go-multierror" "github.com/pkg/errors" ) -// FirmwareInstaller defines an interface to install firmware updates +// FirmwareInstaller defines an interface to upload and initiate a firmware install type FirmwareInstaller interface { // FirmwareInstall uploads firmware update payload to the BMC returning the task ID // // parameters: // component - the component slug for the component update being installed. - // applyAt - one of "Immediate", "OnReset". + // operationsApplyTime - one of the OperationApplyTime constants // forceInstall - purge the install task queued/scheduled firmware install BMC task (if any). // reader - the io.reader to the firmware update file. // // return values: // taskID - A taskID is returned if the update process on the BMC returns an identifier for the update process. - FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (taskID string, err error) + FirmwareInstall(ctx context.Context, component string, operationApplyTime string, forceInstall bool, reader io.Reader) (taskID string, err error) } // firmwareInstallerProvider is an internal struct to correlate an implementation/provider and its name @@ -33,7 +36,7 @@ type firmwareInstallerProvider struct { } // firmwareInstall uploads and initiates firmware update for the component -func firmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader, generic []firmwareInstallerProvider) (taskID string, metadata Metadata, err error) { +func firmwareInstall(ctx context.Context, component, operationApplyTime string, forceInstall bool, reader io.Reader, generic []firmwareInstallerProvider) (taskID string, metadata Metadata, err error) { var metadataLocal Metadata for _, elem := range generic { @@ -47,7 +50,7 @@ func firmwareInstall(ctx context.Context, component, applyAt string, forceInstal return taskID, metadata, err default: metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) - taskID, vErr := elem.FirmwareInstall(ctx, component, applyAt, forceInstall, reader) + taskID, vErr := elem.FirmwareInstall(ctx, component, operationApplyTime, forceInstall, reader) if vErr != nil { err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) err = multierror.Append(err, vErr) @@ -63,7 +66,7 @@ func firmwareInstall(ctx context.Context, component, applyAt string, forceInstal } // FirmwareInstallFromInterfaces identifies implementations of the FirmwareInstaller interface and passes the found implementations to the firmwareInstall() wrapper -func FirmwareInstallFromInterfaces(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader, generic []interface{}) (taskID string, metadata Metadata, err error) { +func FirmwareInstallFromInterfaces(ctx context.Context, component, operationApplyTime string, forceInstall bool, reader io.Reader, generic []interface{}) (taskID string, metadata Metadata, err error) { implementations := make([]firmwareInstallerProvider, 0) for _, elem := range generic { temp := firmwareInstallerProvider{name: getProviderName(elem)} @@ -86,9 +89,11 @@ func FirmwareInstallFromInterfaces(ctx context.Context, component, applyAt strin ) } - return firmwareInstall(ctx, component, applyAt, forceInstall, reader, implementations) + return firmwareInstall(ctx, component, operationApplyTime, forceInstall, reader, implementations) } +// Note: this interface is to be deprecated in favour of a more generic FirmwareTaskVerifier. +// // FirmwareInstallVerifier defines an interface to check firmware install status type FirmwareInstallVerifier interface { // FirmwareInstallStatus returns the status of the firmware install process. @@ -165,3 +170,294 @@ func FirmwareInstallStatusFromInterfaces(ctx context.Context, installVersion, co return firmwareInstallStatus(ctx, installVersion, component, taskID, implementations) } + +// FirmwareInstallerWithOpts defines an interface to install firmware that was previously uploaded with FirmwareUpload +type FirmwareInstallerUploaded interface { + // FirmwareInstallUploaded uploads firmware update payload to the BMC returning the firmware install task ID + // + // parameters: + // component - the component slug for the component update being installed. + // uploadTaskID - the taskID for the firmware upload verify task (returned by FirmwareUpload) + // + // return values: + // installTaskID - A installTaskID is returned if the update process on the BMC returns an identifier for the firmware install process. + FirmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (taskID string, err error) +} + +// firmwareInstallerProvider is an internal struct to correlate an implementation/provider and its name +type firmwareInstallerWithOptionsProvider struct { + name string + FirmwareInstallerUploaded +} + +// firmwareInstallUploaded uploads and initiates firmware update for the component +func firmwareInstallUploaded(ctx context.Context, component, uploadTaskID string, generic []firmwareInstallerWithOptionsProvider) (installTaskID string, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range generic { + if elem.FirmwareInstallerUploaded == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return installTaskID, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + var vErr error + installTaskID, vErr = elem.FirmwareInstallUploaded(ctx, component, uploadTaskID) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadataLocal.SuccessfulProvider = elem.name + return installTaskID, metadataLocal, nil + } + } + + return installTaskID, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareInstallUploaded")) +} + +// FirmwareInstallerUploadedFromInterfaces identifies implementations of the FirmwareInstallUploaded interface and passes the found implementations to the firmwareInstallUploaded() wrapper +func FirmwareInstallerUploadedFromInterfaces(ctx context.Context, component, uploadTaskID string, generic []interface{}) (installTaskID string, metadata Metadata, err error) { + implementations := make([]firmwareInstallerWithOptionsProvider, 0) + for _, elem := range generic { + temp := firmwareInstallerWithOptionsProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FirmwareInstallerUploaded: + temp.FirmwareInstallerUploaded = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a FirmwareInstallerUploaded implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return installTaskID, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no FirmwareInstallerUploaded implementations found"), + ), + ) + } + + return firmwareInstallUploaded(ctx, component, uploadTaskID, implementations) +} + +type FirmwareInstallStepsGetter interface { + FirmwareInstallSteps(ctx context.Context, component string) ([]constants.FirmwareInstallStep, error) +} + +// firmwareInstallStepsGetterProvider is an internal struct to correlate an implementation/provider and its name +type firmwareInstallStepsGetterProvider struct { + name string + FirmwareInstallStepsGetter +} + +// FirmwareInstallStepsFromInterfaces identifies implementations of the FirmwareInstallStepsGetter interface and passes the found implementations to the firmwareInstallSteps() wrapper. +func FirmwareInstallStepsFromInterfaces(ctx context.Context, component string, generic []interface{}) (steps []constants.FirmwareInstallStep, metadata Metadata, err error) { + implementations := make([]firmwareInstallStepsGetterProvider, 0) + for _, elem := range generic { + temp := firmwareInstallStepsGetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FirmwareInstallStepsGetter: + temp.FirmwareInstallStepsGetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a FirmwareInstallStepsGetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return steps, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no FirmwareInstallStepsGetter implementations found"), + ), + ) + } + + return firmwareInstallSteps(ctx, component, implementations) +} + +func firmwareInstallSteps(ctx context.Context, component string, generic []firmwareInstallStepsGetterProvider) (steps []constants.FirmwareInstallStep, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range generic { + if elem.FirmwareInstallStepsGetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return steps, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + steps, vErr := elem.FirmwareInstallSteps(ctx, component) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadataLocal.SuccessfulProvider = elem.name + return steps, metadataLocal, nil + } + } + + return steps, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareInstallSteps")) +} + +type FirmwareUploader interface { + FirmwareUpload(ctx context.Context, component string, file *os.File) (uploadVerifyTaskID string, err error) +} + +// firmwareUploaderProvider is an internal struct to correlate an implementation/provider and its name +type firmwareUploaderProvider struct { + name string + FirmwareUploader +} + +// FirmwareUploaderFromInterfaces identifies implementations of the FirmwareUploader interface and passes the found implementations to the firmwareUpload() wrapper. +func FirmwareUploadFromInterfaces(ctx context.Context, component string, file *os.File, generic []interface{}) (taskID string, metadata Metadata, err error) { + implementations := make([]firmwareUploaderProvider, 0) + for _, elem := range generic { + temp := firmwareUploaderProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FirmwareUploader: + temp.FirmwareUploader = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a FirmwareUploader implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return taskID, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no FirmwareUploader implementations found"), + ), + ) + } + + return firmwareUpload(ctx, component, file, implementations) +} + +func firmwareUpload(ctx context.Context, component string, file *os.File, generic []firmwareUploaderProvider) (taskID string, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range generic { + if elem.FirmwareUploader == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return taskID, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + taskID, vErr := elem.FirmwareUpload(ctx, component, file) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadataLocal.SuccessfulProvider = elem.name + return taskID, metadataLocal, nil + } + } + + return taskID, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareUpload")) +} + +// FirmwareTaskVerifier defines an interface to check the status for firmware related tasks queued on the BMC. +// these could be a an firmware upload and verify task or a firmware install task. +// +// This is to replace the FirmwareInstallVerifier interface +type FirmwareTaskVerifier interface { + // FirmwareTaskStatus returns the status of the firmware upload process. + // + // parameters: + // kind (required) - The FirmwareInstallStep + // component (optional) - the component slug for the component that the firmware was uploaded for. + // taskID (required) - the task identifier. + // installVersion (optional) - the firmware version being installed as part of the task if applicable. + // + // return values: + // state - returns one of the FirmwareTask statuses (see devices/constants.go). + // status - returns firmware task progress or other arbitrary task information. + FirmwareTaskStatus(ctx context.Context, kind bconsts.FirmwareInstallStep, component, taskID, installVersion string) (state string, status string, err error) +} + +// firmwareTaskVerifierProvider is an internal struct to correlate an implementation/provider and its name +type firmwareTaskVerifierProvider struct { + name string + FirmwareTaskVerifier +} + +// firmwareTaskStatus returns the status of the firmware upload process. +func firmwareTaskStatus(ctx context.Context, kind bconsts.FirmwareInstallStep, component, taskID, installVersion string, generic []firmwareTaskVerifierProvider) (state, status string, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range generic { + if elem.FirmwareTaskVerifier == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return state, status, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + state, status, vErr := elem.FirmwareTaskStatus(ctx, kind, component, taskID, installVersion) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadataLocal.SuccessfulProvider = elem.name + return state, status, metadataLocal, nil + } + } + + return state, status, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareTaskStatus")) +} + +// FirmwareTaskStatusFromInterfaces identifies implementations of the FirmwareTaskVerifier interface and passes the found implementations to the firmwareTaskStatus() wrapper. +func FirmwareTaskStatusFromInterfaces(ctx context.Context, kind bconsts.FirmwareInstallStep, component, taskID, installVersion string, generic []interface{}) (state, status string, metadata Metadata, err error) { + implementations := make([]firmwareTaskVerifierProvider, 0) + for _, elem := range generic { + temp := firmwareTaskVerifierProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FirmwareTaskVerifier: + temp.FirmwareTaskVerifier = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a FirmwareTaskVerifier implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return state, status, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no FirmwareTaskVerifier implementations found"), + ), + ) + } + + return firmwareTaskStatus(ctx, kind, component, taskID, installVersion, implementations) +} diff --git a/bmc/firmware_test.go b/bmc/firmware_test.go index 26504a19..454db942 100644 --- a/bmc/firmware_test.go +++ b/bmc/firmware_test.go @@ -3,6 +3,7 @@ package bmc import ( "context" "io" + "os" "testing" "time" @@ -39,9 +40,9 @@ func TestFirmwareInstall(t *testing.T) { providerName string providersAttempted int }{ - {"success with metadata", common.SlugBIOS, constants.FirmwareApplyOnReset, false, nil, "1234", nil, 5 * time.Second, "foo", 1}, - {"failure with metadata", common.SlugBIOS, constants.FirmwareApplyOnReset, false, nil, "1234", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, - {"failure with context timeout", common.SlugBIOS, constants.FirmwareApplyOnReset, false, nil, "1234", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + {"success with metadata", common.SlugBIOS, string(constants.OnReset), false, nil, "1234", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", common.SlugBIOS, string(constants.OnReset), false, nil, "1234", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", common.SlugBIOS, string(constants.OnReset), false, nil, "1234", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, } for _, tc := range testCases { @@ -79,8 +80,8 @@ func TestFirmwareInstallFromInterfaces(t *testing.T) { providerName string badImplementation bool }{ - {"success with metadata", common.SlugBIOS, constants.FirmwareApplyOnReset, false, nil, "1234", nil, "foo", false}, - {"failure with metadata", common.SlugBIOS, constants.FirmwareApplyOnReset, false, nil, "1234", bmclibErrs.ErrProviderImplementation, "foo", true}, + {"success with metadata", common.SlugBIOS, string(constants.OnReset), false, nil, "1234", nil, "foo", false}, + {"failure with metadata", common.SlugBIOS, string(constants.OnReset), false, nil, "1234", bmclibErrs.ErrProviderImplementation, "foo", true}, } for _, tc := range testCases { @@ -162,6 +163,7 @@ func TestFirmwareInstallStatus(t *testing.T) { }) } } + func TestFirmwareInstallStatusFromInterfaces(t *testing.T) { testCases := []struct { testName string @@ -202,3 +204,362 @@ func TestFirmwareInstallStatusFromInterfaces(t *testing.T) { }) } } + +type firmwareInstallUploadTester struct { + TaskID string + Err error +} + +func (f *firmwareInstallUploadTester) FirmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (taskID string, err error) { + return f.TaskID, f.Err +} + +func (r *firmwareInstallUploadTester) Name() string { + return "foo" +} + +func TestFirmwareInstallUploaded(t *testing.T) { + testCases := []struct { + testName string + component string + uploadTaskID string + returnTaskID string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", common.SlugBIOS, "1234", "5678", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", common.SlugBIOS, "1234", "", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", common.SlugBIOS, "1234", "", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + mockImplementation := &firmwareInstallUploadTester{TaskID: tc.returnTaskID, Err: tc.returnError} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 4 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + + taskID, metadata, err := firmwareInstallUploaded(ctx, tc.component, tc.uploadTaskID, []firmwareInstallerWithOptionsProvider{{tc.providerName, mockImplementation}}) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.returnTaskID, taskID) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + assert.Equal(t, tc.providersAttempted, len(metadata.ProvidersAttempted)) + }) + } +} + +func TestFirmwareInstallerUploadedFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + component string + uploadTaskID string + returnTaskID string + returnError error + providerName string + badImplementation bool + }{ + {"success with metadata", common.SlugBIOS, "1234", "5678", nil, "foo", false}, + {"failure with bad implementation", common.SlugBIOS, "1234", "", bmclibErrs.ErrProviderImplementation, "foo", true}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + var generic []interface{} + if tc.badImplementation { + badImplementation := struct{}{} + generic = []interface{}{&badImplementation} + } else { + mockImplementation := &firmwareInstallUploadTester{TaskID: tc.returnTaskID, Err: tc.returnError} + generic = []interface{}{mockImplementation} + } + + installTaskID, metadata, err := FirmwareInstallerUploadedFromInterfaces(context.Background(), tc.component, tc.uploadTaskID, generic) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.returnTaskID, installTaskID) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + }) + } +} + +type firmwareUploadTester struct { + returnTaskID string + returnError error +} + +func (f *firmwareUploadTester) FirmwareUpload(ctx context.Context, component string, file *os.File) (uploadVerifyTaskID string, err error) { + return f.returnTaskID, f.returnError +} + +func (r *firmwareUploadTester) Name() string { + return "foo" +} + +func TestFirmwareUpload(t *testing.T) { + testCases := []struct { + testName string + component string + file *os.File + returnTaskID string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", common.SlugBIOS, nil, "1234", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", common.SlugBIOS, nil, "1234", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", common.SlugBIOS, nil, "1234", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + testImplementation := firmwareUploadTester{returnTaskID: tc.returnTaskID, returnError: tc.returnError} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + taskID, metadata, err := firmwareUpload(ctx, tc.component, tc.file, []firmwareUploaderProvider{{tc.providerName, &testImplementation}}) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.returnTaskID, taskID) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + assert.Equal(t, tc.providersAttempted, len(metadata.ProvidersAttempted)) + }) + } +} + +type firmwareInstallStepsGetterTester struct { + Steps []constants.FirmwareInstallStep + Err error +} + +func (m *firmwareInstallStepsGetterTester) FirmwareInstallSteps(ctx context.Context, component string) ([]constants.FirmwareInstallStep, error) { + return m.Steps, m.Err +} + +func (m *firmwareInstallStepsGetterTester) Name() string { + return "foo" +} + +func TestFirmwareInstallStepsFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + component string + returnSteps []constants.FirmwareInstallStep + returnError error + providerName string + badImplementation bool + }{ + {"success with metadata", common.SlugBIOS, []constants.FirmwareInstallStep{constants.FirmwareInstallStepUpload, constants.FirmwareInstallStepInstallStatus}, nil, "foo", false}, + {"failure with bad implementation", common.SlugBIOS, nil, bmclibErrs.ErrProviderImplementation, "foo", true}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + var generic []interface{} + if tc.badImplementation { + badImplementation := struct{}{} + generic = []interface{}{&badImplementation} + } else { + mockImplementation := &firmwareInstallStepsGetterTester{Steps: tc.returnSteps, Err: tc.returnError} + generic = []interface{}{mockImplementation} + } + + steps, metadata, err := FirmwareInstallStepsFromInterfaces(context.Background(), tc.component, generic) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.returnSteps, steps) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + }) + } +} + +type firmwareInstallStepsTester struct { + returnSteps []constants.FirmwareInstallStep + returnError error +} + +func (f *firmwareInstallStepsTester) FirmwareInstallSteps(ctx context.Context, component string) (steps []constants.FirmwareInstallStep, err error) { + return f.returnSteps, f.returnError +} + +func (r *firmwareInstallStepsTester) Name() string { + return "foo" +} + +func TestFirmwareInstallSteps(t *testing.T) { + testCases := []struct { + testName string + component string + returnSteps []constants.FirmwareInstallStep + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", common.SlugBIOS, []constants.FirmwareInstallStep{constants.FirmwareInstallStepUpload, constants.FirmwareInstallStepInstallStatus}, nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", common.SlugBIOS, nil, errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", common.SlugBIOS, nil, context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + testImplementation := firmwareInstallStepsTester{returnSteps: tc.returnSteps, returnError: tc.returnError} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + steps, metadata, err := firmwareInstallSteps(ctx, tc.component, []firmwareInstallStepsGetterProvider{{tc.providerName, &testImplementation}}) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.returnSteps, steps) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + assert.Equal(t, tc.providersAttempted, len(metadata.ProvidersAttempted)) + }) + } +} + +type firmwareTaskStatusTester struct { + returnState string + returnStatus string + returnError error +} + +func (f *firmwareTaskStatusTester) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state string, status string, err error) { + return f.returnState, f.returnStatus, f.returnError +} + +func (r *firmwareTaskStatusTester) Name() string { + return "foo" +} + +func TestFirmwareTaskStatus(t *testing.T) { + testCases := []struct { + testName string + kind constants.FirmwareInstallStep + component string + taskID string + installVersion string + returnState string + returnStatus string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.FirmwareInstallComplete, "Upload completed", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.FirmwareInstallFailed, "Upload failed", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", "", "", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + testImplementation := firmwareTaskStatusTester{returnState: tc.returnState, returnStatus: tc.returnStatus, returnError: tc.returnError} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + state, status, metadata, err := firmwareTaskStatus(ctx, tc.kind, tc.component, tc.taskID, tc.installVersion, []firmwareTaskVerifierProvider{{tc.providerName, &testImplementation}}) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.returnState, state) + assert.Equal(t, tc.returnStatus, status) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + assert.Equal(t, tc.providersAttempted, len(metadata.ProvidersAttempted)) + }) + } +} + +func TestFirmwareTaskStatusFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + kind constants.FirmwareInstallStep + component string + taskID string + installVersion string + returnState string + returnStatus string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.FirmwareInstallComplete, "uploading", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.FirmwareInstallFailed, "failed", errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with context timeout", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", "", "", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + testImplementation := firmwareTaskStatusTester{ + returnState: tc.returnState, + returnStatus: tc.returnStatus, + returnError: tc.returnError, + } + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + state, status, metadata, err := FirmwareTaskStatusFromInterfaces(ctx, tc.kind, tc.component, tc.taskID, tc.installVersion, []interface{}{&testImplementation}) + if tc.returnError != nil { + assert.ErrorIs(t, err, tc.returnError) + return + } + + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.returnState, state) + assert.Equal(t, tc.returnStatus, status) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + assert.Equal(t, tc.providersAttempted, len(metadata.ProvidersAttempted)) + }) + } +} diff --git a/client.go b/client.go index 4f62e6ad..a1917543 100644 --- a/client.go +++ b/client.go @@ -7,11 +7,13 @@ import ( "fmt" "io" "net/http" + "os" "sync" "time" "dario.cat/mergo" "github.com/bmc-toolbox/bmclib/v2/bmc" + "github.com/bmc-toolbox/bmclib/v2/constants" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/providers/asrockrack" "github.com/bmc-toolbox/bmclib/v2/providers/dell" @@ -419,12 +421,14 @@ func (c *Client) GetBiosConfiguration(ctx context.Context) (biosConfig map[strin } // FirmwareInstall pass through library function to upload firmware and install firmware -func (c *Client) FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (taskID string, err error) { - taskID, metadata, err := bmc.FirmwareInstallFromInterfaces(ctx, component, applyAt, forceInstall, reader, c.registry().GetDriverInterfaces()) +func (c *Client) FirmwareInstall(ctx context.Context, component string, operationApplyTime string, forceInstall bool, reader io.Reader) (taskID string, err error) { + taskID, metadata, err := bmc.FirmwareInstallFromInterfaces(ctx, component, operationApplyTime, forceInstall, reader, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) return taskID, err } +// Note: this interface is to be deprecated in favour of a more generic FirmwareTaskStatus. +// // FirmwareInstallStatus pass through library function to check firmware install status func (c *Client) FirmwareInstallStatus(ctx context.Context, installVersion, component, taskID string) (status string, err error) { status, metadata, err := bmc.FirmwareInstallStatusFromInterfaces(ctx, installVersion, component, taskID, c.registry().GetDriverInterfaces()) @@ -467,3 +471,31 @@ func (c *Client) UnmountFloppyImage(ctx context.Context) (err error) { return err } + +// FirmwareInstallSteps return the order of actions required install firmware for a component. +func (c *Client) FirmwareInstallSteps(ctx context.Context, component string) (actions []constants.FirmwareInstallStep, err error) { + status, metadata, err := bmc.FirmwareInstallStepsFromInterfaces(ctx, component, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return status, err +} + +// FirmwareUpload just uploads the firmware for install, it returns a task ID to verify the upload status. +func (c *Client) FirmwareUpload(ctx context.Context, component string, file *os.File) (uploadVerifyTaskID string, err error) { + uploadVerifyTaskID, metadata, err := bmc.FirmwareUploadFromInterfaces(ctx, component, file, c.Registry.GetDriverInterfaces()) + c.setMetadata(metadata) + return uploadVerifyTaskID, err +} + +// FirmwareTaskStatus pass through library function to check firmware task statuses +func (c *Client) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state, status string, err error) { + state, status, metadata, err := bmc.FirmwareTaskStatusFromInterfaces(ctx, kind, component, taskID, installVersion, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return state, status, err +} + +// FirmwareInstallUploaded kicks off firmware install for a firmware uploaded with FirmwareUpload. +func (c *Client) FirmwareInstallUploaded(ctx context.Context, component, uploadVerifyTaskID string) (installTaskID string, err error) { + installTaskID, metadata, err := bmc.FirmwareInstallerUploadedFromInterfaces(ctx, component, uploadVerifyTaskID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return installTaskID, err +} diff --git a/constants/constants.go b/constants/constants.go index b4e7ce22..2bd1b80a 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -1,6 +1,12 @@ package constants -import "strings" +type ( + // Redfish operation apply time parameter + OperationApplyTime string + + // The FirmwareInstallStep identifies each phase of a firmware install process. + FirmwareInstallStep string +) const ( // Unknown is the constant that defines unknown things @@ -27,9 +33,13 @@ const ( // Redfish firmware apply at constants // FirmwareApplyImmediate sets the firmware to be installed immediately after upload - FirmwareApplyImmediate = "Immediate" + Immediate OperationApplyTime = "Immediate" //FirmwareApplyOnReset sets the firmware to be install on device power cycle/reset - FirmwareApplyOnReset = "OnReset" + OnReset OperationApplyTime = "OnReset" + // FirmwareOnStartUpdateRequest sets the firmware install to begin after the start request has been sent. + OnStartUpdateRequest OperationApplyTime = "OnStartUpdateRequest" + + // TODO: rename FirmwareInstall* task status names to FirmwareTaskState and declare a type. // Firmware install states returned by bmclib provider FirmwareInstallStatus implementations // @@ -60,13 +70,32 @@ const ( FirmwareInstallFailed = "failed" // FirmwareInstallPowerCycleHost indicates the firmware install requires a host power cycle - FirmwareInstallPowerCyleHost = "powercycle-host" + FirmwareInstallPowerCycleHost = "powercycle-host" // FirmwareInstallPowerCycleBMC indicates the firmware install requires a BMC power cycle FirmwareInstallPowerCycleBMC = "powercycle-bmc" FirmwareInstallUnknown = "unknown" + // FirmwareInstallStepUploadInitiateInstall identifies the step to upload _and_ initialize the firmware install. + // as part of the same call. + FirmwareInstallStepUploadInitiateInstall FirmwareInstallStep = "upload-initiate-install" + + // FirmwareInstallStepInstallStatus identifies the step to verify the status of the firmware install. + FirmwareInstallStepInstallStatus FirmwareInstallStep = "install-status" + + // FirmwareInstallStepUpload identifies the upload step in the firmware install process. + FirmwareInstallStepUpload FirmwareInstallStep = "upload" + + // FirmwareInstallStepUploadStatus identifies the step to verify the upload status as part of the firmware install status. + FirmwareInstallStepUploadStatus FirmwareInstallStep = "upload-status" + + // FirmwareInstallStepInstallUploaded identifies the step to install firmware uploaded in FirmwareInstallStepUpload. + FirmwareInstallStepInstallUploaded FirmwareInstallStep = "install-uploaded" + + // FirmwareInstallStepPowerOffHost indicates the host requires to be powered off. + FirmwareInstallStepPowerOffHost FirmwareInstallStep = "power-off-host" + // device BIOS/UEFI POST code bmclib identifiers POSTStateBootINIT = "boot-init/pxe" POSTStateUEFI = "uefi" @@ -78,22 +107,3 @@ const ( func ListSupportedVendors() []string { return []string{HP, Dell, Supermicro} } - -// VendorFromProductName attempts to identify the vendor from the given productname -func VendorFromProductName(productName string) string { - n := strings.ToLower(productName) - switch { - case strings.Contains(n, "intel"): - return Intel - case strings.Contains(n, "dell"): - return Dell - case strings.Contains(n, "supermicro"): - return Supermicro - case strings.Contains(n, "cloudline"): - return Cloudline - case strings.Contains(n, "quanta"): - return Quanta - default: - return productName - } -} diff --git a/errors/errors.go b/errors/errors.go index 3986acf5..80aea51b 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -63,9 +63,18 @@ var ( // ErrFirmwareInstall is returned for firmware install failures ErrFirmwareInstall = errors.New("error updating firmware") + // ErrFirmwareInstallUploaded is returned for a firmware install call on a firmware previously uploaded. + ErrFirmwareInstallUploaded = errors.New("error installing uploaded firmware") + // ErrFirmwareInstallStatus is returned for firmware install status read ErrFirmwareInstallStatus = errors.New("error querying firmware install status") + // ErrFirmwareTaskStatus is returned when a query for the firmware upload status fails + ErrFirmwareTaskStatus = errors.New("error querying firmware upload status") + + // ErrFirmwareVerifyTask indicates a firmware verify task is in progress or did not complete successfully, + ErrFirmwareVerifyTask = errors.New("error firmware upload verify task") + // ErrRedfishUpdateService is returned on redfish update service errors ErrRedfishUpdateService = errors.New("redfish update service error") @@ -105,6 +114,9 @@ var ( // ErrSessionExpired is returned when the BMC session is not valid // the receiver can then choose to request a new session. ErrSessionExpired = errors.New("session expired") + + // ErrSystemVendorModel is returned when the system vendor, model attributes could not be identified. + ErrSystemVendorModel = errors.New("error identifying system vendor, model attributes") ) type ErrUnsupportedHardware struct { diff --git a/examples/install-firmware/main.go b/examples/install-firmware/main.go index fa7d757b..098a0dbf 100644 --- a/examples/install-firmware/main.go +++ b/examples/install-firmware/main.go @@ -79,7 +79,7 @@ func main() { } defer fh.Close() - taskID, err := cl.FirmwareInstall(ctx, *component, constants.FirmwareApplyOnReset, true, fh) + taskID, err := cl.FirmwareInstall(ctx, *component, string(constants.OnReset), true, fh) if err != nil { l.Fatal(err) } @@ -125,7 +125,7 @@ func main() { l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("firmware install completed") os.Exit(0) - case constants.FirmwareInstallPowerCyleHost: + case constants.FirmwareInstallPowerCycleHost: l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("host powercycle required") if _, err := cl.SetPowerState(ctx, "cycle"); err != nil { diff --git a/examples/main.go b/examples/main.go deleted file mode 100644 index 0a10d07d..00000000 --- a/examples/main.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "context" - "crypto/x509" - "flag" - "io/ioutil" - "log" - "os" - "time" - - bmclib "github.com/bmc-toolbox/bmclib/v2" - "github.com/bmc-toolbox/bmclib/v2/constants" - "github.com/bmc-toolbox/common" - "github.com/bombsimon/logrusr/v2" - "github.com/sirupsen/logrus" -) - -func main() { - user := flag.String("user", "", "Username to login with") - pass := flag.String("password", "", "Username to login with") - host := flag.String("host", "", "BMC hostname to connect to") - withSecureTLS := flag.Bool("secure-tls", false, "Enable secure TLS") - certPoolPath := flag.String("cert-pool", "", "Path to an file containing x509 CAs. An empty string uses the system CAs. Only takes effect when --secure-tls=true") - firmwarePath := flag.String("firmware", "", "The local path of the firmware to install") - firmwareVersion := flag.String("version", "", "The firmware version being installed") - - flag.Parse() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - l := logrus.New() - l.Level = logrus.DebugLevel - logger := logrusr.New(l) - - if *host == "" || *user == "" || *pass == "" { - l.Fatal("required host/user/pass parameters not defined") - } - clientOpts := []bmclib.Option{bmclib.WithLogger(logger)} - - if *withSecureTLS { - var pool *x509.CertPool - if *certPoolPath != "" { - pool = x509.NewCertPool() - data, err := ioutil.ReadFile(*certPoolPath) - if err != nil { - l.Fatal(err) - } - pool.AppendCertsFromPEM(data) - } - // a nil pool uses the system certs - clientOpts = append(clientOpts, bmclib.WithSecureTLS(pool)) - } - - cl := bmclib.NewClient(*host, *user, *pass, clientOpts...) - err := cl.Open(ctx) - if err != nil { - l.Fatal(err, "bmc login failed") - } - - defer cl.Close(ctx) - - // collect inventory - inventory, err := cl.Inventory(ctx) - if err != nil { - l.Fatal(err) - } - - l.WithField("bmc-version", inventory.BMC.Firmware.Installed).Info() - - // open file handle - fh, err := os.Open(*firmwarePath) - if err != nil { - l.Fatal(err) - } - defer fh.Close() - - // SlugBMC hardcoded here, this can be any of the existing component slugs from devices/constants.go - // assuming that the BMC provider implements the required component firmware update support - taskID, err := cl.FirmwareInstall(ctx, common.SlugBMC, constants.FirmwareApplyOnReset, true, fh) - if err != nil { - l.Error(err) - } - - state, err := cl.FirmwareInstallStatus(ctx, taskID, common.SlugBMC, *firmwareVersion) - if err != nil { - log.Fatal(err) - } - - l.WithField("state", state).Info("BMC firmware install state") -} diff --git a/go.mod b/go.mod index adef5785..33cbe024 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.31.0 github.com/sirupsen/logrus v1.9.3 - github.com/stmcginnis/gofish v0.14.1-0.20230920133920-77490fd98fa2 + github.com/stmcginnis/gofish v0.14.1-0.20231018151402-dddaff9168fb github.com/stretchr/testify v1.8.0 go.uber.org/goleak v1.2.1 golang.org/x/crypto v0.14.0 diff --git a/go.sum b/go.sum index 4fb41411..85dcade2 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 h1:t95Grn2 github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230/go.mod h1:t2EzW1qybnPDQ3LR/GgeF0GOzHUXT5IVMLP2gkW1cmc= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 h1:a0MBqYm44o0NcthLKCljZHe1mxlN6oahCQHHThnSwB4= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22/go.mod h1:/B7V22rcz4860iDqstGvia/2+IYWXf3/JdQCVd/1D2A= -github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d h1:cQ30Wa8mhLzK1TSOG+g3FlneIsXtFgun61mmPwVPmD0= -github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= github.com/bmc-toolbox/common v0.0.0-20230717121556-5eb9915a8a5a h1:SjtoU9dE3bYfYnPXODCunMztjoDgnE3DVJCPLBqwz6Q= github.com/bmc-toolbox/common v0.0.0-20230717121556-5eb9915a8a5a/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= @@ -20,8 +18,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v1.0.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= @@ -45,12 +41,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -60,18 +52,15 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= -github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stmcginnis/gofish v0.14.1-0.20230920133920-77490fd98fa2 h1:R0N4G786trm1dHBwJftzaupRrwhY1T+rBrTBC8eqiRQ= -github.com/stmcginnis/gofish v0.14.1-0.20230920133920-77490fd98fa2/go.mod h1:BLDSFTp8pDlf/xDbLZa+F7f7eW0E/CHCboggsu8CznI= +github.com/stmcginnis/gofish v0.14.1-0.20231018151402-dddaff9168fb h1:+BpzUuFIEAs71bTshedsUHAAq21VZWvuokbN9ABEQeQ= +github.com/stmcginnis/gofish v0.14.1-0.20231018151402-dddaff9168fb/go.mod h1:BLDSFTp8pDlf/xDbLZa+F7f7eW0E/CHCboggsu8CznI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -82,31 +71,20 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= -golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20230127130021-4ca2cb1a16b7 h1:o7Ps2IYdzLRolS9/nadqeMSHpa9k8pu8u+VKBFUG7cQ= -golang.org/x/exp v0.0.0-20230127130021-4ca2cb1a16b7/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/redfishwrapper/client.go b/internal/redfishwrapper/client.go index 54348a0d..5b40cd00 100644 --- a/internal/redfishwrapper/client.go +++ b/internal/redfishwrapper/client.go @@ -11,12 +11,18 @@ import ( bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" + "github.com/go-logr/logr" "github.com/pkg/errors" "github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish/redfish" "golang.org/x/exp/slices" ) +var ( + ErrManagerID = errors.New("error identifying Manager Odata ID") + ErrBIOSID = errors.New("error identifying System BIOS Odata ID") +) + // Client is a redfishwrapper client which wraps the gofish client. type Client struct { host string @@ -29,6 +35,7 @@ type Client struct { client *gofish.APIClient httpClient *http.Client httpClientSetupFuncs []func(*http.Client) + logger logr.Logger } // Option is a function applied to a *Conn @@ -74,6 +81,13 @@ func WithEtagMatchDisabled(d bool) Option { } } +// WithLogger sets the logger on the redfish wrapper client +func WithLogger(l *logr.Logger) Option { + return func(c *Client) { + c.logger = *l + } +} + // NewClient returns a redfishwrapper client func NewClient(host, port, user, pass string, opts ...Option) *Client { if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") { @@ -85,6 +99,7 @@ func NewClient(host, port, user, pass string, opts ...Option) *Client { port: port, user: user, pass: pass, + logger: logr.Discard(), versionsNotCompatible: []string{}, } @@ -223,3 +238,56 @@ func (c *Client) PatchWithHeaders(ctx context.Context, url string, payload inter func (c *Client) Tasks(ctx context.Context) ([]*redfish.Task, error) { return c.client.Service.Tasks() } + +func (c *Client) ManagerOdataID(ctx context.Context) (string, error) { + managers, err := c.client.Service.Managers() + if err != nil { + return "", errors.Wrap(ErrManagerID, err.Error()) + } + + for _, m := range managers { + if m.ID != "" { + return m.ODataID, nil + } + } + + return "", ErrManagerID +} + +func (c *Client) SystemsBIOSOdataID(ctx context.Context) (string, error) { + systems, err := c.client.Service.Systems() + if err != nil { + return "", errors.Wrap(ErrBIOSID, err.Error()) + } + + for _, s := range systems { + bios, err := s.Bios() + if err != nil { + return "", errors.Wrap(ErrBIOSID, err.Error()) + } + + if bios == nil { + return "", ErrBIOSID + } + + if bios.ID != "" { + return bios.ODataID, nil + } + } + + return "", ErrBIOSID +} + +// DeviceVendorModel returns the device manufacturer and model attributes +func (c *Client) DeviceVendorModel(ctx context.Context) (vendor, model string, err error) { + systems, err := c.client.Service.Systems() + if err != nil { + return "", "", err + } + + for _, sys := range systems { + return sys.Manufacturer, sys.Model, nil + } + + return vendor, model, bmclibErrs.ErrSystemVendorModel +} diff --git a/internal/redfishwrapper/client_test.go b/internal/redfishwrapper/client_test.go index 08fb10e8..a08ba269 100644 --- a/internal/redfishwrapper/client_test.go +++ b/internal/redfishwrapper/client_test.go @@ -1,6 +1,10 @@ package redfishwrapper import ( + "context" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -83,3 +87,134 @@ func TestWithEtagMatchDisabled(t *testing.T) { }) } } + +const ( + fixturesDir = "./fixtures" +) + +func TestManagerOdataID(t *testing.T) { + tests := map[string]struct { + hfunc map[string]func(http.ResponseWriter, *http.Request) + expect string + err error + }{ + "happy case": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + // service root + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/Managers": endpointFunc(t, "managers.json"), + "/redfish/v1/Managers/1": endpointFunc(t, "managers_1.json"), + }, + expect: "/redfish/v1/Managers/1", + err: nil, + }, + "failure case": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "/serviceroot_no_manager.json"), + }, + expect: "", + err: ErrManagerID, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + handleFunc := tc.hfunc + for endpoint, handler := range handleFunc { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + //os.Setenv("DEBUG_BMCLIB", "true") + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "") + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + got, err := client.ManagerOdataID(ctx) + if err != nil { + assert.Equal(t, tc.err, err) + } + + assert.Equal(t, tc.expect, got) + + client.Close(context.Background()) + }) + } +} + +func TestSystemsBIOSOdataID(t *testing.T) { + tests := map[string]struct { + hfunc map[string]func(http.ResponseWriter, *http.Request) + expect string + err error + }{ + "happy case": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + // service root + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/Systems/1": endpointFunc(t, "systems_1.json"), + "/redfish/v1/Systems/1/Bios": endpointFunc(t, "systems_bios.json"), + }, + expect: "/redfish/v1/Systems/1/Bios", + err: nil, + }, + "failure case": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + }, + expect: "", + err: ErrBIOSID, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + handleFunc := tc.hfunc + for endpoint, handler := range handleFunc { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "") + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + got, err := client.SystemsBIOSOdataID(ctx) + if err != nil { + assert.Equal(t, tc.err, err) + } + + assert.Equal(t, tc.expect, got) + + client.Close(context.Background()) + }) + } +} diff --git a/internal/redfishwrapper/firmware.go b/internal/redfishwrapper/firmware.go new file mode 100644 index 00000000..00c6031f --- /dev/null +++ b/internal/redfishwrapper/firmware.go @@ -0,0 +1,433 @@ +package redfishwrapper + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + + "github.com/bmc-toolbox/bmclib/v2/constants" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" +) + +type installMethod string + +const ( + unstructuredHttpPush installMethod = "unstructuredHttpPush" + multipartHttpUpload installMethod = "multipartUpload" +) + +var ( + errMultiPartPayload = errors.New("error preparing multipart payload") + errUpdateParams = errors.New("error in redfish UpdateParameters payload") + errTaskIdFromRespBody = errors.New("failed to identify firmware install taskID from response body") +) + +type RedfishUpdateServiceParameters struct { + Targets []string `json:"Targets"` + OperationApplyTime constants.OperationApplyTime `json:"@Redfish.OperationApplyTime"` + Oem json.RawMessage `json:"Oem"` +} + +// FirmwareUpload uploads and initiates the firmware install process +func (c *Client) FirmwareUpload(ctx context.Context, updateFile *os.File, params *RedfishUpdateServiceParameters) (taskID string, err error) { + parameters, err := json.Marshal(params) + if err != nil { + return "", errors.Wrap(errUpdateParams, err.Error()) + } + + installMethod, installURI, err := c.firmwareInstallMethodURI(ctx) + if err != nil { + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, err.Error()) + } + + // override the gofish HTTP client timeout, + // since the context timeout is set at Open() and is at a lower value than required for this operation. + // + // record the http client timeout to be restored when this method returns + httpClientTimeout := c.HttpClientTimeout() + defer func() { + c.SetHttpClientTimeout(httpClientTimeout) + }() + + ctxDeadline, _ := ctx.Deadline() + c.SetHttpClientTimeout(time.Until(ctxDeadline)) + + var resp *http.Response + + switch installMethod { + case multipartHttpUpload: + var uploadErr error + resp, uploadErr = c.multipartHTTPUpload(ctx, installURI, updateFile, parameters) + if uploadErr != nil { + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, uploadErr.Error()) + } + + case unstructuredHttpPush: + var uploadErr error + resp, uploadErr = c.unstructuredHttpUpload(ctx, installURI, updateFile, parameters) + if uploadErr != nil { + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, uploadErr.Error()) + } + + default: + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, "unsupported install method: "+string(installMethod)) + } + + response, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, err.Error()) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return "", errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "unexpected status code returned: "+resp.Status, + ) + } + + // The response contains a location header pointing to the task URI + // Location: /redfish/v1/TaskService/Tasks/JID_467696020275 + var location = resp.Header.Get("Location") + if strings.Contains(location, "/TaskService/Tasks/") { + return taskIDFromLocationHeader(location) + } + + return taskIDFromResponseBody(response) +} + +// StartUpdateForUploadedFirmware starts an update for a firmware file previously uploaded and returns the taskID +func (c *Client) StartUpdateForUploadedFirmware(ctx context.Context) (taskID string, err error) { + errStartUpdate := errors.New("error in starting update for uploaded firmware") + updateService, err := c.client.Service.UpdateService() + if err != nil { + return "", errors.Wrap(err, "error querying redfish update service") + } + + // start update + resp, err := updateService.GetClient().PostWithHeaders(updateService.StartUpdateTarget, nil, nil) + if err != nil { + return "", errors.Wrap(err, "error querying redfish start update endpoint") + } + + response, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "error reading redfish start update response body") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return "", errors.Wrap(errStartUpdate, "unexpected status code returned: "+resp.Status) + } + + var location = resp.Header.Get("Location") + if strings.Contains(location, "/TaskService/Tasks/") { + return taskIDFromLocationHeader(location) + } + + return taskIDFromResponseBody(response) +} + +type TaskAccepted struct { + Accepted struct { + Code string `json:"code"` + Message string `json:"Message"` + MessageExtendedInfo []struct { + MessageID string `json:"MessageId"` + Severity string `json:"Severity"` + Resolution string `json:"Resolution"` + Message string `json:"Message"` + MessageArgs []string `json:"MessageArgs"` + RelatedProperties []string `json:"RelatedProperties"` + } `json:"@Message.ExtendedInfo"` + } `json:"Accepted"` +} + +func taskIDFromResponseBody(resp []byte) (taskID string, err error) { + a := &TaskAccepted{} + if err = json.Unmarshal(resp, a); err != nil { + return "", errors.Wrap(errTaskIdFromRespBody, err.Error()) + } + + var taskURI string + + for _, info := range a.Accepted.MessageExtendedInfo { + for _, msg := range info.MessageArgs { + if !strings.Contains(msg, "/TaskService/Tasks/") { + continue + } + + taskURI = msg + break + } + } + + if taskURI == "" { + return "", errors.Wrap(errTaskIdFromRespBody, "TaskService/Tasks/ URI not identified") + } + + tokens := strings.Split(taskURI, "/") + if len(tokens) == 0 { + return "", errors.Wrap(errTaskIdFromRespBody, "invalid/unsupported task URI: "+taskURI) + } + + return tokens[len(tokens)-1], nil +} + +func taskIDFromLocationHeader(uri string) (taskID string, err error) { + uri = strings.TrimSuffix(uri, "/") + + switch { + // idracs return /redfish/v1/TaskService/Tasks/JID_467696020275 + case strings.Contains(uri, "JID_"): + taskID = strings.Split(uri, "JID_")[1] + return taskID, nil + + // OpenBMC returns /redfish/v1/TaskService/Tasks/12/Monitor + case strings.Contains(uri, "/Tasks/") && strings.HasSuffix(uri, "/Monitor"): + taskIDPart := strings.Split(uri, "/Tasks/")[1] + taskID := strings.TrimSuffix(taskIDPart, "/Monitor") + return taskID, nil + + case strings.Contains(uri, "Tasks/"): + taskIDPart := strings.Split(uri, "/Tasks/")[1] + return taskIDPart, nil + + default: + return "", errors.Wrap(bmclibErrs.ErrTaskNotFound, "failed to parse taskID from uri: "+uri) + } +} + +type multipartPayload struct { + updateParameters []byte + updateFile *os.File +} + +func (c *Client) multipartHTTPUpload(ctx context.Context, url string, update *os.File, params []byte) (*http.Response, error) { + if url == "" { + return nil, fmt.Errorf("unable to execute request, no target provided") + } + + // payload ordered in the format it ends up in the multipart form + payload := &multipartPayload{ + updateParameters: params, + updateFile: update, + } + + return c.runRequestWithMultipartPayload(url, payload) +} + +func (c *Client) unstructuredHttpUpload(ctx context.Context, url string, update io.Reader, params []byte) (*http.Response, error) { + if url == "" { + return nil, fmt.Errorf("unable to execute request, no target provided") + } + + // TODO: transform this to read the update so that we don't hold the data in memory + b, _ := io.ReadAll(update) + payloadReadSeeker := bytes.NewReader(b) + + return c.RunRawRequestWithHeaders(http.MethodPost, url, payloadReadSeeker, "application/octet-stream", nil) + +} + +// firmwareUpdateMethodURI returns the updateMethod and URI +func (c *Client) firmwareInstallMethodURI(ctx context.Context) (method installMethod, updateURI string, err error) { + updateService, err := c.UpdateService() + if err != nil { + return "", "", errors.Wrap(bmclibErrs.ErrRedfishUpdateService, err.Error()) + } + + // update service disabled + if !updateService.ServiceEnabled { + return "", "", errors.Wrap(bmclibErrs.ErrRedfishUpdateService, "service disabled") + } + + switch { + case updateService.MultipartHTTPPushURI != "": + return multipartHttpUpload, updateService.MultipartHTTPPushURI, nil + case updateService.HTTPPushURI != "": + return unstructuredHttpPush, updateService.HTTPPushURI, nil + } + + return "", "", errors.Wrap(bmclibErrs.ErrRedfishUpdateService, "unsupported update method") +} + +// sets up the UpdateParameters MIMEHeader for the multipart form +// the Go multipart writer CreateFormField does not currently let us set Content-Type on a MIME Header +// https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/mime/multipart/writer.go;l=151 +func updateParametersFormField(fieldName string, writer *multipart.Writer) (io.Writer, error) { + if fieldName != "UpdateParameters" { + return nil, errors.Wrap(errUpdateParams, "expected field not found to create multipart form") + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="UpdateParameters"`) + h.Set("Content-Type", "application/json") + + return writer.CreatePart(h) +} + +// pipeReaderFakeSeeker wraps the io.PipeReader and implements the io.Seeker interface +// to meet the API requirements for the Gofish client https://github.com/stmcginnis/gofish/blob/46b1b33645ed1802727dc4df28f5d3c3da722b15/client.go#L434 +// +// The Gofish method linked does not currently perform seeks and so a PR will be suggested +// to change the method signature to accept an io.Reader instead. +type pipeReaderFakeSeeker struct { + *io.PipeReader +} + +// Seek impelements the io.Seeker interface only to panic if called +func (p pipeReaderFakeSeeker) Seek(offset int64, whence int) (int64, error) { + return 0, errors.New("Seek() not implemented for fake pipe reader seeker.") +} + +// multipartPayloadSize prepares a temporary multipart form to determine the form size +// +// It creates a temporary form without reading in the update file payload and returns +// sizeOf(form) + sizeOf(update file) +func multipartPayloadSize(payload *multipartPayload) (int64, *bytes.Buffer, error) { + body := &bytes.Buffer{} + form := multipart.NewWriter(body) + + // Add UpdateParameters field part + part, err := updateParametersFormField("UpdateParameters", form) + if err != nil { + return 0, body, err + } + + if _, err = io.Copy(part, bytes.NewReader(payload.updateParameters)); err != nil { + return 0, body, err + } + + // Add updateFile form + _, err = form.CreateFormFile("UpdateFile", filepath.Base(payload.updateFile.Name())) + if err != nil { + return 0, body, err + } + + // determine update file size + finfo, err := payload.updateFile.Stat() + if err != nil { + return 0, body, err + } + + // add terminating boundary to multipart form + err = form.Close() + if err != nil { + return 0, body, err + } + + return int64(body.Len()) + finfo.Size(), body, nil +} + +// runRequestWithMultipartPayload is a copy of https://github.com/stmcginnis/gofish/blob/main/client.go#L349 +// with a change to add the UpdateParameters multipart form field with a json content type header +// the resulting form ends up in this format +// +// Content-Length: 416 +// Content-Type: multipart/form-data; boundary=-------------------- +// ----1771f60800cb2801 + +// --------------------------1771f60800cb2801 +// Content-Disposition: form-data; name="UpdateParameters" +// Content-Type: application/json + +// {"Targets": [], "@Redfish.OperationApplyTime": "OnReset", "Oem": +// {}} +// --------------------------1771f60800cb2801 +// Content-Disposition: form-data; name="UpdateFile"; filename="dum +// myfile" +// Content-Type: application/octet-stream + +// hey. +// --------------------------1771f60800cb2801-- +func (c *Client) runRequestWithMultipartPayload(url string, payload *multipartPayload) (*http.Response, error) { + if url == "" { + return nil, fmt.Errorf("unable to execute request, no target provided") + } + + // A content-length header is passed in to indicate the payload size + // + // The Content-length is set explicitly since the payload is an io.Reader, + // https://github.com/golang/go/blob/ddad9b618cce0ed91d66f0470ddb3e12cfd7eeac/src/net/http/request.go#L861 + // + // Without the content-length header the http client will set the Transfer-Encoding to 'chunked' + // and that does not work for some BMCs (iDracs). + contentLength, _, err := multipartPayloadSize(payload) + if err != nil { + return nil, errors.Wrap(err, "error determining multipart payload size") + } + + headers := map[string]string{ + "Content-Length": strconv.FormatInt(contentLength, 10), + } + + // setup pipe + pipeReader, pipeWriter := io.Pipe() + defer pipeReader.Close() + + // initiate a mulitpart writer + form := multipart.NewWriter(pipeWriter) + + // go routine blocks on the io.Copy until the http request is made + go func() { + var err error + defer func() { + if err != nil { + c.logger.Error(err, "multipart upload error occurred") + } + }() + + defer pipeWriter.Close() + + // Add UpdateParameters part + parametersPart, err := updateParametersFormField("UpdateParameters", form) + if err != nil { + c.logger.Error(errMultiPartPayload, err.Error()+": UpdateParameters part copy error") + + return + } + + if _, err = io.Copy(parametersPart, bytes.NewReader(payload.updateParameters)); err != nil { + c.logger.Error(errMultiPartPayload, err.Error()+": UpdateParameters part copy error") + + return + } + + // Add UpdateFile part + updateFilePart, err := form.CreateFormFile("UpdateFile", filepath.Base(payload.updateFile.Name())) + if err != nil { + c.logger.Error(errMultiPartPayload, err.Error()+": UpdateFile part create error") + + return + } + + if _, err = io.Copy(updateFilePart, payload.updateFile); err != nil { + c.logger.Error(errMultiPartPayload, err.Error()+": UpdateFile part copy error") + + return + } + + // add terminating boundary to multipart form + form.Close() + }() + + // pipeReader wrapped as a io.ReadSeeker to satisfy the gofish method signature + reader := pipeReaderFakeSeeker{pipeReader} + + return c.RunRawRequestWithHeaders(http.MethodPost, url, reader, form.FormDataContentType(), headers) +} diff --git a/internal/redfishwrapper/firmware_test.go b/internal/redfishwrapper/firmware_test.go new file mode 100644 index 00000000..db371e4e --- /dev/null +++ b/internal/redfishwrapper/firmware_test.go @@ -0,0 +1,406 @@ +package redfishwrapper + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestRunRequestWithMultipartPayload(t *testing.T) { + defer goleak.VerifyNone(t) + + // init things + tmpdir := t.TempDir() + binPath := filepath.Join(tmpdir, "test.bin") + err := os.WriteFile(binPath, []byte(`HELLOWORLD`), 0600) + if err != nil { + t.Fatal(err) + } + + updateFile, err := os.Open(binPath) + if err != nil { + t.Fatalf("%s -> %s", err.Error(), binPath) + } + + defer updateFile.Close() + defer os.Remove(binPath) + + multipartEndpoint := func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusNotFound) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Fatal(err) + } + + // payload size + expectedContentLength := "476" + + expected := []string{ + `Content-Disposition: form-data; name="UpdateParameters"`, + `Content-Type: application/json`, + `{"Targets":[],"@Redfish.OperationApplyTime":"OnReset","Oem":{}}`, + `Content-Disposition: form-data; name="UpdateFile"; filename="test.bin"`, + `Content-Type: application/octet-stream`, + `HELLOWORLD`, + } + + for _, want := range expected { + assert.Contains(t, string(body), want, "expected value in payload") + } + + assert.Equal(t, expectedContentLength, r.Header.Get("Content-Length")) + + w.Header().Add("Location", "/redfish/v1/TaskService/Tasks/JID_467696020275") + w.WriteHeader(http.StatusAccepted) + } + + tests := map[string]struct { + hfunc map[string]func(http.ResponseWriter, *http.Request) + updateURI string + payload *multipartPayload + err error + }{ + "happy case - multipart push": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/UpdateService/MultipartUpload": multipartEndpoint, + }, + updateURI: "/redfish/v1/UpdateService/MultipartUpload", + payload: &multipartPayload{ + updateParameters: []byte(`{"Targets":[],"@Redfish.OperationApplyTime":"OnReset","Oem":{}}`), + updateFile: updateFile, + }, + err: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + handleFunc := tc.hfunc + for endpoint, handler := range handleFunc { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "", WithBasicAuthEnabled(true)) + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + _, err = client.runRequestWithMultipartPayload(tc.updateURI, tc.payload) + if tc.err != nil { + assert.ErrorContains(t, err, tc.err.Error()) + return + } + + assert.Nil(t, err) + client.Close(context.Background()) + }) + } +} + +func TestFirmwareInstallMethodURI(t *testing.T) { + tests := map[string]struct { + hfunc map[string]func(http.ResponseWriter, *http.Request) + expectInstallMethod installMethod + expectUpdateURI string + err error + }{ + "happy case - multipart push": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/Managers": endpointFunc(t, "managers.json"), + "/redfish/v1/Managers/1": endpointFunc(t, "managers_1.json"), + "/redfish/v1/UpdateService": endpointFunc(t, "updateservice_with_multipart.json"), + }, + expectInstallMethod: multipartHttpUpload, + expectUpdateURI: "/redfish/v1/UpdateService/MultipartUpload", + err: nil, + }, + "happy case - unstructured http push": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/Managers": endpointFunc(t, "managers.json"), + "/redfish/v1/Managers/1": endpointFunc(t, "managers_1.json"), + "/redfish/v1/UpdateService": endpointFunc(t, "updateservice_with_httppushuri.json"), + }, + expectInstallMethod: unstructuredHttpPush, + expectUpdateURI: "/redfish/v1/UpdateService/update", + err: nil, + }, + "failure case - service disabled": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/Managers": endpointFunc(t, "managers.json"), + "/redfish/v1/Managers/1": endpointFunc(t, "managers_1.json"), + "/redfish/v1/UpdateService": endpointFunc(t, "updateservice_disabled.json"), + }, + expectInstallMethod: "", + expectUpdateURI: "", + err: bmclibErrs.ErrRedfishUpdateService, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + handleFunc := tc.hfunc + for endpoint, handler := range handleFunc { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "", WithBasicAuthEnabled(true)) + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + gotMethod, gotURI, err := client.firmwareInstallMethodURI(ctx) + if tc.err != nil { + assert.ErrorContains(t, err, tc.err.Error()) + return + } + + assert.Nil(t, err) + assert.Equal(t, tc.expectInstallMethod, gotMethod) + assert.Equal(t, tc.expectUpdateURI, gotURI) + + client.Close(context.Background()) + }) + } +} + +func TestTaskIDFromResponseBody(t *testing.T) { + testCases := []struct { + name string + body []byte + expectedID string + expectedErr error + }{ + { + name: "happy case", + body: mustReadFile(t, "updateservice_ok_response.json"), + expectedID: "1234", + expectedErr: nil, + }, + { + name: "failure case", + body: mustReadFile(t, "updateservice_unexpected_response.json"), + expectedID: "", + expectedErr: errTaskIdFromRespBody, + }, + { + name: "failure case - invalid json", + body: []byte(`crappy bmc is crappy`), + expectedID: "", + expectedErr: errTaskIdFromRespBody, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + taskID, err := taskIDFromResponseBody(tc.body) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.Nil(t, err) + assert.Equal(t, tc.expectedID, taskID) + }) + } +} + +func TestTaskIDFromLocationHeader(t *testing.T) { + testCases := []struct { + name string + uri string + expectedID string + expectedErr error + }{ + { + name: "task URI with JID", + uri: "http://foo/redfish/v1/TaskService/Tasks/JID_12345", + expectedID: "12345", + expectedErr: nil, + }, + { + name: "task URI with ID", + uri: "http://foo/redfish/v1/TaskService/Tasks/1234", + expectedID: "1234", + expectedErr: nil, + }, + { + name: "task URI with Monitor suffix", + uri: "/redfish/v1/TaskService/Tasks/12/Monitor", + expectedID: "12", + expectedErr: nil, + }, + { + name: "trailing slash removed", + uri: "http://foo/redfish/v1/TaskService/Tasks/1/", + expectedID: "1", + expectedErr: nil, + }, + { + name: "invalid task URI - no task ID", + uri: "http://foo/redfish/v1/TaskService/Tasks/", + expectedID: "", + expectedErr: bmclibErrs.ErrTaskNotFound, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + taskID, err := taskIDFromLocationHeader(tc.uri) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.Nil(t, err) + assert.Equal(t, tc.expectedID, taskID) + }) + } +} + +func TestUpdateParametersFormField(t *testing.T) { + testCases := []struct { + name string + fieldName string + expectedErr error + }{ + { + name: "happy case", + fieldName: "UpdateParameters", + expectedErr: nil, + }, + { + name: "failure case", + fieldName: "InvalidField", + expectedErr: errUpdateParams, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buf := new(bytes.Buffer) + writer := multipart.NewWriter(buf) + + output, err := updateParametersFormField(tc.fieldName, writer) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.Contains(t, buf.String(), `Content-Disposition: form-data; name="UpdateParameters`) + assert.Contains(t, buf.String(), `Content-Type: application/json`) + assert.NotNil(t, output) + + // Validate the created multipart form content + err = writer.Close() + assert.NoError(t, err) + + }) + } +} + +func TestMultipartPayloadSize(t *testing.T) { + updateParameters, err := json.Marshal(struct { + Targets []string `json:"Targets"` + RedfishOpApplyTime string `json:"@Redfish.OperationApplyTime"` + Oem struct{} `json:"Oem"` + }{ + []string{}, + "foobar", + struct{}{}, + }) + + if err != nil { + t.Fatal(err) + } + + tmpdir := t.TempDir() + binPath := filepath.Join(tmpdir, "test.bin") + err = os.WriteFile(binPath, []byte(`HELLOWORLD`), 0600) + if err != nil { + t.Fatal(err) + } + + testfileFH, err := os.Open(binPath) + if err != nil { + t.Fatalf("%s -> %s", err.Error(), binPath) + } + + testCases := []struct { + testName string + payload *multipartPayload + expectedSize int64 + errorMsg string + }{ + { + "content length as expected", + &multipartPayload{ + updateParameters: updateParameters, + updateFile: testfileFH, + }, + 475, + "", + }, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + gotSize, _, err := multipartPayloadSize(tc.payload) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + + assert.Nil(t, err) + assert.Equal(t, tc.expectedSize, gotSize) + }) + } +} diff --git a/internal/redfishwrapper/fixtures/managers.json b/internal/redfishwrapper/fixtures/managers.json new file mode 100644 index 00000000..e99f8a37 --- /dev/null +++ b/internal/redfishwrapper/fixtures/managers.json @@ -0,0 +1,12 @@ +{ + "@odata.type": "#ManagerCollection.ManagerCollection", + "@odata.id": "/redfish/v1/Managers", + "Name": "Manager Collection", + "Description": "Manager Collection", + "Members@odata.count": 1, + "Members": [ + { + "@odata.id": "/redfish/v1/Managers/1" + } + ] +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/managers_1.json b/internal/redfishwrapper/fixtures/managers_1.json new file mode 100644 index 00000000..6eda42a4 --- /dev/null +++ b/internal/redfishwrapper/fixtures/managers_1.json @@ -0,0 +1,92 @@ +{ + "@odata.type": "#Manager.v1_7_0.Manager", + "@odata.id": "/redfish/v1/Managers/1", + "Id": "1", + "Name": "Manager", + "Description": "BMC", + "ManagerType": "BMC", + "UUID": "00000000-0000-0000-0000-3CECEFCEFEDA", + "Model": "ASPEED", + "FirmwareVersion": "01.13.04", + "DateTime": "2023-11-06T14:16:52Z", + "DateTimeLocalOffset": "+00:00", + "Status": { + "State": "Enabled", + "Health": "OK" + }, + "GraphicalConsole": { + "ServiceEnabled": true, + "MaxConcurrentSessions": 4, + "ConnectTypesSupported": [ + "KVMIP" + ] + }, + "SerialConsole": { + "ServiceEnabled": true, + "MaxConcurrentSessions": 1, + "ConnectTypesSupported": [ + "SSH", + "IPMI" + ] + }, + "CommandShell": { + "ServiceEnabled": true, + "MaxConcurrentSessions": 0, + "ConnectTypesSupported": [ + "SSH" + ] + }, + "NetworkProtocol": { + "@odata.id": "/redfish/v1/Managers/1/NetworkProtocol" + }, + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces" + }, + "SerialInterfaces": { + "@odata.id": "/redfish/v1/Managers/1/SerialInterfaces" + }, + "LogServices": { + "@odata.id": "/redfish/v1/Managers/1/LogServices" + }, + "VirtualMedia": { + "@odata.id": "/redfish/v1/Managers/1/VirtualMedia" + }, + "HostInterfaces": { + "@odata.id": "/redfish/v1/Managers/1/HostInterfaces" + }, + "LldpService": { + "@odata.id": "/redfish/v1/Managers/1/LldpService" + }, + "Links": { + "ManagerForServers@odata.count": 1, + "ManagerForServers": [ + { + "@odata.id": "/redfish/v1/Systems/1" + } + ], + "ManagerForChassis@odata.count": 1, + "ManagerForChassis": [ + { + "@odata.id": "/redfish/v1/Chassis/1" + } + ], + "ManagerInChassis": { + "@odata.id": "/redfish/v1/Chassis/1/" + }, + "ActiveSoftwareImage": { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/BMC" + }, + "SoftwareImages@odata.count": 1, + "SoftwareImages": [ + { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/BMC" + } + ], + "Oem": {} + }, + "Actions": { + "#Manager.Reset": { + "target": "/redfish/v1/Managers/1/Actions/Manager.Reset" + } + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/serviceroot.json b/internal/redfishwrapper/fixtures/serviceroot.json new file mode 100644 index 00000000..11078082 --- /dev/null +++ b/internal/redfishwrapper/fixtures/serviceroot.json @@ -0,0 +1,62 @@ +{ + "@odata.type": "#ServiceRoot.v1_5_2.ServiceRoot", + "@odata.id": "/redfish/v1", + "Id": "ServiceRoot", + "Name": "Root Service", + "RedfishVersion": "1.9.0", + "UUID": "00000000-0000-0000-0000-3CECEFCEFEDA", + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "AccountService": { + "@odata.id": "/redfish/v1/AccountService" + }, + "EventService": { + "@odata.id": "/redfish/v1/EventService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + }, + "CertificateService": { + "@odata.id": "/redfish/v1/CertificateService" + }, + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "JsonSchemas": { + "@odata.id": "/redfish/v1/JsonSchemas" + }, + "TelemetryService": { + "@odata.id": "/redfish/v1/TelemetryService" + }, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + }, + "ProtocolFeaturesSupported": { + "FilterQuery": true, + "SelectQuery": true, + "ExcerptQuery": false, + "OnlyMemberQuery": false, + "ExpandQuery": { + "Links": true, + "NoLinks": true, + "ExpandAll": true, + "Levels": true, + "MaxLevels": 2 + } + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/serviceroot_no_manager.json b/internal/redfishwrapper/fixtures/serviceroot_no_manager.json new file mode 100644 index 00000000..cec2bf4f --- /dev/null +++ b/internal/redfishwrapper/fixtures/serviceroot_no_manager.json @@ -0,0 +1,59 @@ +{ + "@odata.type": "#ServiceRoot.v1_5_2.ServiceRoot", + "@odata.id": "/redfish/v1", + "Id": "ServiceRoot", + "Name": "Root Service", + "RedfishVersion": "1.9.0", + "UUID": "00000000-0000-0000-0000-3CECEFCEFEDA", + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "AccountService": { + "@odata.id": "/redfish/v1/AccountService" + }, + "EventService": { + "@odata.id": "/redfish/v1/EventService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + }, + "CertificateService": { + "@odata.id": "/redfish/v1/CertificateService" + }, + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "JsonSchemas": { + "@odata.id": "/redfish/v1/JsonSchemas" + }, + "TelemetryService": { + "@odata.id": "/redfish/v1/TelemetryService" + }, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + }, + "ProtocolFeaturesSupported": { + "FilterQuery": true, + "SelectQuery": true, + "ExcerptQuery": false, + "OnlyMemberQuery": false, + "ExpandQuery": { + "Links": true, + "NoLinks": true, + "ExpandAll": true, + "Levels": true, + "MaxLevels": 2 + } + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/systems.json b/internal/redfishwrapper/fixtures/systems.json new file mode 100644 index 00000000..7bf9aa0b --- /dev/null +++ b/internal/redfishwrapper/fixtures/systems.json @@ -0,0 +1,12 @@ +{ + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "@odata.id": "/redfish/v1/Systems", + "Name": "Computer System Collection", + "Description": "Computer System Collection", + "Members@odata.count": 1, + "Members": [ + { + "@odata.id": "/redfish/v1/Systems/1" + } + ] +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/systems_1.json b/internal/redfishwrapper/fixtures/systems_1.json new file mode 100644 index 00000000..3b28cf03 --- /dev/null +++ b/internal/redfishwrapper/fixtures/systems_1.json @@ -0,0 +1,116 @@ +{ + "@odata.type": "#ComputerSystem.v1_8_0.ComputerSystem", + "@odata.id": "/redfish/v1/Systems/1", + "Id": "1", + "Name": "System", + "Description": "Description of server", + "Status": { + "State": "Enabled", + "Health": "Critical" + }, + "SerialNumber": "FOOBAR", + "PartNumber": "SYS-510T-MR1-EI018", + "SystemType": "Physical", + "BiosVersion": "1.6", + "Manufacturer": "Supermicro", + "Model": "SYS-510T-MR1-EI018", + "SKU": "To be filled by O.E.M.", + "UUID": "0032331A-24D7-EC11-8000-3CECEFCEFEDA", + "ProcessorSummary": { + "Count": 1, + "Model": "Intel(R) Xeon(R) processor", + "Status": { + "State": "Enabled", + "Health": "OK", + "HealthRollup": "OK" + }, + "Metrics": { + "@odata.id": "/redfish/v1/Systems/1/ProcessorSummary/ProcessorMetrics" + } + }, + "MemorySummary": { + "TotalSystemMemoryGiB": 64, + "MemoryMirroring": "System", + "Status": { + "State": "Enabled", + "Health": "OK", + "HealthRollup": "OK" + }, + "Metrics": { + "@odata.id": "/redfish/v1/Systems/1/MemorySummary/MemoryMetrics" + } + }, + "IndicatorLED": "Off", + "PowerState": "On", + "Boot": { + "BootSourceOverrideEnabled": "Once", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideTarget@Redfish.AllowableValues": [ + "None", + "Pxe", + "Floppy", + "Cd", + "Usb", + "Hdd", + "BiosSetup", + "UsbCd", + "UefiBootNext", + "UefiHttp" + ], + "BootOptions": { + "@odata.id": "/redfish/v1/Systems/1/BootOptions" + }, + "BootNext": "", + "BootOrder": [ + "Boot0003", + "Boot0006", + "Boot0005" + ] + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/1/Processors" + }, + "Memory": { + "@odata.id": "/redfish/v1/Systems/1/Memory" + }, + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces" + }, + "NetworkInterfaces": { + "@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces" + }, + "SimpleStorage": { + "@odata.id": "/redfish/v1/Systems/1/SimpleStorage" + }, + "Storage": { + "@odata.id": "/redfish/v1/Systems/1/Storage" + }, + "LogServices": { + "@odata.id": "/redfish/v1/Systems/1/LogServices" + }, + "SecureBoot": { + "@odata.id": "/redfish/v1/Systems/1/SecureBoot" + }, + "Bios": { + "@odata.id": "/redfish/v1/Systems/1/Bios" + }, + "Links": { + "Chassis": [ + { + "@odata.id": "/redfish/v1/Chassis/1" + } + ], + "ManagedBy": [ + { + "@odata.id": "/redfish/v1/Managers/1" + } + ] + }, + "Actions": { + "#ComputerSystem.Reset": { + "target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", + "@Redfish.ActionInfo": "/redfish/v1/Systems/1/ResetActionInfo" + } + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/systems_1_no_bios.json b/internal/redfishwrapper/fixtures/systems_1_no_bios.json new file mode 100644 index 00000000..3b28cf03 --- /dev/null +++ b/internal/redfishwrapper/fixtures/systems_1_no_bios.json @@ -0,0 +1,116 @@ +{ + "@odata.type": "#ComputerSystem.v1_8_0.ComputerSystem", + "@odata.id": "/redfish/v1/Systems/1", + "Id": "1", + "Name": "System", + "Description": "Description of server", + "Status": { + "State": "Enabled", + "Health": "Critical" + }, + "SerialNumber": "FOOBAR", + "PartNumber": "SYS-510T-MR1-EI018", + "SystemType": "Physical", + "BiosVersion": "1.6", + "Manufacturer": "Supermicro", + "Model": "SYS-510T-MR1-EI018", + "SKU": "To be filled by O.E.M.", + "UUID": "0032331A-24D7-EC11-8000-3CECEFCEFEDA", + "ProcessorSummary": { + "Count": 1, + "Model": "Intel(R) Xeon(R) processor", + "Status": { + "State": "Enabled", + "Health": "OK", + "HealthRollup": "OK" + }, + "Metrics": { + "@odata.id": "/redfish/v1/Systems/1/ProcessorSummary/ProcessorMetrics" + } + }, + "MemorySummary": { + "TotalSystemMemoryGiB": 64, + "MemoryMirroring": "System", + "Status": { + "State": "Enabled", + "Health": "OK", + "HealthRollup": "OK" + }, + "Metrics": { + "@odata.id": "/redfish/v1/Systems/1/MemorySummary/MemoryMetrics" + } + }, + "IndicatorLED": "Off", + "PowerState": "On", + "Boot": { + "BootSourceOverrideEnabled": "Once", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideTarget@Redfish.AllowableValues": [ + "None", + "Pxe", + "Floppy", + "Cd", + "Usb", + "Hdd", + "BiosSetup", + "UsbCd", + "UefiBootNext", + "UefiHttp" + ], + "BootOptions": { + "@odata.id": "/redfish/v1/Systems/1/BootOptions" + }, + "BootNext": "", + "BootOrder": [ + "Boot0003", + "Boot0006", + "Boot0005" + ] + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/1/Processors" + }, + "Memory": { + "@odata.id": "/redfish/v1/Systems/1/Memory" + }, + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces" + }, + "NetworkInterfaces": { + "@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces" + }, + "SimpleStorage": { + "@odata.id": "/redfish/v1/Systems/1/SimpleStorage" + }, + "Storage": { + "@odata.id": "/redfish/v1/Systems/1/Storage" + }, + "LogServices": { + "@odata.id": "/redfish/v1/Systems/1/LogServices" + }, + "SecureBoot": { + "@odata.id": "/redfish/v1/Systems/1/SecureBoot" + }, + "Bios": { + "@odata.id": "/redfish/v1/Systems/1/Bios" + }, + "Links": { + "Chassis": [ + { + "@odata.id": "/redfish/v1/Chassis/1" + } + ], + "ManagedBy": [ + { + "@odata.id": "/redfish/v1/Managers/1" + } + ] + }, + "Actions": { + "#ComputerSystem.Reset": { + "target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", + "@Redfish.ActionInfo": "/redfish/v1/Systems/1/ResetActionInfo" + } + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/systems_bios.json b/internal/redfishwrapper/fixtures/systems_bios.json new file mode 100644 index 00000000..6e5cec19 --- /dev/null +++ b/internal/redfishwrapper/fixtures/systems_bios.json @@ -0,0 +1,19 @@ +{ + "@odata.context": "/redfish/v1/$metadata#Bios.Bios", + "@odata.id": "/redfish/v1/Systems/1/Bios", + "@odata.type": "#Bios.v1_1_1.Bios", + "Id": "Bios", + "Name": "BIOS Configuration Current Settings", + "Description": "BIOS Configuration Current Settings", + "AttributeRegistry": "BiosAttributeRegistry.v1_0_3", + "Attributes": { + "SmuVersion": "0.36.113.0", + "DxioVersion": "36.637", + "ProcCoreSpeed": "2.80 GHz", + "Proc1Id": "17-31-0", + "Proc1Brand": "AMD EPYC 7402P 24-Core Processor ", + "Proc1L2Cache": "24x512 KB", + "Proc1L3Cache": "128 MB", + "Proc1Microcode": "0x8301052" + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks.json b/internal/redfishwrapper/fixtures/tasks.json new file mode 100644 index 00000000..5e05607f --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks.json @@ -0,0 +1,15 @@ +{ + "@odata.type": "#TaskCollection.TaskCollection", + "@odata.id": "/redfish/v1/TaskService/Tasks", + "Id": "Tasks", + "Name": "Task Collection", + "Members@odata.count": 2, + "Members": [ + { + "@odata.id": "/redfish/v1/TaskService/Tasks/1" + }, + { + "@odata.id": "/redfish/v1/TaskService/Tasks/2" + } + ] +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_completed.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_completed.json new file mode 100644 index 00000000..0c2d24c5 --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_completed.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Completed", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_failed.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_failed.json new file mode 100644 index 00000000..b735b319 --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_failed.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Failed", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_pending.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_pending.json new file mode 100644 index 00000000..22d777fc --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_pending.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Pending", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_running.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_running.json new file mode 100644 index 00000000..cd18091c --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_running.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Running", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_scheduled.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_scheduled.json new file mode 100644 index 00000000..75fc9bc0 --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_scheduled.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Scheduled", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_starting.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_starting.json new file mode 100644 index 00000000..03c83410 --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_starting.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "Starting", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_1_unknown.json b/internal/redfishwrapper/fixtures/tasks/tasks_1_unknown.json new file mode 100644 index 00000000..e67830ad --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_1_unknown.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/1", + "Id": "1", + "Name": "BIOS Verify", + "TaskState": "foobared", + "StartTime": "2023-11-06T12:04:16+00:00", + "EndTime": "2023-11-06T12:05:31+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/tasks/tasks_2.json b/internal/redfishwrapper/fixtures/tasks/tasks_2.json new file mode 100644 index 00000000..65c8c5aa --- /dev/null +++ b/internal/redfishwrapper/fixtures/tasks/tasks_2.json @@ -0,0 +1,27 @@ +{ + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/2", + "Id": "2", + "Name": "BIOS Update", + "TaskState": "Completed", + "StartTime": "2023-11-06T12:05:47+00:00", + "EndTime": "2023-11-06T12:12:37+00:00", + "PercentComplete": 100, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/MaiRrV41mtzxlYvKWrO72tK0LK0e1zL", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/taskservice.json b/internal/redfishwrapper/fixtures/taskservice.json new file mode 100644 index 00000000..f6dbf925 --- /dev/null +++ b/internal/redfishwrapper/fixtures/taskservice.json @@ -0,0 +1,18 @@ +{ + "@odata.type": "#TaskService.v1_1_3.TaskService", + "@odata.id": "/redfish/v1/TaskService", + "Id": "TaskService", + "Name": "Tasks Service", + "DateTime": "2023-11-07T10:17:09Z", + "CompletedTaskOverWritePolicy": "Oldest", + "LifeCycleEventOnTaskStateChange": false, + "Status": { + "State": "Enabled", + "Health": "OK" + }, + "ServiceEnabled": true, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService/Tasks" + }, + "Oem": {} +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/updateservice_disabled.json b/internal/redfishwrapper/fixtures/updateservice_disabled.json new file mode 100644 index 00000000..99181eb2 --- /dev/null +++ b/internal/redfishwrapper/fixtures/updateservice_disabled.json @@ -0,0 +1,41 @@ +{ + "@odata.context": "/redfish/v1/$metadata#UpdateService.UpdateService", + "@odata.id": "/redfish/v1/UpdateService", + "@odata.type": "#UpdateService.v1_8_0.UpdateService", + "Actions": { + "#UpdateService.SimpleUpdate": { + "@Redfish.OperationApplyTimeSupport": { + "@odata.type": "#Settings.v1_3_0.OperationApplyTimeSupport", + "SupportedValues": [ + "Immediate", + "OnReset" + ] + }, + "TransferProtocol@Redfish.AllowableValues": [ + "HTTP", + "NFS", + "CIFS", + "TFTP", + "HTTPS" + ], + "target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate" + } + }, + "Description": "Represents the properties for the Update Service", + "FirmwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory" + }, + "HttpPushUri": "/redfish/v1/UpdateService/FirmwareInventory", + "Id": "UpdateService", + "MaxImageSizeBytes": null, + "MultipartHttpPushUri": "/redfish/v1/UpdateService/MultipartUpload", + "Name": "Update Service", + "ServiceEnabled": false, + "SoftwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/SoftwareInventory" + }, + "Status": { + "Health": "OK", + "State": "Enabled" + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/updateservice_ok_response.json b/internal/redfishwrapper/fixtures/updateservice_ok_response.json new file mode 100644 index 00000000..e245e340 --- /dev/null +++ b/internal/redfishwrapper/fixtures/updateservice_ok_response.json @@ -0,0 +1,20 @@ +{ + "Accepted": { + "code": "Base.v1_10_3.Accepted", + "Message": "Successfully Accepted Request. Please see the location header and ExtendedInfo for more information.", + "@Message.ExtendedInfo": [ + { + "MessageId": "SMC.1.0.OemSimpleupdateAcceptedMessage", + "Severity": "Ok", + "Resolution": "No resolution was required.", + "Message": "Please also check Task Resource /redfish/v1/TaskService/Tasks/1 to see more information.", + "MessageArgs": [ + "/redfish/v1/TaskService/Tasks/1234" + ], + "RelatedProperties": [ + "BiosVerifyAccepted" + ] + } + ] + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/updateservice_unexpected_response.json b/internal/redfishwrapper/fixtures/updateservice_unexpected_response.json new file mode 100644 index 00000000..4cf2293f --- /dev/null +++ b/internal/redfishwrapper/fixtures/updateservice_unexpected_response.json @@ -0,0 +1,16 @@ +{ + "Accepted": { + "code": "Base.v1_10_3.Accepted", + "Message": "Successfully Accepted Request. Please see the location header and ExtendedInfo for more information.", + "@Message.ExtendedInfo": [ + { + "MessageId": "SMC.1.0.OemSimpleupdateAcceptedMessage", + "Severity": "Ok", + "Resolution": "No resolution was required.", + "RelatedProperties": [ + "BiosVerifyAccepted" + ] + } + ] + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/updateservice_with_httppushuri.json b/internal/redfishwrapper/fixtures/updateservice_with_httppushuri.json new file mode 100644 index 00000000..514cb68e --- /dev/null +++ b/internal/redfishwrapper/fixtures/updateservice_with_httppushuri.json @@ -0,0 +1,18 @@ +{ + "@odata.id": "/redfish/v1/UpdateService", + "@odata.type": "#UpdateService.v1_5_0.UpdateService", + "Description": "Service for Software Update", + "FirmwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory" + }, + "HttpPushUri": "/redfish/v1/UpdateService/update", + "HttpPushUriOptions": { + "HttpPushUriApplyTime": { + "ApplyTime": "OnReset" + } + }, + "Id": "UpdateService", + "MaxImageSizeBytes": 35651584, + "Name": "Update Service", + "ServiceEnabled": true +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/updateservice_with_multipart.json b/internal/redfishwrapper/fixtures/updateservice_with_multipart.json new file mode 100644 index 00000000..f946cfa5 --- /dev/null +++ b/internal/redfishwrapper/fixtures/updateservice_with_multipart.json @@ -0,0 +1,41 @@ +{ + "@odata.context": "/redfish/v1/$metadata#UpdateService.UpdateService", + "@odata.id": "/redfish/v1/UpdateService", + "@odata.type": "#UpdateService.v1_8_0.UpdateService", + "Actions": { + "#UpdateService.SimpleUpdate": { + "@Redfish.OperationApplyTimeSupport": { + "@odata.type": "#Settings.v1_3_0.OperationApplyTimeSupport", + "SupportedValues": [ + "Immediate", + "OnReset" + ] + }, + "TransferProtocol@Redfish.AllowableValues": [ + "HTTP", + "NFS", + "CIFS", + "TFTP", + "HTTPS" + ], + "target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate" + } + }, + "Description": "Represents the properties for the Update Service", + "FirmwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/FirmwareInventory" + }, + "HttpPushUri": "/redfish/v1/UpdateService/FirmwareInventory", + "Id": "UpdateService", + "MaxImageSizeBytes": null, + "MultipartHttpPushUri": "/redfish/v1/UpdateService/MultipartUpload", + "Name": "Update Service", + "ServiceEnabled": true, + "SoftwareInventory": { + "@odata.id": "/redfish/v1/UpdateService/SoftwareInventory" + }, + "Status": { + "Health": "OK", + "State": "Enabled" + } +} \ No newline at end of file diff --git a/internal/redfishwrapper/main_test.go b/internal/redfishwrapper/main_test.go new file mode 100644 index 00000000..b322015c --- /dev/null +++ b/internal/redfishwrapper/main_test.go @@ -0,0 +1,43 @@ +package redfishwrapper + +import ( + "io" + "log" + "net/http" + "os" + "testing" +) + +func mustReadFile(t *testing.T, filename string) []byte { + t.Helper() + + fixture := fixturesDir + "/" + filename + fh, err := os.Open(fixture) + if err != nil { + log.Fatal(err) + } + + defer fh.Close() + + b, err := io.ReadAll(fh) + if err != nil { + log.Fatal(err) + } + + return b +} + +var endpointFunc = func(t *testing.T, file string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if file == "404" { + w.WriteHeader(http.StatusNotFound) + } + + // expect either GET or Delete methods + if r.Method != http.MethodGet && r.Method != http.MethodDelete { + w.WriteHeader(http.StatusNotFound) + } + + _, _ = w.Write(mustReadFile(t, file)) + } +} diff --git a/internal/redfishwrapper/task.go b/internal/redfishwrapper/task.go new file mode 100644 index 00000000..869da835 --- /dev/null +++ b/internal/redfishwrapper/task.go @@ -0,0 +1,56 @@ +package redfishwrapper + +import ( + "context" + "fmt" + "strings" + + "github.com/bmc-toolbox/bmclib/v2/constants" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/pkg/errors" + gofishrf "github.com/stmcginnis/gofish/redfish" +) + +func (c *Client) Task(ctx context.Context, taskID string) (*gofishrf.Task, error) { + tasks, err := c.Tasks(ctx) + if err != nil { + return nil, errors.Wrap(err, "error querying redfish tasks") + } + + for _, t := range tasks { + if t.ID != taskID { + continue + } + + return t, nil + } + + return nil, bmclibErrs.ErrTaskNotFound +} + +func (c *Client) TaskStatus(ctx context.Context, taskID string) (state, status string, err error) { + task, err := c.Task(ctx, taskID) + if err != nil { + return "", "", errors.Wrap(err, "error querying redfish for taskID: "+taskID) + } + taskInfo := fmt.Sprintf("id: %s, state: %s, status: %s", task.ID, task.TaskState, task.TaskStatus) + + state = strings.ToLower(string(task.TaskState)) + + switch state { + case "starting", "downloading", "downloaded": + return constants.FirmwareInstallInitializing, taskInfo, nil + case "running", "stopping", "cancelling", "scheduling": + return constants.FirmwareInstallRunning, taskInfo, nil + case "pending", "new": + return constants.FirmwareInstallQueued, taskInfo, nil + case "scheduled": + return constants.FirmwareInstallPowerCycleHost, taskInfo, nil + case "interrupted", "killed", "exception", "cancelled", "suspended", "failed": + return constants.FirmwareInstallFailed, taskInfo, nil + case "completed": + return constants.FirmwareInstallComplete, taskInfo, nil + default: + return constants.FirmwareInstallUnknown, taskInfo, nil + } +} diff --git a/internal/redfishwrapper/task_test.go b/internal/redfishwrapper/task_test.go new file mode 100644 index 00000000..607b150b --- /dev/null +++ b/internal/redfishwrapper/task_test.go @@ -0,0 +1,234 @@ +package redfishwrapper + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/bmc-toolbox/bmclib/v2/constants" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/stretchr/testify/assert" +) + +func TestTaskStatus(t *testing.T) { + type hmap map[string]func(http.ResponseWriter, *http.Request) + withHandler := func(s string, f func(http.ResponseWriter, *http.Request)) hmap { + return hmap{ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/TaskService": endpointFunc(t, "taskservice.json"), + "/redfish/v1/TaskService/Tasks": endpointFunc(t, "tasks.json"), + // "/redfish/v1/TaskService/Tasks/1": endpointFunc(t, "tasks_1.json"), + // "/redfish/v1/TaskService/Tasks/2": endpointFunc(t, "tasks_2.json"), + s: f, + } + } + + tests := map[string]struct { + hmap hmap + expectedState string + expectedStatus string + expectedErr error + }{ + "task in Initializing state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_starting.json"), + ), + expectedState: constants.FirmwareInstallInitializing, + expectedStatus: "id: 1, state: Starting, status: OK", + expectedErr: nil, + }, + "task in Running state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_running.json"), + ), + expectedState: constants.FirmwareInstallRunning, + expectedStatus: "id: 1, state: Running, status: OK", + expectedErr: nil, + }, + "task in Queued state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_pending.json"), + ), + expectedState: constants.FirmwareInstallQueued, + expectedStatus: "id: 1, state: Pending, status: OK", + expectedErr: nil, + }, + "task in PowerCycleHost state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_scheduled.json"), + ), + expectedState: constants.FirmwareInstallPowerCycleHost, + expectedStatus: "id: 1, state: Scheduled, status: OK", + expectedErr: nil, + }, + "task in Failed state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_failed.json"), + ), + expectedState: constants.FirmwareInstallFailed, + expectedStatus: "id: 1, state: Failed, status: OK", + expectedErr: nil, + }, + "task in Complete state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_completed.json"), + ), + expectedState: constants.FirmwareInstallComplete, + expectedStatus: "id: 1, state: Completed, status: OK", + expectedErr: nil, + }, + "unknown task state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_unknown.json"), + ), + expectedState: constants.FirmwareInstallUnknown, + expectedStatus: "id: 1, state: foobared, status: OK", + expectedErr: nil, + }, + "failure case - no task found": { + hmap: hmap{ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/TaskService": endpointFunc(t, "taskservice.json"), + "/redfish/v1/TaskService/Tasks": endpointFunc(t, "tasks.json"), + }, + expectedState: "", + expectedStatus: "", + expectedErr: bmclibErrs.ErrTaskNotFound, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + + for endpoint, handler := range tc.hmap { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "", WithBasicAuthEnabled(true)) + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + state, status, err := client.TaskStatus(ctx, "1") + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.Nil(t, err) + assert.Equal(t, tc.expectedState, state) + assert.Equal(t, tc.expectedStatus, status) + + client.Close(context.Background()) + }) + } +} + +func TestTask(t *testing.T) { + type hmap map[string]func(http.ResponseWriter, *http.Request) + handlers := func() hmap { + return hmap{ + "/redfish/v1/": endpointFunc(t, "serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "systems.json"), + "/redfish/v1/TaskService": endpointFunc(t, "taskservice.json"), + "/redfish/v1/TaskService/Tasks": endpointFunc(t, "tasks.json"), + "/redfish/v1/TaskService/Tasks/1": endpointFunc(t, "/tasks/tasks_1_completed.json"), + "/redfish/v1/TaskService/Tasks/2": endpointFunc(t, "/tasks/tasks_2.json"), + } + } + + tests := map[string]struct { + handlers hmap + taskID string + expectTaskStatus string + expectTaskState string + err error + }{ + "happy case - task 1": { + handlers: handlers(), + taskID: "1", + expectTaskStatus: "OK", + expectTaskState: "Completed", + err: nil, + }, + "happy case - task 2": { + handlers: handlers(), + taskID: "2", + expectTaskStatus: "OK", + expectTaskState: "Completed", + err: nil, + }, + "failure case - no task found": { + handlers: handlers(), + taskID: "3", + err: bmclibErrs.ErrTaskNotFound, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + + for endpoint, handler := range tc.handlers { + mux.HandleFunc(endpoint, handler) + } + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + //os.Setenv("DEBUG_BMCLIB", "true") + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "", WithBasicAuthEnabled(true)) + + err = client.Open(ctx) + if err != nil { + t.Fatal(err) + } + + got, err := client.Task(ctx, tc.taskID) + if tc.err != nil { + fmt.Println(err) + assert.ErrorContains(t, err, tc.err.Error()) + return + } + + assert.Nil(t, err) + assert.NotNil(t, got) + assert.Equal(t, tc.expectTaskStatus, string(got.TaskStatus)) + assert.Equal(t, tc.expectTaskState, string(got.TaskState)) + + client.Close(context.Background()) + }) + } +} diff --git a/providers/asrockrack/inventory.go b/providers/asrockrack/inventory.go index f1ad88ef..5b0f2b3c 100644 --- a/providers/asrockrack/inventory.go +++ b/providers/asrockrack/inventory.go @@ -3,7 +3,6 @@ package asrockrack import ( "context" - "github.com/bmc-toolbox/bmclib/v2/constants" "github.com/bmc-toolbox/common" ) @@ -181,7 +180,7 @@ func (a *ASRockRack) systemAttributes(ctx context.Context, device *common.Device if component.ProductManufacturerName == "N/A" && component.ProductPartNumber != "N/A" { - vendor = constants.VendorFromProductName(component.ProductPartNumber) + vendor = common.FormatVendorName(component.ProductPartNumber) } device.Drives = append(device.Drives, diff --git a/providers/providers.go b/providers/providers.go index b32749b4..b7eb0518 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -30,6 +30,7 @@ const ( // FeatureUnmountFloppyImage means an implementation removes a floppy image that was previously uploaded. FeatureUnmountFloppyImage registrar.Feature = "unmountFloppyImage" // FeatureFirmwareInstall means an implementation that initiates the firmware install process + // FeatureFirmwareInstall means an implementation that uploads _and_ initiates the firmware install process FeatureFirmwareInstall registrar.Feature = "firmwareinstall" // FeatureFirmwareInstallSatus means an implementation that returns the firmware install status FeatureFirmwareInstallStatus registrar.Feature = "firmwareinstallstatus" @@ -41,4 +42,16 @@ const ( FeatureScreenshot registrar.Feature = "screenshot" // FeatureClearSystemEventLog means an implementation that clears the BMC System Event Log (SEL) FeatureClearSystemEventLog registrar.Feature = "clearsystemeventlog" + + // FeatureFirmwareInstallSteps means an implementation returns the steps part of the firmware update process. + FeatureFirmwareInstallSteps registrar.Feature = "firmwareinstallactions" + + // FeatureFirmwareUpload means an implementation that uploads firmware for installing. + FeatureFirmwareUpload registrar.Feature = "firmwareupload" + + // FeatureFirmwareInstallUploaded means an implementation that installs firmware uploaded using the firmwareupload feature. + FeatureFirmwareInstallUploaded registrar.Feature = "firmwareinstalluploaded" + + // FeatureFirmwareTaskStatus identifies an implementaton that can return the status of a firmware upload/install task. + FeatureFirmwareTaskStatus registrar.Feature = "firmwaretaskstatus" ) diff --git a/providers/redfish/firmware.go b/providers/redfish/firmware.go index 9db2f436..8606bfdd 100644 --- a/providers/redfish/firmware.go +++ b/providers/redfish/firmware.go @@ -20,7 +20,6 @@ import ( "github.com/bmc-toolbox/bmclib/v2/constants" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/bmclib/v2/internal" ) var ( @@ -35,16 +34,8 @@ const ( multipartHttpUpload installMethod = "multipartUpload" ) -// SupportedFirmwareApplyAtValues returns the supported redfish firmware applyAt values -func SupportedFirmwareApplyAtValues() []string { - return []string{ - constants.FirmwareApplyImmediate, - constants.FirmwareApplyOnReset, - } -} - // FirmwareInstall uploads and initiates the firmware install process -func (c *Conn) FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (taskID string, err error) { +func (c *Conn) FirmwareInstall(ctx context.Context, component string, operationApplyTime string, forceInstall bool, reader io.Reader) (taskID string, err error) { // limit to *os.File until theres a need for other types of readers updateFile, ok := reader.(*os.File) if !ok { @@ -56,11 +47,6 @@ func (c *Conn) FirmwareInstall(ctx context.Context, component, applyAt string, f return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) } - // validate applyAt parameter - if !internal.StringInSlice(applyAt, SupportedFirmwareApplyAtValues()) { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "invalid applyAt parameter: "+applyAt) - } - // expect atleast 10 minutes left in the deadline to proceed with the update // // this gives the BMC enough time to have the firmware uploaded and return a response to the client. @@ -105,14 +91,14 @@ func (c *Conn) FirmwareInstall(ctx context.Context, component, applyAt string, f switch installMethod { case multipartHttpUpload: var uploadErr error - resp, uploadErr = c.multipartHTTPUpload(ctx, installURI, applyAt, updateFile) + resp, uploadErr = c.multipartHTTPUpload(ctx, installURI, operationApplyTime, updateFile) if uploadErr != nil { return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, uploadErr.Error()) } case unstructuredHttpPush: var uploadErr error - resp, uploadErr = c.unstructuredHttpUpload(ctx, installURI, applyAt, reader) + resp, uploadErr = c.unstructuredHttpUpload(ctx, installURI, operationApplyTime, reader) if uploadErr != nil { return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, uploadErr.Error()) } @@ -164,7 +150,7 @@ type multipartPayload struct { updateFile *os.File } -func (c *Conn) multipartHTTPUpload(ctx context.Context, url, applyAt string, update *os.File) (*http.Response, error) { +func (c *Conn) multipartHTTPUpload(ctx context.Context, url string, operationApplyTime string, update *os.File) (*http.Response, error) { if url == "" { return nil, fmt.Errorf("unable to execute request, no target provided") } @@ -175,7 +161,7 @@ func (c *Conn) multipartHTTPUpload(ctx context.Context, url, applyAt string, upd Oem struct{} `json:"Oem"` }{ []string{}, - applyAt, + operationApplyTime, struct{}{}, }) @@ -192,7 +178,7 @@ func (c *Conn) multipartHTTPUpload(ctx context.Context, url, applyAt string, upd return c.runRequestWithMultipartPayload(url, payload) } -func (c *Conn) unstructuredHttpUpload(ctx context.Context, url, applyAt string, update io.Reader) (*http.Response, error) { +func (c *Conn) unstructuredHttpUpload(ctx context.Context, url string, operationApplyTime string, update io.Reader) (*http.Response, error) { if url == "" { return nil, fmt.Errorf("unable to execute request, no target provided") } @@ -395,7 +381,7 @@ func updateParametersFormField(fieldName string, writer *multipart.Writer) (io.W // FirmwareInstallStatus returns the status of the firmware install task queued func (c *Conn) FirmwareInstallStatus(ctx context.Context, installVersion, component, taskID string) (state string, err error) { - vendor, _, err := c.DeviceVendorModel(ctx) + vendor, _, err := c.redfishwrapper.DeviceVendorModel(ctx) if err != nil { return state, errors.Wrap(err, "unable to determine device vendor, model attributes") } @@ -432,7 +418,7 @@ func (c *Conn) FirmwareInstallStatus(ctx context.Context, installVersion, compon case "pending", "new": return constants.FirmwareInstallQueued, nil case "scheduled": - return constants.FirmwareInstallPowerCyleHost, nil + return constants.FirmwareInstallPowerCycleHost, nil case "interrupted", "killed", "exception", "cancelled", "suspended", "failed": return constants.FirmwareInstallFailed, nil case "completed": diff --git a/providers/redfish/firmware_test.go b/providers/redfish/firmware_test.go index 16b224cc..9af54a34 100644 --- a/providers/redfish/firmware_test.go +++ b/providers/redfish/firmware_test.go @@ -80,7 +80,7 @@ func TestFirmwareInstall(t *testing.T) { tests := []struct { component string - applyAt string + applyAt constants.OperationApplyTime forceInstall bool setRequiredTimeout bool reader io.Reader @@ -91,7 +91,7 @@ func TestFirmwareInstall(t *testing.T) { }{ { common.SlugBIOS, - constants.FirmwareApplyOnReset, + constants.OnReset, false, false, nil, @@ -102,7 +102,7 @@ func TestFirmwareInstall(t *testing.T) { }, { common.SlugBIOS, - constants.FirmwareApplyOnReset, + constants.OnReset, false, false, &os.File{}, @@ -113,18 +113,7 @@ func TestFirmwareInstall(t *testing.T) { }, { common.SlugBIOS, - "invalidApplyAt", - false, - true, - &os.File{}, - "", - bmclibErrs.ErrFirmwareInstall, - "invalid applyAt parameter", - "applyAt parameter invalid", - }, - { - common.SlugBIOS, - constants.FirmwareApplyOnReset, + constants.OnReset, false, true, fh, @@ -135,7 +124,7 @@ func TestFirmwareInstall(t *testing.T) { }, { common.SlugBIOS, - constants.FirmwareApplyOnReset, + constants.OnReset, true, true, fh, @@ -153,11 +142,11 @@ func TestFirmwareInstall(t *testing.T) { ctx, cancel = context.WithTimeout(context.TODO(), 20*time.Minute) } - taskID, err := mockClient.FirmwareInstall(ctx, tc.component, tc.applyAt, tc.forceInstall, tc.reader) + taskID, err := mockClient.FirmwareInstall(ctx, tc.component, string(tc.applyAt), tc.forceInstall, tc.reader) if tc.expectErr != nil { assert.ErrorIs(t, err, tc.expectErr) if tc.expectErrSubStr != "" { - assert.True(t, strings.Contains(err.Error(), tc.expectErrSubStr)) + assert.ErrorContains(t, err, tc.expectErrSubStr) } } else { assert.Nil(t, err) diff --git a/providers/redfish/redfish.go b/providers/redfish/redfish.go index 1a982af0..0a8d2907 100644 --- a/providers/redfish/redfish.go +++ b/providers/redfish/redfish.go @@ -180,23 +180,7 @@ func (c *Conn) Compatible(ctx context.Context) bool { return err == nil } -// DeviceVendorModel returns the device manufacturer and model attributes -func (c *Conn) DeviceVendorModel(ctx context.Context) (vendor, model string, err error) { - systems, err := c.redfishwrapper.Systems() - if err != nil { - return "", "", err - } - - for _, sys := range systems { - if !compatibleOdataID(sys.ODataID, systemsOdataIDs) { - continue - } - return sys.Manufacturer, sys.Model, nil - } - - return vendor, model, bmclibErrs.ErrRedfishSystemOdataID -} // BmcReset power cycles the BMC func (c *Conn) BmcReset(ctx context.Context, resetType string) (ok bool, err error) { diff --git a/providers/redfish/tasks.go b/providers/redfish/tasks.go index ee56a885..c5450980 100644 --- a/providers/redfish/tasks.go +++ b/providers/redfish/tasks.go @@ -77,7 +77,7 @@ func (c *Conn) activeTask(ctx context.Context) (*gofishrf.Task, error) { // GetFirmwareInstallTaskQueued returns the redfish task object for a queued update task func (c *Conn) GetFirmwareInstallTaskQueued(ctx context.Context, component string) (*gofishrf.Task, error) { - vendor, _, err := c.DeviceVendorModel(ctx) + vendor, _, err := c.redfishwrapper.DeviceVendorModel(ctx) if err != nil { return nil, errors.Wrap(err, "unable to determine device vendor, model attributes") } @@ -101,7 +101,7 @@ func (c *Conn) GetFirmwareInstallTaskQueued(ctx context.Context, component strin // purgeQueuedFirmwareInstallTask removes any existing queued firmware install task for the given component slug func (c *Conn) purgeQueuedFirmwareInstallTask(ctx context.Context, component string) error { - vendor, _, err := c.DeviceVendorModel(ctx) + vendor, _, err := c.redfishwrapper.DeviceVendorModel(ctx) if err != nil { return errors.Wrap(err, "unable to determine device vendor, model attributes") } diff --git a/providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf b/providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf new file mode 100644 index 00000000..17a6cd8e Binary files /dev/null and b/providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf differ diff --git a/providers/supermicro/docs/x12.md b/providers/supermicro/docs/x12.md new file mode 100644 index 00000000..b487440f --- /dev/null +++ b/providers/supermicro/docs/x12.md @@ -0,0 +1,118 @@ +curl 'https://10.251.153.157/redfish/v1/UpdateService/upload' \ + -H 'Accept: */*' \ + -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ + -H 'CSRF_TOKEN: p9lTd1+h0qsz/inooljtRbrja+1/z6nBRLuAKV6JJkM' \ + -H 'Connection: keep-alive' \ + -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundarytykIB3S8fDkno3cP' \ + -H 'Cookie: SID=1rnGhJ9HoMI6JpP' \ + -H 'Origin: https://10.251.153.157' \ + -H 'Referer: https://10.251.153.157/cgi/url_redirect.cgi?url_name=topmenu' \ + -H 'Sec-Fetch-Dest: empty' \ + -H 'Sec-Fetch-Mode: cors' \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36' \ + -H 'X-Auth-Token: o5jk3881vldnk3hdkn20wu5kg8brl18r' \ + -H 'X-Requested-With: XMLHttpRequest' \ + -H 'sec-ch-ua: "Google Chrome";v="117", "Not;A=Brand";v="8", "Chromium";v="117"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + --data-raw $'------WebKitFormBoundarytykIB3S8fDkno3cP\r\nContent-Disposition: form-data; name="UpdateParameters"\r\n\r\n{"Targets":["/redfish/v1/Managers/1"],"@Redfish.OperationApplyTime":"OnStartUpdateRequest","Oem":{"Supermicro":{"BMC":{"PreserveCfg":true,"PreserveSdr":true,"PreserveSsl":true}}}}\r\n------WebKitFormBoundarytykIB3S8fDkno3cP\r\nContent-Disposition: form-data; name="UpdateFile"; filename="BMC_X12AST2600-F201MS_20220627_1.13.04_STDsp.bin"\r\nContent-Type: application/macbinary\r\n\r\n\r\n------WebKitFormBoundarytykIB3S8fDkno3cP--\r\n' \ + --compressed + + +// install parameters +{"Targets":["/redfish/v1/Managers/1"],"@Redfish.OperationApplyTime":"OnStartUpdateRequest","Oem":{"Supermicro":{"BMC":{"PreserveCfg":true,"PreserveSdr":true,"PreserveSsl":true}}}} + + ## look for task with name "BMC Verify" + +❯ curl 'https://10.251.153.157/redfish/v1/TaskService/Tasks/1' \ + -H 'Accept: application/json, text/javascript, */*; q=0.01' \ + -H 'CSRF_TOKEN: 10QMfkMegOzCe/WZZARLcs0cpxdDif8tSJcg5ZEnqVw' \ + -H 'Connection: keep-alive' \ + -H 'Content-Type: application/json' \ + -H 'Cookie: SID=1rnGhJ9HoMI6JpP' \ + -H 'X-Auth-Token: o5jk3881vldnk3hdkn20wu5kg8brl18r' \ + --compressed \ + --insecure + + + {"@odata.type":"#Task.v1_4_3.Task","@odata.id":"/redfish/v1/TaskService/Tasks/1","Id":"1","Name":"BMC Verify","TaskState":"Completed","StartTime":"2023-10-06T07:53:25+00:00","EndTime":"2023-10-06T07:53:31+00:00","PercentComplete":100,"HidePayload":true,"TaskMonitor":"/redfish/v1/TaskMonitor/fa37JncCHryDsbzayy4cBWDxS22Jjzh","TaskStatus":"OK","Messages":[{"MessageId":"","RelatedProperties":[""],"Message":"","MessageArgs":[""],"Severity":""}],"Oem":{}} + + ## look for task with "BMC Update" + + { + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/2", + "Id": "2", + "Name": "BMC Update", + "TaskState": "Running", + "StartTime": "2023-10-09T05:42:25+00:00", + "PercentComplete": 2, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/MaiRrV41mtzxlYvKWrO72tK0LK0e1zL", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} + + +curl 'https://10.251.153.157/cgi/upgrade_process.cgi' \ + -H 'Accept: */*' \ + -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ + -H 'CSRF_TOKEN: +5/2t9ZcRuEzRg6MbTU2/j5Ils1VM2zf7uVImW/wVMI' \ + -H 'Connection: keep-alive' \ + -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \ + -H 'Cookie: SID=qK4ZDz2cNet9nor' \ + -H 'Origin: https://10.251.153.157' \ + -H 'Referer: https://10.251.153.157/cgi/url_redirect.cgi?url_name=topmenu' \ + -H 'Sec-Fetch-Dest: empty' \ + -H 'Sec-Fetch-Mode: cors' \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36' \ + -H 'X-Requested-With: XMLHttpRequest' \ + -H 'sec-ch-ua: "Google Chrome";v="117", "Not;A=Brand";v="8", "Chromium";v="117"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + --data-raw 'fwtype=255' \ + --compressed \ + --insecure + + + { + "@odata.type": "#Task.v1_4_3.Task", + "@odata.id": "/redfish/v1/TaskService/Tasks/2", + "Id": "2", + "Name": "BMC Update", + "TaskState": "Running", + "StartTime": "2023-10-13T13:27:51+00:00", + "PercentComplete": 5, + "HidePayload": true, + "TaskMonitor": "/redfish/v1/TaskMonitor/MaiRrV41mtzxlYvKWrO72tK0LK0e1zL", + "TaskStatus": "OK", + "Messages": [ + { + "MessageId": "", + "RelatedProperties": [ + "" + ], + "Message": "", + "MessageArgs": [ + "" + ], + "Severity": "" + } + ], + "Oem": {} +} \ No newline at end of file diff --git a/providers/supermicro/errors.go b/providers/supermicro/errors.go index 14096bb9..c2dcf057 100644 --- a/providers/supermicro/errors.go +++ b/providers/supermicro/errors.go @@ -8,7 +8,10 @@ import ( ) var ( - ErrQueryFRUInfo = errors.New("FRU information query returned error") + ErrQueryFRUInfo = errors.New("FRU information query returned error") + ErrXMLAPIUnsupported = errors.New("XML API is unsupported") + ErrModelUnknown = errors.New("Model number unknown") + ErrModelUnsupported = errors.New("Model not supported") ) type UnexpectedResponseError struct { diff --git a/providers/supermicro/firmware.go b/providers/supermicro/firmware.go index 2d8e5ec6..b0540b04 100644 --- a/providers/supermicro/firmware.go +++ b/providers/supermicro/firmware.go @@ -2,80 +2,22 @@ package supermicro import ( "context" - "io" "os" "strings" "time" + "github.com/bmc-toolbox/bmclib/v2/constants" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/common" "github.com/pkg/errors" ) -// FirmwareInstall uploads and initiates firmware update for the component -func (c *Client) FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (jobID string, err error) { - if err := c.deviceSupported(ctx); err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) - } - - var size int64 - if file, ok := reader.(*os.File); ok { - finfo, err := file.Stat() - if err != nil { - c.log.V(2).Error(err, "unable to determine file size") - } - - size = finfo.Size() - } - - // expect atleast 10 minutes left in the deadline to proceed with the update - d, _ := ctx.Deadline() - if time.Until(d) < 10*time.Minute { - return "", errors.New("remaining context deadline insufficient to perform update: " + time.Until(d).String()) - } - - component = strings.ToUpper(component) - - switch component { - case common.SlugBIOS: - err = c.firmwareInstallBIOS(ctx, reader, size) - case common.SlugBMC: - err = c.firmwareInstallBMC(ctx, reader, size) - default: - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component) - } - - if err != nil { - err = errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) - } - - return jobID, err -} - -// FirmwareInstallStatus returns the status of the firmware install process -func (c *Client) FirmwareInstallStatus(ctx context.Context, installVersion, component, taskID string) (string, error) { - component = strings.ToUpper(component) - - switch component { - case common.SlugBMC: - return c.statusBMCFirmwareInstall(ctx) - case common.SlugBIOS: - return c.statusBIOSFirmwareInstall(ctx) - default: - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component) - } -} - -func (c *Client) deviceSupported(ctx context.Context) error { - errBoardPartNumUnknown := errors.New("baseboard part number unknown") - errBoardUnsupported := errors.New("feature not supported/implemented for device") - - // Its likely this works on all X11's +var ( + // Its likely the X11 code works on all X11's // for now, we list only the ones its been tested on. // // board part numbers // - supported := []string{ + supportedModels = []string{ "X11SCM-F", "X11DPH-T", "X11SCH-F", @@ -83,24 +25,54 @@ func (c *Client) deviceSupported(ctx context.Context) error { "X11DPG-SN", "X11DPT-B", "X11SSE-F", + "X12STH-SYS", } - data, err := c.fruInfo(ctx) - if err != nil { - return err + errUploadTaskIDExpected = errors.New("expected an firmware upload taskID") +) + +// bmc client interface implementations methods +func (c *Client) FirmwareInstallSteps(ctx context.Context, component string) ([]constants.FirmwareInstallStep, error) { + if err := c.serviceClient.supportsFirmwareInstall(ctx, c.bmc.deviceModel()); err != nil { + return nil, err } - if data.Board == nil || strings.TrimSpace(data.Board.PartNum) == "" { - return errors.Wrap(errBoardPartNumUnknown, "baseboard part number empty") + return c.bmc.firmwareInstallSteps(component) +} + +func (c *Client) FirmwareUpload(ctx context.Context, component string, file *os.File) (taskID string, err error) { + if err := c.serviceClient.supportsFirmwareInstall(ctx, c.bmc.deviceModel()); err != nil { + return "", err } - c.model = strings.TrimSpace(data.Board.PartNum) + // // expect atleast 5 minutes left in the deadline to proceed with the upload + d, _ := ctx.Deadline() + if time.Until(d) < 5*time.Minute { + return "", errors.New("remaining context deadline insufficient to perform update: " + time.Until(d).String()) + } - for _, b := range supported { - if strings.EqualFold(b, strings.TrimSpace(data.Board.PartNum)) { - return nil - } + return c.bmc.firmwareUpload(ctx, component, file) +} + +func (c *Client) FirmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (installTaskID string, err error) { + if err := c.serviceClient.supportsFirmwareInstall(ctx, c.bmc.deviceModel()); err != nil { + return "", err } - return errors.Wrap(errBoardUnsupported, data.Board.PartNum) + // x11's don't return a upload Task ID, since the upload mechanism is not redfish + if !strings.HasPrefix(c.bmc.deviceModel(), "x11") && uploadTaskID == "" { + return "", errUploadTaskIDExpected + } + + return c.bmc.firmwareInstallUploaded(ctx, component, uploadTaskID) +} + +// FirmwareTaskStatus returns the status of a firmware related task queued on the BMC. +func (c *Client) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state, status string, err error) { + if err := c.serviceClient.supportsFirmwareInstall(ctx, c.bmc.deviceModel()); err != nil { + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) + } + + component = strings.ToUpper(component) + return c.bmc.firmwareTaskStatus(ctx, component, taskID) } diff --git a/providers/supermicro/firmware_bios_test.go b/providers/supermicro/firmware_bios_test.go index f85914a8..f4bdfb53 100644 --- a/providers/supermicro/firmware_bios_test.go +++ b/providers/supermicro/firmware_bios_test.go @@ -8,6 +8,7 @@ import ( "net/url" "testing" + "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" ) @@ -79,7 +80,10 @@ func Test_setComponentUpdateMisc(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.checkComponentUpdateMisc(context.Background(), tc.stage); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) @@ -165,7 +169,10 @@ func Test_setBIOSFirmwareInstallMode(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.setBMCFirmwareInstallMode(context.Background()); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) diff --git a/providers/supermicro/floppy.go b/providers/supermicro/floppy.go index 3cd3dceb..3c652890 100644 --- a/providers/supermicro/floppy.go +++ b/providers/supermicro/floppy.go @@ -24,7 +24,7 @@ func (c *Client) floppyImageMounted(ctx context.Context) (bool, error) { return false, err } - inserted, err := c.redfish.InsertedVirtualMedia(ctx) + inserted, err := c.serviceClient.redfish.InsertedVirtualMedia(ctx) if err != nil { return false, err } @@ -50,18 +50,23 @@ func (c *Client) MountFloppyImage(ctx context.Context, image io.Reader) error { var payloadBuffer bytes.Buffer - formParts := []struct { + type form struct { name string data io.Reader - }{ + } + + formParts := []form{ { name: "img_file", data: image, }, - { + } + + if c.serviceClient.csrfToken != "" { + formParts = append(formParts, form{ name: "csrf-token", - data: bytes.NewBufferString(c.csrfToken), - }, + data: bytes.NewBufferString(c.serviceClient.csrfToken), + }) } payloadWriter := multipart.NewWriter(&payloadBuffer) @@ -103,7 +108,7 @@ func (c *Client) MountFloppyImage(ctx context.Context, image io.Reader) error { } payloadWriter.Close() - resp, statusCode, err := c.query( + resp, statusCode, err := c.serviceClient.query( ctx, "cgi/uimapin.cgi", http.MethodPost, @@ -133,7 +138,7 @@ func (c *Client) UnmountFloppyImage(ctx context.Context) error { return nil } - resp, statusCode, err := c.query( + resp, statusCode, err := c.serviceClient.query( ctx, "cgi/uimapout.cgi", http.MethodPost, diff --git a/providers/supermicro/supermicro.go b/providers/supermicro/supermicro.go index 29e235a0..bb0e4818 100644 --- a/providers/supermicro/supermicro.go +++ b/providers/supermicro/supermicro.go @@ -5,7 +5,6 @@ import ( "context" "crypto/x509" "encoding/base64" - "encoding/xml" "fmt" "io" "net/http" @@ -17,9 +16,11 @@ import ( "strings" "time" + "github.com/bmc-toolbox/bmclib/v2/constants" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" "github.com/bmc-toolbox/bmclib/v2/providers" + "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" "github.com/pkg/errors" @@ -39,10 +40,12 @@ var ( // Features implemented Features = registrar.Features{ providers.FeatureScreenshot, - providers.FeatureFirmwareInstall, - providers.FeatureFirmwareInstallStatus, providers.FeatureMountFloppyImage, providers.FeatureUnmountFloppyImage, + providers.FeatureFirmwareUpload, + providers.FeatureFirmwareInstallUploaded, + providers.FeatureFirmwareTaskStatus, + providers.FeatureFirmwareInstallSteps, } ) @@ -92,24 +95,25 @@ func WithPort(port string) Option { // Connection details type Client struct { - client *http.Client - host string - user string - pass string - port string - csrfToken string - model string - redfish *redfishwrapper.Client - log logr.Logger - _ [32]byte + serviceClient *serviceClient + bmc bmcQueryor + log logr.Logger +} + +type bmcQueryor interface { + firmwareInstallSteps(component string) ([]constants.FirmwareInstallStep, error) + firmwareUpload(ctx context.Context, component string, file *os.File) (taskID string, err error) + firmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (installTaskID string, err error) + firmwareTaskStatus(ctx context.Context, component, taskID string) (state, status string, err error) + // query device model from the bmc + queryDeviceModel(ctx context.Context) (model string, err error) + // returns the device model, that was queried previously with queryDeviceModel + deviceModel() (model string) + supportsInstall(component string) error } // New returns connection with a Supermicro client initialized func NewClient(host, user, pass string, log logr.Logger, opts ...Option) *Client { - if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") { - host = "https://" + host - } - defaultConfig := &Config{ Port: "443", } @@ -118,13 +122,17 @@ func NewClient(host, user, pass string, log logr.Logger, opts ...Option) *Client opt(defaultConfig) } + serviceClient := newBmcServiceClient( + host, + defaultConfig.Port, + user, + pass, + httpclient.Build(defaultConfig.httpClientSetupFuncs...), + ) + return &Client{ - host: host, - user: user, - pass: pass, - port: defaultConfig.Port, - client: httpclient.Build(defaultConfig.httpClientSetupFuncs...), - log: log, + serviceClient: serviceClient, + log: log, } } @@ -132,13 +140,13 @@ func NewClient(host, user, pass string, log logr.Logger, opts ...Option) *Client func (c *Client) Open(ctx context.Context) (err error) { data := fmt.Sprintf( "name=%s&pwd=%s&check=00", - base64.StdEncoding.EncodeToString([]byte(c.user)), - base64.StdEncoding.EncodeToString([]byte(c.pass)), + base64.StdEncoding.EncodeToString([]byte(c.serviceClient.user)), + base64.StdEncoding.EncodeToString([]byte(c.serviceClient.pass)), ) headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} - body, status, err := c.query(ctx, "cgi/login.cgi", http.MethodPost, bytes.NewBufferString(data), headers, 0) + body, status, err := c.serviceClient.query(ctx, "cgi/login.cgi", http.MethodPost, bytes.NewBufferString(data), headers, 0) if err != nil { return errors.Wrap(bmclibErrs.ErrLoginFailed, err.Error()) } @@ -152,7 +160,7 @@ func (c *Client) Open(ctx context.Context) (err error) { return errors.Wrap(bmclibErrs.ErrLoginFailed, "unexpected response contents") } - contentsTopMenu, status, err := c.query(ctx, "cgi/url_redirect.cgi?url_name=topmenu", http.MethodGet, nil, nil, 0) + contentsTopMenu, status, err := c.serviceClient.query(ctx, "cgi/url_redirect.cgi?url_name=topmenu", http.MethodGet, nil, nil, 0) if err != nil { return errors.Wrap(bmclibErrs.ErrLoginFailed, err.Error()) } @@ -161,40 +169,70 @@ func (c *Client) Open(ctx context.Context) (err error) { return errors.Wrap(bmclibErrs.ErrLoginFailed, strconv.Itoa(status)) } - token := parseToken(contentsTopMenu) - if token == "" { - return errors.Wrap(bmclibErrs.ErrLoginFailed, "could not parse CSRF-TOKEN from page") - } + // Note: older firmware version on the X11s don't use a CSRF token + // so here theres no explicit requirement for it to be found. + // + // X11DPH-T 01.71.11 10/25/2019 + csrfToken := parseToken(contentsTopMenu) + c.serviceClient.setCsrfToken(csrfToken) - c.csrfToken = token + c.bmc, err = c.bmcQueryor(ctx) + if err != nil { + return errors.Wrap(bmclibErrs.ErrLoginFailed, err.Error()) + } return nil } +func (c *Client) bmcQueryor(ctx context.Context) (bmcQueryor, error) { + x11 := newX11Client(c.serviceClient, c.log) + x12 := newX12Client(c.serviceClient, c.log) + + var queryor bmcQueryor + + for _, bmc := range []bmcQueryor{x11, x12} { + var err error + + _, err = bmc.queryDeviceModel(ctx) + if err != nil { + if errors.Is(err, ErrXMLAPIUnsupported) { + continue + } + + return nil, errors.Wrap(ErrModelUnknown, err.Error()) + } + + queryor = bmc + break + } + + if queryor == nil { + return nil, errors.Wrap(ErrModelUnknown, "failed to setup query client") + } + + model := strings.ToLower(queryor.deviceModel()) + if !strings.HasPrefix(model, "x12") && !strings.HasPrefix(model, "x11") { + return nil, errors.Wrap(ErrModelUnsupported, model) + } + + return queryor, nil +} + func (c *Client) openRedfish(ctx context.Context) error { - if c.redfish != nil && c.redfish.SessionActive() == nil { + if c.serviceClient.redfish != nil && c.serviceClient.redfish.SessionActive() == nil { return nil } - rfclient := redfishwrapper.NewClient(c.host, "", c.user, c.pass) + rfclient := redfishwrapper.NewClient(c.serviceClient.host, "", c.serviceClient.user, c.serviceClient.pass) if err := rfclient.Open(ctx); err != nil { return err } - c.redfish = rfclient + c.serviceClient.redfish = rfclient return nil } -func (c *Client) closeRedfish(ctx context.Context) { - if c.redfish != nil { - // error not checked on purpose - _ = c.redfish.Close(ctx) - - c.redfish = nil - } -} - func parseToken(body []byte) string { var key string if bytes.Contains(body, []byte(`CSRF-TOKEN`)) { @@ -209,7 +247,7 @@ func parseToken(body []byte) string { return "" } - re, err := regexp.Compile(`"CSRF_TOKEN", "(?P.*)"`) + re, err := regexp.Compile(fmt.Sprintf(`"%s", "(?P.*)"`, key)) if err != nil { return "" } @@ -224,11 +262,11 @@ func parseToken(body []byte) string { // Close a connection to a Supermicro BMC using the vendor API. func (c *Client) Close(ctx context.Context) error { - if c.client == nil { + if c.serviceClient.client == nil { return nil } - _, status, err := c.query(ctx, "cgi/logout.cgi", http.MethodGet, nil, nil, 0) + _, status, err := c.serviceClient.query(ctx, "cgi/logout.cgi", http.MethodGet, nil, nil, 0) if err != nil { return errors.Wrap(bmclibErrs.ErrLogoutFailed, err.Error()) } @@ -237,7 +275,14 @@ func (c *Client) Close(ctx context.Context) error { return errors.Wrap(bmclibErrs.ErrLogoutFailed, strconv.Itoa(status)) } - c.closeRedfish(ctx) + if c.serviceClient.redfish != nil { + err = c.serviceClient.redfish.Close(ctx) + if err != nil { + return errors.Wrap(bmclibErrs.ErrLogoutFailed, err.Error()) + } + + c.serviceClient.redfish = nil + } return nil } @@ -271,7 +316,7 @@ func (c *Client) fetchScreenPreview(ctx context.Context) ([]byte, error) { headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} endpoint := "cgi/url_redirect.cgi?url_name=Snapshot&url_type=img" - body, status, err := c.query(ctx, endpoint, http.MethodGet, nil, headers, 0) + body, status, err := c.serviceClient.query(ctx, endpoint, http.MethodGet, nil, headers, 0) if err != nil { return nil, errors.Wrap(bmclibErrs.ErrScreenshot, strconv.Itoa(status)) } @@ -288,7 +333,7 @@ func (c *Client) initScreenPreview(ctx context.Context) error { data := "op=sys_preview&_=" - body, status, err := c.query(ctx, "cgi/op.cgi", http.MethodPost, bytes.NewBufferString(data), headers, 0) + body, status, err := c.serviceClient.query(ctx, "cgi/op.cgi", http.MethodPost, bytes.NewBufferString(data), headers, 0) if err != nil { return errors.Wrap(bmclibErrs.ErrScreenshot, err.Error()) } @@ -314,32 +359,6 @@ func (c *Client) PowerSet(ctx context.Context, state string) (ok bool, err error } } -func (c *Client) fruInfo(ctx context.Context) (*FruInfo, error) { - headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} - - payload := "op=FRU_INFO.XML&r=(0,0)&_=" - - body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBufferString(payload), headers, 0) - if err != nil { - return nil, errors.Wrap(ErrQueryFRUInfo, err.Error()) - } - - if status != 200 { - return nil, unexpectedResponseErr([]byte(payload), body, status) - } - - if !bytes.Contains(body, []byte(``)) { - return nil, unexpectedResponseErr([]byte(payload), body, status) - } - - data := &IPMI{} - if err := xml.Unmarshal(body, data); err != nil { - return nil, errors.Wrap(ErrQueryFRUInfo, err.Error()) - } - - return data.FruInfo, nil -} - // powerCycle using SMC XML API // // This method is only here for the case when firmware updates are being applied using this provider. @@ -350,7 +369,7 @@ func (c *Client) powerCycle(ctx context.Context) (bool, error) { "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", } - body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0) + body, status, err := c.serviceClient.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0) if err != nil { return false, err } @@ -362,7 +381,52 @@ func (c *Client) powerCycle(ctx context.Context) (bool, error) { return true, nil } -func (c *Client) query(ctx context.Context, endpoint, method string, payload io.Reader, headers map[string]string, contentLength int64) ([]byte, int, error) { +type serviceClient struct { + host string + port string + user string + pass string + csrfToken string + client *http.Client + redfish *redfishwrapper.Client +} + +func newBmcServiceClient(host, port, user, pass string, client *http.Client) *serviceClient { + if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") { + host = "https://" + host + } + + return &serviceClient{host: host, port: port, user: user, pass: pass, client: client} +} + +func (c *serviceClient) setCsrfToken(t string) { + c.csrfToken = t +} + +func (c *serviceClient) redfishSession(ctx context.Context) (err error) { + c.redfish = redfishwrapper.NewClient(c.host, "", c.user, c.pass, redfishwrapper.WithHTTPClient(c.client)) + if err := c.redfish.Open(ctx); err != nil { + return err + } + + return nil +} + +func (c *serviceClient) supportsFirmwareInstall(ctx context.Context, model string) error { + if model == "" { + return errors.Wrap(ErrModelUnknown, "unable to determine firmware install compatibility") + } + + for _, s := range supportedModels { + if strings.EqualFold(s, model) { + return nil + } + } + + return errors.Wrap(ErrModelUnsupported, "firmware install not supported for: "+model) +} + +func (c *serviceClient) query(ctx context.Context, endpoint, method string, payload io.Reader, headers map[string]string, contentLength int64) ([]byte, int, error) { var body []byte var err error var req *http.Request diff --git a/providers/supermicro/supermicro_test.go b/providers/supermicro/supermicro_test.go index 43508a5f..ca0313c8 100644 --- a/providers/supermicro/supermicro_test.go +++ b/providers/supermicro/supermicro_test.go @@ -3,6 +3,7 @@ package supermicro import ( "context" "io" + "log" "net/http" "net/http/httptest" "net/url" @@ -12,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_parseToken(t *testing.T) { +func TestParseToken(t *testing.T) { testcases := []struct { name string body []byte @@ -49,6 +50,11 @@ func Test_parseToken(t *testing.T) { []byte(``), "RYjdEjWIhU+PCRFMBP2ZRPPePcQ4n3dM3s+rCgTnBBU", }, + { + "token with key type 5 found", + []byte(``), + "RYjdEjWIhU+PCRFMBP2ZRPPePcQ4n3dM3s+rCgTnBBU", + }, } for _, tc := range testcases { @@ -60,7 +66,7 @@ func Test_parseToken(t *testing.T) { } } -func Test_Open(t *testing.T) { +func TestOpen(t *testing.T) { type handlerFuncMap map[string]func(http.ResponseWriter, *http.Request) testcases := []struct { name string @@ -75,10 +81,13 @@ func Test_Open(t *testing.T) { "foo", "bar", handlerFuncMap{ + "/": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, // first request to login "/cgi/login.cgi": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, r.Method, http.MethodPost) - assert.Equal(t, r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) b, err := io.ReadAll(r.Body) if err != nil { @@ -118,6 +127,24 @@ func Test_Open(t *testing.T) { response := []byte(``) _, _ = w.Write(response) }, + // request for model + "/cgi/ipmi.cgi": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type")) + + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, `op=FRU_INFO.XML&r=(0,0)&_=`, string(b)) + + _, _ = w.Write([]byte(` + + + + `)) + }, }, }, { @@ -155,27 +182,27 @@ func Test_Open(t *testing.T) { server := httptest.NewTLSServer(mux) defer server.Close() + server.Config.ErrorLog = log.Default() parsedURL, err := url.Parse(server.URL) if err != nil { t.Fatal(err) } client := NewClient(parsedURL.Hostname(), tc.user, tc.pass, logr.Discard(), WithPort(parsedURL.Port())) - if err := client.Open(context.Background()); err != nil { - if tc.errorContains != "" { - assert.ErrorContains(t, err, tc.errorContains) + err = client.Open(context.Background()) + if tc.errorContains != "" { + assert.ErrorContains(t, err, tc.errorContains) - return - } - - assert.Nil(t, err) + return } + + assert.Nil(t, err) }) } } -func Test_Close(t *testing.T) { +func TestClose(t *testing.T) { testcases := []struct { name string errorContains string @@ -217,21 +244,21 @@ func Test_Close(t *testing.T) { } client := NewClient(parsedURL.Hostname(), tc.user, tc.pass, logr.Discard(), WithPort(parsedURL.Port())) - if err := client.Close(context.Background()); err != nil { - if tc.errorContains != "" { - assert.ErrorContains(t, err, tc.errorContains) + err = client.Close(context.Background()) + if tc.errorContains != "" { + assert.ErrorContains(t, err, tc.errorContains) - return - } - - assert.Nil(t, err) + return } + + assert.Nil(t, err) + assert.Nil(t, client.serviceClient.redfish) }) } } -func Test_initScreenPreview(t *testing.T) { +func TestInitScreenPreview(t *testing.T) { testcases := []struct { name string errorContains string @@ -282,20 +309,19 @@ func Test_initScreenPreview(t *testing.T) { } client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) - if err := client.initScreenPreview(context.Background()); err != nil { - if tc.errorContains != "" { - assert.ErrorContains(t, err, tc.errorContains) + err = client.initScreenPreview(context.Background()) + if tc.errorContains != "" { + assert.ErrorContains(t, err, tc.errorContains) + return + } - return - } + assert.Nil(t, err) - assert.Nil(t, err) - } }) } } -func Test_fetchScreenPreview(t *testing.T) { +func TestFetchScreenPreview(t *testing.T) { testcases := []struct { name string expectImage []byte @@ -343,11 +369,9 @@ func Test_fetchScreenPreview(t *testing.T) { client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) image, err := client.fetchScreenPreview(context.Background()) - if err != nil { - if tc.errorContains != "" { - assert.ErrorContains(t, err, tc.errorContains) - return - } + if tc.errorContains != "" { + assert.ErrorContains(t, err, tc.errorContains) + return } assert.Nil(t, err) diff --git a/providers/supermicro/x11.go b/providers/supermicro/x11.go new file mode 100644 index 00000000..705c29f2 --- /dev/null +++ b/providers/supermicro/x11.go @@ -0,0 +1,145 @@ +package supermicro + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "net/http" + "os" + "strings" + + "github.com/bmc-toolbox/bmclib/v2/constants" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/bmc-toolbox/common" + "github.com/go-logr/logr" + "github.com/pkg/errors" + "golang.org/x/exp/slices" +) + +type x11 struct { + *serviceClient + model string + log logr.Logger +} + +func newX11Client(client *serviceClient, logger logr.Logger) bmcQueryor { + return &x11{ + serviceClient: client, + log: logger, + } +} + +func (c *x11) deviceModel() string { + return c.model +} + +func (c *x11) queryDeviceModel(ctx context.Context) (string, error) { + errBoardPartNumUnknown := errors.New("baseboard part number unknown") + data, err := c.fruInfo(ctx) + if err != nil { + if strings.Contains(err.Error(), "404") { + return "", ErrXMLAPIUnsupported + } + + return "", err + } + + partNum := strings.TrimSpace(data.Board.PartNum) + + if data.Board == nil || partNum == "" { + return "", errors.Wrap(errBoardPartNumUnknown, "baseboard part number empty") + } + + c.model = common.FormatProductName(partNum) + + return c.model, nil +} + +func (c *x11) fruInfo(ctx context.Context) (*FruInfo, error) { + headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} + + payload := "op=FRU_INFO.XML&r=(0,0)&_=" + + body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBufferString(payload), headers, 0) + if err != nil { + return nil, errors.Wrap(ErrQueryFRUInfo, err.Error()) + } + + if status != 200 { + return nil, unexpectedResponseErr([]byte(payload), body, status) + } + + if !bytes.Contains(body, []byte(``)) { + return nil, unexpectedResponseErr([]byte(payload), body, status) + } + + data := &IPMI{} + if err := xml.Unmarshal(body, data); err != nil { + return nil, errors.Wrap(ErrQueryFRUInfo, err.Error()) + } + + return data.FruInfo, nil +} + +func (c *x11) supportsInstall(component string) error { + errComponentNotSupported := fmt.Errorf("component %s on device %s not supported", component, c.model) + + supported := []string{common.SlugBIOS, common.SlugBMC} + if !slices.Contains(supported, strings.ToUpper(component)) { + return errComponentNotSupported + } + + return nil +} + +func (c *x11) firmwareInstallSteps(component string) ([]constants.FirmwareInstallStep, error) { + if err := c.supportsInstall(component); err != nil { + return nil, err + } + + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepUpload, + constants.FirmwareInstallStepInstallUploaded, + constants.FirmwareInstallStepInstallStatus, + }, nil +} + +func (c *x11) firmwareUpload(ctx context.Context, component string, file *os.File) (string, error) { + component = strings.ToUpper(component) + + switch component { + case common.SlugBIOS: + return "", c.firmwareUploadBIOS(ctx, file) + case common.SlugBMC: + return "", c.firmwareUploadBMC(ctx, file) + } + + return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component) +} + +func (c *x11) firmwareInstallUploaded(ctx context.Context, component, _ string) (string, error) { + component = strings.ToUpper(component) + + switch component { + case common.SlugBIOS: + return "", c.firmwareInstallUploadedBIOS(ctx) + case common.SlugBMC: + return "", c.initiateBMCFirmwareInstall(ctx) + } + + return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallUploaded, "component unsupported: "+component) +} + +func (c *x11) firmwareTaskStatus(ctx context.Context, component, _ string) (state, status string, err error) { + component = strings.ToUpper(component) + + switch component { + case common.SlugBIOS: + return c.statusBIOSFirmwareInstall(ctx) + case common.SlugBMC: + return c.statusBMCFirmwareInstall(ctx) + } + + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareTaskStatus, "component unsupported: "+component) +} diff --git a/providers/supermicro/firmware_bios.go b/providers/supermicro/x11_firmware_bios.go similarity index 81% rename from providers/supermicro/firmware_bios.go rename to providers/supermicro/x11_firmware_bios.go index 65936fb5..255d43ee 100644 --- a/providers/supermicro/firmware_bios.go +++ b/providers/supermicro/x11_firmware_bios.go @@ -19,7 +19,7 @@ import ( "github.com/pkg/errors" ) -func (c *Client) firmwareInstallBIOS(ctx context.Context, reader io.Reader, fileSize int64) error { +func (c *x11) firmwareUploadBIOS(ctx context.Context, reader io.Reader) error { var err error c.log.V(2).Info("set firmware install mode", "ip", c.host, "component", "BIOS", "model", c.model) @@ -51,30 +51,24 @@ func (c *Client) firmwareInstallBIOS(ctx context.Context, reader io.Reader, file c.log.V(2).Info("verifying uploaded firmware", "ip", c.host, "component", "BIOS", "model", c.model) // 3. BMC verifies the uploaded firmware version - err = c.verifyBIOSFirmwareVersion(ctx) - if err != nil { - return err - } + return c.verifyBIOSFirmwareVersion(ctx) +} +func (c *x11) firmwareInstallUploadedBIOS(ctx context.Context) error { c.log.V(2).Info("initiating firmware install", "ip", c.host, "component", "BIOS", "model", c.model) // pre install requisite - err = c.setBIOSOp(ctx) + err := c.setBIOSOp(ctx) if err != nil { return err } // 4. Run the firmware install process - err = c.initiateBIOSFirmwareInstall(ctx) - if err != nil { - return err - } - - return nil + return c.initiateBIOSFirmwareInstall(ctx) } // checks component update status -func (c *Client) checkComponentUpdateMisc(ctx context.Context, stage string) error { +func (c *x11) checkComponentUpdateMisc(ctx context.Context, stage string) error { var payload, expectResponse []byte switch stage { @@ -124,7 +118,7 @@ func (c *Client) checkComponentUpdateMisc(ctx context.Context, stage string) err return nil } -func (c *Client) setBIOSFirmwareInstallMode(ctx context.Context) error { +func (c *x11) setBIOSFirmwareInstallMode(ctx context.Context) error { payload := []byte(`op=BIOS_UPLOAD.XML&r=(0,0)&_=`) @@ -168,7 +162,7 @@ func (c *Client) setBIOSFirmwareInstallMode(ctx context.Context) error { } -func (c *Client) setBiosUpdateStart(ctx context.Context) error { +func (c *x11) setBiosUpdateStart(ctx context.Context) error { payload := []byte(`op=BIOS_UPDATE_START.XML&r=(1,0)&_=`) headers := map[string]string{ @@ -197,22 +191,27 @@ func (c *Client) setBiosUpdateStart(ctx context.Context) error { // // OO8+cjamaZZOMf6ZiGDY3Lw+7O20r5lR8aI8ByuTo3E // ------WebKitFormBoundaryXIAavwG4xzohdB6k-- -func (c *Client) uploadBIOSFirmware(ctx context.Context, fwReader io.Reader) error { +func (c *x11) uploadBIOSFirmware(ctx context.Context, fwReader io.Reader) error { var payloadBuffer bytes.Buffer var err error - formParts := []struct { + type form struct { name string data io.Reader - }{ + } + + formParts := []form{ { name: "bios_rom", data: fwReader, }, - { + } + + if c.csrfToken != "" { + formParts = append(formParts, form{ name: "csrf-token", data: bytes.NewBufferString(c.csrfToken), - }, + }) } payloadWriter := multipart.NewWriter(&payloadBuffer) @@ -274,7 +273,7 @@ func (c *Client) uploadBIOSFirmware(ctx context.Context, fwReader io.Reader) err return nil } -func (c *Client) verifyBIOSFirmwareVersion(ctx context.Context) error { +func (c *x11) verifyBIOSFirmwareVersion(ctx context.Context) error { payload := []byte(`op=BIOS_UPDATE_CHECK.XML&r=(0,0)&_=`) expectResponse := []byte(``) @@ -306,7 +305,7 @@ func (c *Client) verifyBIOSFirmwareVersion(ctx context.Context) error { return nil } -func (c *Client) setBIOSOp(ctx context.Context) error { +func (c *x11) setBIOSOp(ctx context.Context) error { payload := []byte(`op=BIOS_OPTION.XML&_=`) expectResponse := []byte(``) @@ -326,7 +325,7 @@ func (c *Client) setBIOSOp(ctx context.Context) error { return nil } -func (c *Client) initiateBIOSFirmwareInstall(ctx context.Context) error { +func (c *x11) initiateBIOSFirmwareInstall(ctx context.Context) error { // save all current SMBIOS, NVRAM, ME configuration payload := []byte(`op=main_biosupdate&_=`) expectResponse := []byte(`ok`) @@ -356,7 +355,7 @@ func (c *Client) initiateBIOSFirmwareInstall(ctx context.Context) error { return nil } -func (c *Client) setBIOSUpdateDone(ctx context.Context) error { +func (c *x11) setBIOSUpdateDone(ctx context.Context) error { payload := []byte(`op=BIOS_UPDATE_DONE.XML&r=(1,0)&_=`) headers := map[string]string{ @@ -377,64 +376,73 @@ func (c *Client) setBIOSUpdateDone(ctx context.Context) error { } // statusBIOSFirmwareInstall returns the status of the firmware install process -func (c *Client) statusBIOSFirmwareInstall(ctx context.Context) (string, error) { +func (c *x11) statusBIOSFirmwareInstall(ctx context.Context) (state, status string, err error) { payload := []byte(`fwtype=1&_`) headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} - resp, status, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0) + resp, httpStatus, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0) if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) } - if status != http.StatusOK { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected status code: "+strconv.Itoa(status)) + if httpStatus != http.StatusOK { + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected http status code: "+strconv.Itoa(httpStatus)) + } + + // if theres html or no xml in the response, the session expired + // at the end of the install the BMC resets itself and the response is in HTML. + if bytes.Contains(resp, []byte(``)) || !bytes.Contains(resp, []byte(``)) { + // reopen session here, check firmware install status + return constants.FirmwareInstallUnknown, "session expired/unexpected response", bmclibErrs.ErrSessionExpired } + // as long as the response is xml, the firmware install is running + part := strings.Split(string(resp), "")[1] + percent := strings.Split(part, "")[0] + percent += "%" + switch { // 1% indicates the file has been uploaded and the firmware install is not yet initiated case bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("1")): - return constants.FirmwareInstallFailed, bmclibErrs.ErrBMCColdResetRequired + return constants.FirmwareInstallFailed, percent, bmclibErrs.ErrBMCColdResetRequired // 0% along with the check on the component endpoint indicates theres no update in progress case (bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("0"))): if err := c.checkComponentUpdateMisc(ctx, "postUpdate"); err != nil { if errors.Is(err, bmclibErrs.ErrHostPowercycleRequired) { - return constants.FirmwareInstallPowerCyleHost, nil + return constants.FirmwareInstallPowerCycleHost, percent, nil } } - return constants.FirmwareInstallComplete, nil + return constants.FirmwareInstallComplete, "all done!", nil // status 0 and 100% indicates the update is complete and requires a few post update calls case bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("100")): + // TODO: create a new bmc method FirmwarePostInstall() // notifies the BMC the BIOS update is done if err := c.setBIOSUpdateDone(ctx); err != nil { - return "", err + return "", "", err } // tells the BMC it can get out of the BIOS update mode if err := c.checkComponentUpdateMisc(ctx, "postUpdate"); err != nil { if errors.Is(err, bmclibErrs.ErrHostPowercycleRequired) { - return constants.FirmwareInstallPowerCyleHost, nil + return constants.FirmwareInstallPowerCycleHost, percent, nil } - return constants.FirmwareInstallPowerCyleHost, err + return constants.FirmwareInstallPowerCycleHost, percent, err } - return constants.FirmwareInstallPowerCyleHost, nil + return constants.FirmwareInstallPowerCycleHost, percent, nil // status 8 and percent 0 indicates its initializing the update case bytes.Contains(resp, []byte("8")) && bytes.Contains(resp, []byte("0")): - return constants.FirmwareInstallRunning, nil + return constants.FirmwareInstallRunning, percent, nil // status 8 and any other percent value indicates its running case bytes.Contains(resp, []byte("8")) && bytes.Contains(resp, []byte("")): - return constants.FirmwareInstallRunning, nil - - case bytes.Contains(resp, []byte(``)): - return constants.FirmwareInstallUnknown, bmclibErrs.ErrSessionExpired - - default: - return constants.FirmwareInstallUnknown, nil + return constants.FirmwareInstallRunning, percent, nil } + + return constants.FirmwareInstallUnknown, "", nil } diff --git a/providers/supermicro/firmware_bmc.go b/providers/supermicro/x11_firmware_bmc.go similarity index 78% rename from providers/supermicro/firmware_bmc.go rename to providers/supermicro/x11_firmware_bmc.go index b142a9a0..39ec304a 100644 --- a/providers/supermicro/firmware_bmc.go +++ b/providers/supermicro/x11_firmware_bmc.go @@ -25,14 +25,11 @@ var ( ErrMultipartForm = errors.New("multipart form error") ) -// firmwareInstallBMC uploads and installs firmware for the BMC component -func (c *Client) firmwareInstallBMC(ctx context.Context, reader io.Reader, fileSize int64) error { - var err error - +func (c *x11) firmwareUploadBMC(ctx context.Context, reader io.Reader) error { c.log.V(2).Info("setting device to firmware install mode", "ip", c.host, "component", "BMC", "model", c.model) // 1. set the device to flash mode - prepares the flash - err = c.setBMCFirmwareInstallMode(ctx) + err := c.setBMCFirmwareInstallMode(ctx) if err != nil { return err } @@ -48,23 +45,10 @@ func (c *Client) firmwareInstallBMC(ctx context.Context, reader io.Reader, fileS c.log.V(2).Info("verifying uploaded firmware", "ip", c.host, "component", "BMC", "model", c.model) // 3. BMC verifies the uploaded firmware version - err = c.verifyBMCFirmwareVersion(ctx) - if err != nil { - return err - } - - c.log.V(2).Info("initiating firmware install", "ip", c.host, "component", "BMC", "model", c.model) - - // 4. Run the firmware install process - err = c.initiateBMCFirmwareInstall(ctx) - if err != nil { - return err - } - - return nil + return c.verifyBMCFirmwareVersion(ctx) } -func (c *Client) setBMCFirmwareInstallMode(ctx context.Context) error { +func (c *x11) setBMCFirmwareInstallMode(ctx context.Context) error { payload := []byte(`op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`) headers := map[string]string{ @@ -118,22 +102,27 @@ func (c *Client) setBMCFirmwareInstallMode(ctx context.Context) error { // // JhVe1BUiWzOVQdvXUKn7ClsQ5xffq8StMOxG7ZNlpKs // -----------------------------348113760313214626342869148824-- -func (c *Client) uploadBMCFirmware(ctx context.Context, fwReader io.Reader) error { +func (c *x11) uploadBMCFirmware(ctx context.Context, fwReader io.Reader) error { var payloadBuffer bytes.Buffer var err error - formParts := []struct { + type form struct { name string data io.Reader - }{ + } + + formParts := []form{ { name: "fw_image", data: fwReader, }, - { + } + + if c.csrfToken != "" { + formParts = append(formParts, form{ name: "csrf-token", data: bytes.NewBufferString(c.csrfToken), - }, + }) } payloadWriter := multipart.NewWriter(&payloadBuffer) @@ -195,7 +184,7 @@ func (c *Client) uploadBMCFirmware(ctx context.Context, fwReader io.Reader) erro return nil } -func (c *Client) verifyBMCFirmwareVersion(ctx context.Context) error { +func (c *x11) verifyBMCFirmwareVersion(ctx context.Context) error { errUnexpectedResponse := errors.New("unexpected response") payload := []byte(`op=UPLOAD_FW_VERSION.XML&r=(0,0)&_=`) @@ -221,7 +210,7 @@ func (c *Client) verifyBMCFirmwareVersion(ctx context.Context) error { } // initiate BMC firmware install process -func (c *Client) initiateBMCFirmwareInstall(ctx context.Context) error { +func (c *x11) initiateBMCFirmwareInstall(ctx context.Context) error { // preserve all configuration, sensor data and SSL certs(?) during upgrade payload := "op=main_fwupdate&preserve_config=1&preserve_sdr=1&preserve_ssl=1" @@ -254,42 +243,43 @@ func (c *Client) initiateBMCFirmwareInstall(ctx context.Context) error { } // statusBMCFirmwareInstall returns the status of the firmware install process -func (c *Client) statusBMCFirmwareInstall(ctx context.Context) (string, error) { +func (c *x11) statusBMCFirmwareInstall(ctx context.Context) (state, status string, err error) { payload := []byte(`fwtype=0&_`) headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} - resp, status, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0) + resp, httpStatus, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0) if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) + return constants.FirmwareInstallUnknown, "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) } - if status != http.StatusOK { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected status code: "+strconv.Itoa(status)) + if httpStatus != http.StatusOK { + return constants.FirmwareInstallUnknown, "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected http status code: "+strconv.Itoa(httpStatus)) + } + + // if theres html or no xml in the response, the session expired + // at the end of the install the BMC resets itself and the response is in HTML. + if bytes.Contains(resp, []byte(``)) || !bytes.Contains(resp, []byte(``)) { + // reopen session here, check firmware install status + return constants.FirmwareInstallUnknown, "session expired/unexpected response", bmclibErrs.ErrSessionExpired } // as long as the response is xml, the firmware install is running - // at the end of the install the BMC resets itself and the response is in HTML - // - switch { + part := strings.Split(string(resp), "")[1] + percent := strings.Split(part, "")[0] + percent += "%" + switch percent { // TODO: - // - look up model on device and limit the parent methods to tested models. - // - fix up percent value checks, html indicates session has been terminated // X11DPH-T - returns percent 0 all the time // // 0% indicates its either not running or complete - case bytes.Contains(resp, []byte("0")) || bytes.Contains(resp, []byte("100")): - return constants.FirmwareInstallComplete, nil + case "0%", "100%": + return constants.FirmwareInstallComplete, percent, nil // until 2% its initializing - case bytes.Contains(resp, []byte(`1`)) || bytes.Contains(resp, []byte(`2`)): - return constants.FirmwareInstallInitializing, nil + case "1%", "2%": + return constants.FirmwareInstallInitializing, percent, nil // any other percent value indicates its active - case bytes.Contains(resp, []byte(``)): - return constants.FirmwareInstallRunning, nil - case bytes.Contains(resp, []byte(``)): - // reopen session here, check firmware install status - return constants.FirmwareInstallUnknown, bmclibErrs.ErrSessionExpired default: - return constants.FirmwareInstallUnknown, nil + return constants.FirmwareInstallRunning, percent, nil } } diff --git a/providers/supermicro/firmware_bmc_test.go b/providers/supermicro/x11_firmware_bmc_test.go similarity index 86% rename from providers/supermicro/firmware_bmc_test.go rename to providers/supermicro/x11_firmware_bmc_test.go index dbbb2f0c..b6b0ad13 100644 --- a/providers/supermicro/firmware_bmc_test.go +++ b/providers/supermicro/x11_firmware_bmc_test.go @@ -14,11 +14,12 @@ import ( "testing" "github.com/bmc-toolbox/bmclib/v2/constants" + "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" ) -func Test_setBMCFirmwareInstallMode(t *testing.T) { +func TestX11SetBMCFirmwareInstallMode(t *testing.T) { testcases := []struct { name string errorContains string @@ -90,7 +91,9 @@ func Test_setBMCFirmwareInstallMode(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.setBMCFirmwareInstallMode(context.Background()); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) @@ -104,7 +107,7 @@ func Test_setBMCFirmwareInstallMode(t *testing.T) { } } -func Test_uploadBMCFirmware(t *testing.T) { +func TestX11UploadBMCFirmware(t *testing.T) { testcases := []struct { name string errorContains string @@ -181,8 +184,10 @@ func Test_uploadBMCFirmware(t *testing.T) { defer os.Remove(binPath) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) - client.csrfToken = "foobar" + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.uploadBMCFirmware(context.Background(), fwReader); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) @@ -196,7 +201,7 @@ func Test_uploadBMCFirmware(t *testing.T) { } } -func Test_verifyBMCFirmwareVersion(t *testing.T) { +func TestX11VerifyBMCFirmwareVersion(t *testing.T) { testcases := []struct { name string errorContains string @@ -260,8 +265,10 @@ func Test_verifyBMCFirmwareVersion(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) - client.csrfToken = "foobar" + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.verifyBMCFirmwareVersion(context.Background()); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) @@ -275,7 +282,7 @@ func Test_verifyBMCFirmwareVersion(t *testing.T) { } } -func Test_initiateBMCFirmwareInstall(t *testing.T) { +func TestX11InitiateBMCFirmwareInstall(t *testing.T) { testcases := []struct { name string errorContains string @@ -339,8 +346,10 @@ func Test_initiateBMCFirmwareInstall(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) - client.csrfToken = "foobar" + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + if err := client.initiateBMCFirmwareInstall(context.Background()); err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) @@ -354,9 +363,10 @@ func Test_initiateBMCFirmwareInstall(t *testing.T) { } } -func Test_statusBMCFirmwareInstall(t *testing.T) { +func TestX11StatusBMCFirmwareInstall(t *testing.T) { testcases := []struct { name string + expectState string expectStatus string errorContains string endpoint string @@ -365,6 +375,7 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { { "state complete 0", constants.FirmwareInstallComplete, + "0%", "", "/cgi/upgrade_process.cgi", func(w http.ResponseWriter, r *http.Request) { @@ -389,6 +400,7 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { { "state complete 100", constants.FirmwareInstallComplete, + "100%", "", "/cgi/upgrade_process.cgi", func(w http.ResponseWriter, r *http.Request) { @@ -413,6 +425,7 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { { "state initializing", constants.FirmwareInstallInitializing, + "1%", "", "/cgi/upgrade_process.cgi", func(w http.ResponseWriter, r *http.Request) { @@ -437,6 +450,7 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { { "status running", constants.FirmwareInstallRunning, + "95%", "", "/cgi/upgrade_process.cgi", func(w http.ResponseWriter, r *http.Request) { @@ -461,6 +475,7 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { { "status unknown", constants.FirmwareInstallUnknown, + "", "session expired", "/cgi/upgrade_process.cgi", func(w http.ResponseWriter, r *http.Request) { @@ -494,18 +509,22 @@ func Test_statusBMCFirmwareInstall(t *testing.T) { t.Fatal(err) } - client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port())) - client.csrfToken = "foobar" - if gotStatus, err := client.statusBMCFirmwareInstall(context.Background()); err != nil { + serviceClient := newBmcServiceClient(parsedURL.Hostname(), parsedURL.Port(), "foo", "bar", httpclient.Build()) + serviceClient.csrfToken = "foobar" + client := &x11{serviceClient: serviceClient, log: logr.Discard()} + + gotState, gotStatus, err := client.statusBMCFirmwareInstall(context.Background()) + if err != nil { if tc.errorContains != "" { assert.ErrorContains(t, err, tc.errorContains) return } - - assert.Nil(t, err) - assert.Equal(t, tc.expectStatus, gotStatus) } + + assert.Nil(t, err) + assert.Equal(t, tc.expectState, gotState) + assert.Equal(t, tc.expectStatus, gotStatus) }) } } diff --git a/providers/supermicro/x12.go b/providers/supermicro/x12.go new file mode 100644 index 00000000..e027d858 --- /dev/null +++ b/providers/supermicro/x12.go @@ -0,0 +1,303 @@ +package supermicro + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/bmc-toolbox/bmclib/v2/constants" + brrs "github.com/bmc-toolbox/bmclib/v2/errors" + rfw "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" + "github.com/bmc-toolbox/common" + "github.com/go-logr/logr" + "github.com/pkg/errors" + "github.com/stmcginnis/gofish/redfish" + "golang.org/x/exp/slices" +) + +type x12 struct { + *serviceClient + model string + log logr.Logger +} + +func newX12Client(client *serviceClient, logger logr.Logger) bmcQueryor { + return &x12{ + serviceClient: client, + log: logger, + } +} + +func (c *x12) deviceModel() string { + return c.model +} + +func (c *x12) queryDeviceModel(ctx context.Context) (string, error) { + if err := c.redfishSession(ctx); err != nil { + return "", err + } + + _, model, err := c.redfish.DeviceVendorModel(ctx) + if err != nil { + return "", err + } + + if model == "" { + return "", errors.Wrap(ErrModelUnknown, "empty value") + } + + c.model = common.FormatProductName(model) + + return c.model, nil +} + +var ( + errUploadTaskIDEmpty = errors.New("firmware upload request returned empty firmware upload verify TaskID") +) + +func (c *x12) supportsInstall(component string) error { + errComponentNotSupported := fmt.Errorf("component %s on device %s not supported", component, c.model) + + supported := []string{common.SlugBIOS, common.SlugBMC} + if !slices.Contains(supported, strings.ToUpper(component)) { + return errComponentNotSupported + } + + return nil +} + +func (c *x12) firmwareInstallSteps(component string) ([]constants.FirmwareInstallStep, error) { + if err := c.supportsInstall(component); err != nil { + return nil, err + } + + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepUpload, + constants.FirmwareInstallStepUploadStatus, + constants.FirmwareInstallStepInstallUploaded, + constants.FirmwareInstallStepInstallStatus, + }, nil +} + +// upload firmware +func (c *x12) firmwareUpload(ctx context.Context, component string, file *os.File) (taskID string, err error) { + if err = c.supportsInstall(component); err != nil { + return "", err + } + + err = c.firmwareTaskActive(ctx, component) + if err != nil { + return "", err + } + + targetID, err := c.redfishOdataID(ctx, component) + if err != nil { + return "", err + } + + params, err := c.redfishParameters(component, targetID) + if err != nil { + return "", err + } + + taskID, err = c.redfish.FirmwareUpload(ctx, file, params) + if err != nil { + if strings.Contains(err.Error(), "OemFirmwareAlreadyInUpdateMode") { + return "", errors.Wrap(brrs.ErrBMCColdResetRequired, "BMC currently in update mode, either continue the update OR if no update is currently running - reset the BMC") + } + + return "", errors.Wrap(err, "error in firmware upload") + } + + if taskID == "" { + return "", errUploadTaskIDEmpty + } + + return taskID, nil +} + +// returns an error when a bmc firmware install is active +func (c *x12) firmwareTaskActive(ctx context.Context, component string) error { + tasks, err := c.redfish.Tasks(ctx) + if err != nil { + return errors.Wrap(err, "error querying redfish tasks") + } + + for _, t := range tasks { + t := t + + if stateFinalized(t.TaskState) { + continue + } + + if err := noTasksRunning(component, t); err != nil { + return err + } + } + + return nil +} + +// noTasksRunning returns an error if a firmware related task was found active +func noTasksRunning(component string, t *redfish.Task) error { + errTaskActive := errors.New("A firmware task was found active for component: " + component) + + const ( + // The redfish task name when the BMC is verifies the uploaded BMC firmware. + verifyBMCFirmware = "BMC Verify" + // The redfish task name when the BMC is installing the uploaded BMC firmware. + updateBMCFirmware = "BMC Update" + // The redfish task name when the BMC is verifies the uploaded BIOS firmware. + verifyBIOSFirmware = "BIOS Verify" + // The redfish task name when the BMC is installing the uploaded BIOS firmware. + updateBIOSFirmware = "BIOS Update" + ) + + var verifyTaskName, updateTaskName string + + switch strings.ToUpper(component) { + case common.SlugBMC: + verifyTaskName = verifyBMCFirmware + updateTaskName = updateBMCFirmware + case common.SlugBIOS: + verifyTaskName = verifyBIOSFirmware + updateTaskName = updateBIOSFirmware + } + + taskInfo := fmt.Sprintf("id: %s, state: %s, status: %s", t.ID, t.TaskState, t.TaskStatus) + + switch t.Name { + case verifyTaskName: + return errors.Wrap(errTaskActive, taskInfo) + case updateTaskName: + return errors.Wrap(errTaskActive, taskInfo) + default: + return nil + } +} + +func stateFinalized(s redfish.TaskState) bool { + finalized := []redfish.TaskState{ + redfish.CompletedTaskState, + redfish.CancelledTaskState, + redfish.InterruptedTaskState, + redfish.ExceptionTaskState, + } + + return slices.Contains(finalized, s) +} + +// redfish OEM parameter structs +type BIOS struct { + PreserveME bool `json:"PreserveME"` + PreserveNVRAM bool `json:"PreserveNVRAM"` + PreserveSMBIOS bool `json:"PreserveSMBIOS"` + PreserveOA bool `json:"PreserveOA"` + PreserveSETUPCONF bool `json:"PreserveSETUPCONF"` + PreserveSETUPPWD bool `json:"PreserveSETUPPWD"` + PreserveSECBOOTKEY bool `json:"PreserveSECBOOTKEY"` + PreserveBOOTCONF bool `json:"PreserveBOOTCONF"` +} + +type BMC struct { + PreserveCfg bool `json:"PreserveCfg"` + PreserveSdr bool `json:"PreserveSdr"` + PreserveSsl bool `json:"PreserveSsl"` +} + +type Supermicro struct { + *BIOS `json:"BIOS,omitempty"` + *BMC `json:"BMC,omitempty"` +} + +type OEM struct { + Supermicro `json:"Supermicro"` +} + +func (c *x12) redfishParameters(component, targetODataID string) (*rfw.RedfishUpdateServiceParameters, error) { + errUnsupported := errors.New("redfish parameters for x12 hardware component not supported: " + component) + + oem := OEM{} + + switch strings.ToUpper(component) { + case common.SlugBIOS: + oem.Supermicro.BIOS = &BIOS{ + PreserveME: false, + PreserveNVRAM: false, + PreserveSMBIOS: true, + PreserveOA: true, + PreserveSETUPCONF: true, + PreserveSETUPPWD: true, + PreserveSECBOOTKEY: true, + PreserveBOOTCONF: true, + } + case common.SlugBMC: + oem.Supermicro.BMC = &BMC{ + PreserveCfg: true, + PreserveSdr: true, + PreserveSsl: true, + } + default: + return nil, errUnsupported + } + + b, err := json.Marshal(oem) + if err != nil { + return nil, errors.Wrap(err, "error preparing redfish parameters") + } + + return &rfw.RedfishUpdateServiceParameters{ + OperationApplyTime: constants.OnStartUpdateRequest, + Targets: []string{targetODataID}, + Oem: b, + }, nil +} + +func (c *x12) redfishOdataID(ctx context.Context, component string) (string, error) { + errUnsupported := errors.New("unable to return redfish OData ID for unsupported component: " + component) + + switch strings.ToUpper(component) { + case common.SlugBMC: + return c.redfish.ManagerOdataID(ctx) + case common.SlugBIOS: + // hardcoded since SMCs without the DCMS license will throw license errors + return "/redfish/v1/Systems/1/Bios", nil + //return c.redfish.SystemsBIOSOdataID(ctx) + } + + return "", errUnsupported +} + +func (c *x12) firmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (installTaskID string, err error) { + if err = c.supportsInstall(component); err != nil { + return "", err + } + + task, err := c.redfish.Task(ctx, uploadTaskID) + if err != nil { + e := fmt.Sprintf("error querying redfish tasks for firmware upload taskID: %s, err: %s", uploadTaskID, err.Error()) + return "", errors.Wrap(brrs.ErrFirmwareVerifyTask, e) + } + + taskInfo := fmt.Sprintf("id: %s, state: %s, status: %s", task.ID, task.TaskState, task.TaskStatus) + + if task.TaskState != redfish.CompletedTaskState { + return "", errors.Wrap(brrs.ErrFirmwareVerifyTask, taskInfo) + } + + if task.TaskStatus != "OK" { + return "", errors.Wrap(brrs.ErrFirmwareVerifyTask, taskInfo) + } + + return c.redfish.StartUpdateForUploadedFirmware(ctx) +} + +func (c *x12) firmwareTaskStatus(ctx context.Context, component, taskID string) (state, status string, err error) { + if err = c.supportsInstall(component); err != nil { + return "", "", errors.Wrap(brrs.ErrFirmwareTaskStatus, err.Error()) + } + + return c.redfish.TaskStatus(ctx, taskID) +}