diff --git a/providers/dell/firmware.go b/providers/dell/firmware.go new file mode 100644 index 00000000..f311ea73 --- /dev/null +++ b/providers/dell/firmware.go @@ -0,0 +1,235 @@ +package dell + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/bmc-toolbox/bmclib/v2/constants" + bmcliberrs "github.com/bmc-toolbox/bmclib/v2/errors" + rfw "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" + "github.com/bmc-toolbox/common" + "github.com/pkg/errors" + "github.com/stmcginnis/gofish/redfish" +) + +var ( + ErrUnsupportedHardware = errors.New("hardware not supported") +) + +// bmc client interface implementations methods +func (c *Conn) FirmwareInstallSteps(ctx context.Context, component string) ([]constants.FirmwareInstallStep, error) { + if err := c.deviceSupported(ctx); err != nil { + return nil, errors.Wrap(ErrUnsupportedHardware, err.Error()) + } + + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepUploadInitiateInstall, + constants.FirmwareInstallStepInstallStatus, + }, nil +} + +func (c *Conn) FirmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File) (taskID string, err error) { + if err := c.deviceSupported(ctx); err != nil { + return "", errors.Wrap(ErrUnsupportedHardware, err.Error()) + } + + // // expect atleast 5 minutes left in the deadline to proceed with the upload + d, _ := ctx.Deadline() + if time.Until(d) < 10*time.Minute { + return "", errors.New("remaining context deadline insufficient to perform update: " + time.Until(d).String()) + } + + // list current tasks on BMC + tasks, err := c.redfishwrapper.Tasks(ctx) + if err != nil { + return "", errors.Wrap(err, "error listing bmc redfish tasks") + } + + // validate a new firmware install task can be queued + if err := c.checkQueueability(component, tasks); err != nil { + return "", errors.Wrap(bmcliberrs.ErrFirmwareInstall, err.Error()) + } + + params := &rfw.RedfishUpdateServiceParameters{ + Targets: []string{}, + OperationApplyTime: constants.OnReset, + Oem: []byte(`{}`), + } + + return c.redfishwrapper.FirmwareUpload(ctx, file, params) +} + +// checkQueueability returns an error if an existing firmware task is in progress for the given component +func (c *Conn) checkQueueability(component string, tasks []*redfish.Task) error { + errTaskActive := errors.New("A firmware job was found active for component: " + component) + + // Redfish on the Idrac names firmware install tasks in this manner. + taskNameMap := map[string]string{ + common.SlugBIOS: "Firmware Update: BIOS", + common.SlugBMC: "Firmware Update: iDRAC with Lifecycle Controller", + common.SlugNIC: "Firmware Update: Network", + common.SlugDrive: "Firmware Update: Serial ATA", + common.SlugStorageController: "Firmware Update: SAS RAID", + } + + for _, t := range tasks { + if t.Name == taskNameMap[strings.ToUpper(component)] { + // taskInfo returned in error if any. + taskInfo := fmt.Sprintf("id: %s, state: %s, status: %s", t.ID, t.TaskState, t.TaskStatus) + + // convert redfish task state to bmclib state + convstate := c.redfishwrapper.ConvertTaskState(string(t.TaskState)) + // check if task is active based on converted state + active, err := c.redfishwrapper.TaskStateActive(convstate) + if err != nil { + return errors.Wrap(err, taskInfo) + } + + if active { + return errors.Wrap(errTaskActive, taskInfo) + } + } + } + + return nil +} + +// FirmwareTaskStatus returns the status of a firmware related task queued on the BMC. +func (c *Conn) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state constants.TaskState, status string, err error) { + if err := c.deviceSupported(ctx); err != nil { + return "", "", errors.Wrap(ErrUnsupportedHardware, err.Error()) + } + + // Dell jobs are turned into Redfish tasks on the idrac + // once the Redfish task completes successfully, the Redfish task is purged, + // and the dell Job stays around. + task, err := c.redfishwrapper.Task(ctx, taskID) + if err != nil { + if errors.Is(err, bmcliberrs.ErrTaskNotFound) { + return c.statusFromJob(taskID) + } + + return "", "", err + } + + return c.statusFromTaskOem(taskID, task.Oem) +} + +func (c *Conn) statusFromJob(taskID string) (constants.TaskState, string, error) { + job, err := c.job(taskID) + if err != nil { + return "", "", err + } + + s := strings.ToLower(job.JobState) + state := c.redfishwrapper.ConvertTaskState(s) + + status := fmt.Sprintf( + "id: %s, state: %s, status: %s, progress: %d%%", + taskID, + job.JobState, + job.Message, + job.PercentComplete, + ) + + return state, status, nil +} + +func (c *Conn) statusFromTaskOem(taskID string, oem json.RawMessage) (constants.TaskState, string, error) { + data, err := convFirmwareTaskOem(oem) + if err != nil { + return "", "", err + } + + s := strings.ToLower(data.Dell.JobState) + state := c.redfishwrapper.ConvertTaskState(s) + + status := fmt.Sprintf( + "id: %s, state: %s, status: %s, progress: %d%%", + taskID, + data.Dell.JobState, + data.Dell.Message, + data.Dell.PercentComplete, + ) + + return state, status, nil +} + +func (c *Conn) job(jobID string) (*Dell, error) { + errLookup := errors.New("error querying dell job: " + jobID) + + endpoint := "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs/" + jobID + resp, err := c.redfishwrapper.Get(endpoint) + if err != nil { + return nil, errors.Wrap(errLookup, err.Error()) + } + + if resp.StatusCode != 200 { + return nil, errors.Wrap(errLookup, "unexpected status code: "+resp.Status) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(errLookup, err.Error()) + } + + dell := &Dell{} + err = json.Unmarshal(body, &dell) + if err != nil { + return nil, errors.Wrap(errLookup, err.Error()) + } + + return dell, nil +} + +type oem struct { + Dell `json:"Dell"` +} + +type Dell struct { + OdataType string `json:"@odata.type"` + CompletionTime interface{} `json:"CompletionTime"` + Description string `json:"Description"` + EndTime string `json:"EndTime"` + ID string `json:"Id"` + JobState string `json:"JobState"` + JobType string `json:"JobType"` + Message string `json:"Message"` + MessageArgs []interface{} `json:"MessageArgs"` + MessageID string `json:"MessageId"` + Name string `json:"Name"` + PercentComplete int `json:"PercentComplete"` + StartTime string `json:"StartTime"` + TargetSettingsURI interface{} `json:"TargetSettingsURI"` +} + +func convFirmwareTaskOem(oemdata json.RawMessage) (oem, error) { + oem := oem{} + + errTaskOem := errors.New("error in Task Oem data: " + string(oemdata)) + + if len(oemdata) == 0 || string(oemdata) == `{}` { + return oem, errors.Wrap(errTaskOem, "empty oem data") + } + + if err := json.Unmarshal(oemdata, &oem); err != nil { + return oem, errors.Wrap(errTaskOem, "failed to unmarshal: "+err.Error()) + } + + if oem.Dell.Description == "" || oem.Dell.JobState == "" { + return oem, errors.Wrap(errTaskOem, "invalid oem data") + } + + if oem.Dell.JobType != "FirmwareUpdate" { + return oem, errors.Wrap(errTaskOem, "unexpected job type: "+oem.Dell.JobType) + } + + return oem, nil +} diff --git a/providers/dell/firmware_test.go b/providers/dell/firmware_test.go new file mode 100644 index 00000000..77f099e2 --- /dev/null +++ b/providers/dell/firmware_test.go @@ -0,0 +1,94 @@ +package dell + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvFirmwareTaskOem(t *testing.T) { + testCases := []struct { + name string + oemdata []byte + expectedJob oem + expectedErr string + }{ + { + name: "Valid OEM data", + oemdata: []byte(`{ + "Dell": { + "@odata.type": "#DellJob.v1_4_0.DellJob", + "CompletionTime": null, + "Description": "Job Instance", + "EndTime": "TIME_NA", + "Id": "JID_005950769310", + "JobState": "Scheduled", + "JobType": "FirmwareUpdate", + "Message": "Task successfully scheduled.", + "MessageArgs": [], + "MessageId": "IDRAC.2.8.JCP001", + "Name": "Firmware Update: BIOS", + "PercentComplete": 0, + "StartTime": "TIME_NOW", + "TargetSettingsURI": null + } + }`), + expectedJob: oem{ + Dell{ + OdataType: "#DellJob.v1_4_0.DellJob", + CompletionTime: nil, + Description: "Job Instance", + EndTime: "TIME_NA", + ID: "JID_005950769310", + JobState: "Scheduled", + JobType: "FirmwareUpdate", + Message: "Task successfully scheduled.", + MessageArgs: []interface{}{}, + MessageID: "IDRAC.2.8.JCP001", + Name: "Firmware Update: BIOS", + PercentComplete: 0, + StartTime: "TIME_NOW", + TargetSettingsURI: nil, + }, + }, + expectedErr: "", + }, + { + name: "Empty OEM data", + oemdata: []byte(`{}`), + expectedJob: oem{}, + expectedErr: "empty oem data", + }, + { + name: "Invalid OEM data", + oemdata: []byte(`{"InvalidKey": "InvalidValue"}`), + expectedJob: oem{}, + expectedErr: "invalid oem data", + }, + { + name: "Unexpected job type", + oemdata: []byte(`{ + "Dell": { + "JobType": "InvalidJobType", + "Description": "Job Instance", + "JobState": "Scheduled" + } + }`), + expectedJob: oem{}, + expectedErr: "unexpected job type", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + job, err := convFirmwareTaskOem(tc.oemdata) + if tc.expectedErr == "" { + assert.NoError(t, err) + assert.Equal(t, tc.expectedJob, job) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + } + }) + } +} diff --git a/providers/dell/idrac.go b/providers/dell/idrac.go index c47dee3b..22613847 100644 --- a/providers/dell/idrac.go +++ b/providers/dell/idrac.go @@ -36,6 +36,12 @@ var ( // Features implemented by dell redfish Features = registrar.Features{ providers.FeatureScreenshot, + providers.FeaturePowerState, + providers.FeaturePowerSet, + providers.FeatureFirmwareInstallSteps, + providers.FeatureFirmwareUploadInitiateInstall, + providers.FeatureFirmwareTaskStatus, + providers.FeatureInventoryRead, } )