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(`