From f83ce5d283b17a963cc456807d73f6855b013dc2 Mon Sep 17 00:00:00 2001 From: Doctor Vince Date: Mon, 11 Nov 2024 13:42:27 -0500 Subject: [PATCH] update the fork (#5) * ipmitool and redfish sel clear in place, now to wire it up * tests next * tests added * security: update golang.org/x/net to 0.7.0 * Update go dependencies * internal/redfishwrapper: expose Tasks() method * providers/redfish: split up method to support upload based on UpdateService URI * provides/redfish/tasks: WIP: add a generic Task listing method moved dell specific tasks handling into its own file * providers/redfish/firmware: Fix parameters mismatch for unstructuredHttpUpload * providers/redfish/firmware: Use correct type for multipartHTTP update * providers/redfish/firmware: implement unstructured HTTP updates * providers/redfish/tasks: add a function to get current task status for OpenBMC * providers/redfish/firmware: get task ID for OpenBMC, return Task object for OpenBMC status * providers/redfish: Add tests for openBMC status * providers/redfish: Move default task logic into GetTask * providers/redfish: Detect duplicate update requests * providers/redfish/firmware: Add TaskIDFromLocationURI and tests * providers/redfish/firmware: removed firmwareUpdateCompatible * providers/redfish/tasks: check if a task is running before update * added feature var for selclear * added example * Fix copying of request body: The request body was being dropped after the io.Copy causing issues with further reading of it. Add a PingMethod for the Open call. This way we send something for the client to interpret instead of nothing. Update tests. Signed-off-by: Jacob Weinstock * Update RPC example Signed-off-by: Jacob Weinstock * addressed feedback: renamed all the things to be clearer, fixed example * Remove returning the response body: In testing this was causing issues with consumers of the provider using the error string. Also, for security reasons we might not want to return an arbitrary response body, especially when it doesn't conform to the response contract. Signed-off-by: Jacob Weinstock * providers/redfish: client config parameter to disable Etag If-Match headers Work around for (crappy) vendor implementations where the If-Match header does not work - even with '*' as the value, requests are incorrectly denied with an ETag mismatch error. depends on https://github.com/stmcginnis/gofish/pull/277 * go: update gofish to current * Add test to pass github validation * providers/redfish/tasks: returns TaskNotFound on 404 * providers/redfish/tasks: cleanup * providers/supermicro: list other x11 hardware models supported * client: expose redfish etag match header disable parameter * client: move provider register methods into separate methods * client: remove unused error returns * User supplied http client in RPC provider: This plumbs through either the default or user supplied http client to the RPC provider. Signed-off-by: Jacob Weinstock * Fix session leak in Dell provider: This was leaving open sessions in non Dell machines. Closed sessions when the Dell Open function returns error after successfully opening. Signed-off-by: Jacob Weinstock * Add error test cases for Open method: Signed-off-by: Jacob Weinstock * Small clean ups Signed-off-by: Jacob Weinstock * Remove erroneous print line Signed-off-by: Jacob Weinstock * asrock/firmware: Different endpoint for E3C256D4I + add state "to be done" * asrock/helpers: preserve_config param for E3C256D4I * asrock: Add tests * redfishwrapper: add method to return list of virtual media currently inserted * supermicro: Add methods to upload and unmount a floppy image * Add methods to upload and unmount a floppy image * examples: Add example to upload and unmount a floppy image * bmc/floppy: fixes returned error and adds test cases * Rename UploadFloppyImage -> MountFloppyImage * providers/redfish: Add Odata ID for MegaRAC/AsRockRack Systems & Managers * Update go modules * providers/asrockrack: when checking component, use upper case * providers/arockrack: use debug variable * redfishwrapper/fixtures: add mock redfish endpoint data * redfishwrapper/client: add helper methods to return the manager, bios Odata ID * redfishwrapper: add Task helper methods * redfishwrapper: add firmware helper methods * providers/redfish: move DeviceVendorModel helper method into redfishwrapper * constants: define the FirmwareInstallStep, OperationApplyTime consts * asrockrack: use helper method from the common package * redfishwrapper/client: simpler logger setup * redfishwrapper: clean up a few commented out bits * bmc/firmware: rename constant and skip validating its value Since we need to accept more than just those two OperationApplyTime parameters * bmc/firmware: adds new interfaces for firmware upload, install uploaded, task status The FirmwareTaskStatus method is to replace FirmwareInstallStatus * providers: define the new features * errors: define a few common errors * examples: update based on changes * supermicro/firmware: rework to support x12s and the newer firmware interface methods * providers/redfish: fix bad import * go: update to current gofish * FirmwareUpload interface to accept a *os.File instead There has been no real use for having an io.Reader passed in and this interface is to expect a file instead. * providers/supermicro: define a bmcQueryor interface and implement for x11, x12 based on feedback recieved this approach made more sense * supermicro: support SMC BMCs that don't implement CSRF tokens Older X11 BMC firmwares don't include CSRF tokens in requests to the BMC API. * providers/smc: purge unused error * client: add WithTraceProvider option to trace client methods Each traced method is annotated with client metadata * go: add otel deps * go: update misc deps * bmc: Adds a TaskState constant type which is returned by FirmwareTaskStatus Update the FirmwareTaskStatus method to return the the TaskState constant. This is to remove the FirmwareInstall prefix from the current FirmwareInstall* constants and to have a generic Task State type. * redfishwrapper: to return constants.TaskState * providers/supermicro: return TaskState constant in FirmwareTaskStatus * bmc/firmware: import bmclib/errors as bmclibErrors * bmc/firmware_test: fix unused import * Add GetBootDeviceOverride support for redfish (#367) Add GetBootDeviceOverride support for redfish * asrockrack: Return unknown when nil progress and version match * asrockrack: Rewind file read from beginning in uploadFirmware * redfish/inventory: move Inventory method under internal/redfishwrapper This enables other providers to reuse the Inventory method and customise its use based on the vendor/model * providers/redfish: firmware methods moved into redfishwrapper The FirmwareUpload and related methods in redfishwrapper are more generic and can be re-used by providers with OEM specific update parameters. Having these methods in the redfish provider makes it cumbersome to maintain and extend. * providers/redfish: moved GetBiosConfiguration method under redfishwrapper The wrapper provides implementations other providers can call into for code reuse * redfishwrapper/power: move implementation here for re-use * providers/redfish: Inventory, FirmwareInstall, PowerSet, PowerState moved method internals into the redfishwrapper so they can be reused by other providers * redfishwrapper: minor lint fixes * redfishwrapper/task: include task message in info This helps with debugging failed tasks * redfishwrapper/task: lowercase task status before match * bmc/firmware: fix up inconsistent metadata obj init and error collection * providers/redfish: purge un-used methods * supermicro: implement Inventory, PowerSet, PowerStateGet methods * providers/supermicro: fix up redfish session init and purge unused methods * providers/supermicro: fix TestOpen() * redfish/GetBiosconfiguration: tests and fixtures moved under redfishwrapper package * redfishwrapper/firmware: lets not strip the JID_ prefix, since the method should be generic * bmc/firmware: initialize metadata object properly * bmc/firmware: defines interface to upload and install firmware in the same method * providers/dell: adds a helper method and implements Inventory(), PowerSet(), PowerStateGet() methods * providers/dell: Implements FirmwareInstallSteps(), FirmwareInstallUploadedAndInitiate(), FirmwareInstallStatus() methods * go: update gofish to include Task Oem data fix https://github.com/stmcginnis/gofish/pull/289 * providers/redfish: task methods moved under redfishwrapper package * squash * providers/redfish: dell tests moved under dell provider * redfishwrapper: minor fix for test * Retrieve SEL in both opinionated and raw formats * Fix the conflict Clipped one line too high when fixing the conflicts earlier * Updated devcontainer * openbmc: add provider * client: register openbmc provider * constants: remove state which indicates the BMC requires a power cycle This is replaced by the firmware install step, and subsequently will be moved into a type FirmwareInstallProperties{} which will replace FirmwareInstallSteps * errors: ErrBMCUpdating is returned if the BMC is known to be going through an update * providers/asrockrack: update for newer Firmware interface methods - Fix up inventory collection on E3C256D4ID-NL * providers/asrockrack: rework firmware install methods for newer interface * providers/asrockrack: GetPowerState() return ErrBMCUpdating when the BMC is under and update * providers/asrr: use constants for the model names * providers/dell: implement the BMCResetter interface * openbmc, supermicro: implement BMCResetter interface This makes sure when the provider is pinned/filtered* the BmcReset method is available. * https://github.com/bmc-toolbox/bmclib#one-time-filtering * Incorporated changes from review, added test * Add change after rebase from main * Fix unit test TestConvertTaskState * Add DeactivateSOL method This method will terminate an SOL (serial-over-lan) session currently active on the BMC (if there is one). The only provider implementing it is ipmitool, via 'ipmitool sol deactivate'. * Add tests for SOL deactivation * Return err if open fails: Returning the error will ensure the provider is removed from future calls. * Add support for sending NMI Support for sending an NMI has been added to ipmi, redfish, redfishwrapper, and all providers that use the redfishwrapper. * Upgrade go to 1.22 and dependencies * Use go-version-file rather than go-version * Add SetBiosConfiguration(ctx, biosConfig) as well as ResetBiosConfiguration(ctx) to redfishwrapper, redfish, etc. * go get -u -d * Remove go.opencensus.io otel lib * ResetBiosConfiguration() functionality * Clean up unused parameters * Tidy go.mod * Utilize gofish's UpdateBiosAttributesApplyAt and force OnReset for ApplyAt * examples/bios: An example that exercises SetBiosConfiguration, GetBiosConfiguration and ResetBiosConfiguration * Improve fatal error handling, add support for reading bios config from json file * Handle invalid mode specification * Use newMetadata() helper * providers/supermicro: add x11spo-ntf in supported list This has been tested and works * providers/supermicro: Close session if any login dependencies fail This prevents the client from assumptions that the client has an active session available * providers/dell: reuse ErrUnsupportedHardware from defined in bmclib/errors instead of defining another one * providers/dell: verify vendor model before proceeding This is to ensure the code within this provider only executes on dells * providers/asrr: verify hardware supported before firmware action * providers/openbmc: verify hardware support in all methods This is to ensure the Openbmc provider executes code only on hardware identified to be Openbmc * Revert "providers/dell: verify vendor model before proceeding" This reverts commit 43d8535dec56d6ac9bdf5c4b25e5d851330aecba. * Revert "providers/openbmc: verify hardware support in all methods" This reverts commit 902ddd166b09206235070846d50a5624732fea8d. * provider/dell: remove unused import * supermicro/x12: use maps for OEM parameters and add X12SPO-NTF BIOS params * supermicro/x11: lower case device model before comparison This is a regression from a rework on the provider done earlier * Move back to Go 1.18 in go.mod: We don't seem to have an offical strategy for the Go version in go.mod. I believe that, as a library, we should only bump this version if there are dependencies we use that would require us to move to this version. I don't believe that we have any dependencies that warrant a bump. Signed-off-by: Jacob Weinstock * Document go.mod version philosophy: This makes official our philosophy and policy for the Go version in go.mod. Signed-off-by: Jacob Weinstock * FS-1123: Supermicro (SMC) BIOS config Get, Set and Reset support via SUM * FS-1123: Update devcontainer Dockerfile to use go 1.22 * FS-1123: Import latest bmc-toolbox/common * FS-1123: Enhancements to examples/bios * FS-1123: Supermicro Update Manager (SUM) provider * FS-1123: Pin to bmc-toolbox/common@cfd9f1a6c4ad3253e074a34bca4e2aa7f2463eab * go.mod: Update bmc-toolbox/common version * internal/sum: Convert providers/sum into an internal package * internal/sum/sum.go: Use explict returns * go.sum: go mod tidy * Implement sum test cases and a mocked Executor model * internal/sum/sum.go: s/Mvcli/Sum/g * internal/sum/sum.go: Remove unused NewFakeSum func * internal/sum/sum.go: Provide a link and description re supermicro update mananger(sum) * internal/sum/sum_test.go: Fix test for Sum.Run(); Upgrade bmc-toolbox/common version * fixtures/internal/sum/GetBIOSInfo: Add mock data for GetBIOSInfo command * go.mod: Update bmc-toolbox/common to ba8adc6a35e37791d7e47e5022214224e0e46418 * internal/executor: Remove unused interface funcs * port to gofish/redfish v0.19.0 (#395) * port to gofish/redfish v0.19.0 * update toolchain, golangci-lint, fix linting issues * don't use errors.Wrap in new code * stay on go 1.18 * revert upgrade of golangci-lint * go 1.21 is required for redfish v0.19.0 * Add SetBiosConfigurationFromFile client functions * support BootProgress on SMC X12/X13 (#396) * WIP: support BootProgress on SMC X12/X13 * add some requested comments * Update virtual media mounting: At least one BMC, Supermicro SYS-E300-D9, did not support setting inserted and/or writeProtected properties in redfish calls to do a virtual media mount. This falls back to not using them if the initial call with them in the properties fails. This was test and worked successfully on a Supermicro (SYS-E300-D9), HP ILO5, and Dell iDRAC9. Signed-off-by: Jacob Weinstock * Return early for improved code clarity: This helps understand and maintain-ability. Decreases the complexity of the function too. Signed-off-by: Jacob Weinstock * Add example for virtual media: This helps users see how to use the virtual media mounting capabilities. Signed-off-by: Jacob Weinstock * expand response payload processing (#399) In contrast to other server-vendors, SMC does not return the task id in the Location header of the response to a firmware upload. In BMC version 1.05.03 (Redfish version 1.14.0) the payload format changes from a TaskAccepted message to a Redfish task, which breaks task id detection. This change adds an attempt to deserialize the task structure before falling back to the earlier TaskAccepted message type. This also corrects the startUpdateURI. * Allow specifying the Redfish system name: For Redfish implementations that manage multiple systems bening able to specify the system name is required. This is the case with sushy-tools, which is a redfish emulator for libvirt and others. Signed-off-by: Jacob Weinstock * Update to latest version of golangci-lint Signed-off-by: Jacob Weinstock * Add system name checking to the Managers helper method: This allows the systemName struct field to filter the managers. Needed for things like inserting and ejecting virtual media. Signed-off-by: Jacob Weinstock * Make sure the System helper method is used: Many of the existing redfish feature methods weren't using the System helper that filtered for system name. Also, for virtual media ejecting, handle BMC's that don't support the "inserted" property. Signed-off-by: Jacob Weinstock * providers/supermicro/supermicro.go: Initialize sum 'client' during serviceclient init * Update tests to validate new error return is nil * providers/supermicro/supermicro.go: Explain why we return nil in NewClient() * Fix SetBiosFromFile Fix example --------- Signed-off-by: Matthew Burns Signed-off-by: Jacob Weinstock Co-authored-by: Matthew Burns Co-authored-by: Olivier FAURAX Co-authored-by: Joel Rebello Co-authored-by: Olivier FAURAX Co-authored-by: Jacob Weinstock Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Matthew Burns Co-authored-by: John Mears <20019566+coffeefreak101@users.noreply.github.com> Co-authored-by: John Mears Co-authored-by: Zev Weiss Co-authored-by: Zev Weiss Co-authored-by: James W. Brinkerhoff Co-authored-by: James W. Brinkerhoff Co-authored-by: Jake Schuurmans Co-authored-by: Jake Schuurmans <143427381+jakeschuurmans@users.noreply.github.com> --- .devcontainer/Dockerfile | 4 + .devcontainer/devcontainer.json | 33 + .github/workflows/ci.yaml | 4 +- README.md | 21 + bmc/bios.go | 191 +- bmc/bmc.go | 37 + bmc/boot_device.go | 97 +- bmc/boot_device_test.go | 128 + bmc/connection.go | 12 +- bmc/firmware.go | 422 +- bmc/firmware_test.go | 468 ++- bmc/floppy.go | 150 + bmc/floppy_test.go | 114 + bmc/inventory.go | 2 + bmc/nmi.go | 66 + bmc/nmi_test.go | 124 + bmc/power.go | 4 + bmc/reset.go | 2 + bmc/sel.go | 175 + bmc/sel_test.go | 135 + bmc/sol.go | 70 + bmc/sol_test.go | 99 + client.go | 406 +- client_test.go | 4 +- constants/constants.go | 87 +- errors/errors.go | 21 + examples/bios/main.go | 102 +- examples/floppy-image/doc.go | 19 + examples/floppy-image/main.go | 79 + examples/install-firmware/main.go | 4 +- examples/main.go | 92 - examples/rpc/main.go | 2 + examples/sel/main.go | 89 + examples/virtualmedia/doc.go | 18 + examples/virtualmedia/main.go | 51 + fixtures/internal/sum/ChangeBiosConfig | 11 + .../internal/sum/ChangeBiosConfig-Changed | 7 + .../sum/ChangeBiosConfig-Changed-Reboot | 16 + fixtures/internal/sum/GetBIOSInfo | 7 + fixtures/internal/sum/GetBiosConfiguration | 3634 +++++++++++++++++ fixtures/internal/sum/SetBiosConfiguration | 16 + go.mod | 31 +- go.sum | 70 +- internal/executor/errors.go | 44 + internal/executor/executor.go | 126 + internal/executor/executor_test.go | 116 + internal/executor/fake_executor.go | 100 + internal/ipmi/ipmi.go | 70 + internal/redfishwrapper/bios.go | 97 + internal/redfishwrapper/bios_test.go | 94 + internal/redfishwrapper/boot_device.go | 143 +- internal/redfishwrapper/client.go | 147 + internal/redfishwrapper/client_test.go | 284 ++ internal/redfishwrapper/firmware.go | 455 +++ internal/redfishwrapper/firmware_test.go | 406 ++ .../redfishwrapper/fixtures}/dell/bios.json | 0 .../fixtures/dell/serviceroot.json | 80 + .../fixtures}/dell/system.embedded.1.json | 0 .../redfishwrapper/fixtures/dell/systems.json | 13 + .../redfishwrapper/fixtures/managers.json | 12 + .../redfishwrapper/fixtures/managers_1.json | 92 + .../redfishwrapper/fixtures/serviceroot.json | 62 + .../fixtures/serviceroot_no_manager.json | 59 + .../fixtures/smc_1.14.0_serviceroot.json | 1 + .../fixtures/smc_1.14.0_systems.json | 1 + .../fixtures/smc_1.14.0_systems_1.json | 1 + .../fixtures/smc_1.9.0_serviceroot.json | 1 + internal/redfishwrapper/fixtures/systems.json | 12 + .../redfishwrapper/fixtures/systems_1.json | 116 + .../fixtures/systems_1_no_bios.json | 116 + .../redfishwrapper/fixtures/systems_bios.json | 19 + internal/redfishwrapper/fixtures/tasks.json | 15 + .../fixtures/tasks/tasks_1_completed.json | 27 + .../fixtures/tasks/tasks_1_failed.json | 27 + .../fixtures/tasks/tasks_1_pending.json | 27 + .../fixtures/tasks/tasks_1_running.json | 27 + .../fixtures/tasks/tasks_1_scheduled.json | 27 + .../fixtures/tasks/tasks_1_starting.json | 27 + .../fixtures/tasks/tasks_1_unknown.json | 27 + .../fixtures/tasks/tasks_2.json | 27 + .../redfishwrapper/fixtures/taskservice.json | 18 + .../fixtures/updateservice_disabled.json | 41 + .../fixtures/updateservice_ok_response.json | 20 + .../updateservice_unexpected_response.json | 16 + .../updateservice_with_httppushuri.json | 18 + .../updateservice_with_multipart.json | 41 + .../redfishwrapper}/inventory.go | 133 +- .../redfishwrapper}/inventory_collect.go | 70 +- .../redfishwrapper}/inventory_collect_test.go | 38 +- internal/redfishwrapper/main_test.go | 44 + internal/redfishwrapper/power.go | 71 +- internal/redfishwrapper/sel.go | 112 + internal/redfishwrapper/system.go | 48 +- internal/redfishwrapper/task.go | 103 + internal/redfishwrapper/task_test.go | 302 ++ internal/redfishwrapper/virtual_media.go | 91 +- internal/sum/sum.go | 262 ++ internal/sum/sum_test.go | 90 + lint.mk | 2 +- option.go | 23 + providers/asrockrack/asrockrack.go | 67 +- providers/asrockrack/asrockrack_test.go | 33 +- providers/asrockrack/firmware.go | 208 +- providers/asrockrack/helpers.go | 59 +- providers/asrockrack/helpers_test.go | 18 +- providers/asrockrack/inventory.go | 20 +- providers/asrockrack/inventory_test.go | 5 +- providers/asrockrack/mock_test.go | 8 +- providers/asrockrack/power.go | 27 +- providers/asrockrack/user_test.go | 6 +- providers/dell/firmware.go | 231 ++ providers/dell/firmware_test.go | 94 + .../systems_embedded_no_manufacturer.1.json | 525 +++ .../fixtures/systems_embedded_not_dell.1.json | 525 +++ providers/dell/idrac.go | 75 +- providers/dell/idrac_test.go | 146 +- providers/ipmitool/ipmitool.go | 26 + providers/ipmitool/ipmitool_test.go | 90 + providers/openbmc/firmware.go | 100 + providers/openbmc/openbmc.go | 191 + providers/providers.go | 45 + providers/redfish/bios.go | 36 - providers/redfish/bios_test.go | 57 - providers/redfish/firmware.go | 424 -- providers/redfish/firmware_test.go | 235 -- .../redfish/fixtures/v1/dell/entries.json | 50 + .../redfish/fixtures/v1/dell/logservices.json | 13 + .../fixtures/v1/dell/logservices.sel.json | 22 + .../v1/dell/manager.idrac.embedded.1.json | 9 + .../redfish/fixtures/v1/dell/managers.json | 13 + .../fixtures/v1/dell/selentries/1.json | 20 + .../fixtures/v1/dell/selentries/2.json | 20 + providers/redfish/main_test.go | 22 +- providers/redfish/redfish.go | 96 +- providers/redfish/sel.go | 15 + providers/redfish/sel_test.go | 29 + providers/redfish/tasks.go | 173 - providers/redfish/tasks_test.go | 40 - providers/rpc/http.go | 3 +- providers/rpc/http_test.go | 29 +- providers/rpc/logging.go | 18 +- providers/rpc/payload.go | 1 + providers/rpc/rpc.go | 51 +- providers/rpc/rpc_test.go | 4 +- .../docs/20230907_2-RedfishRefGuide.pdf | Bin 0 -> 2468523 bytes providers/supermicro/docs/x11.md | 31 + providers/supermicro/docs/x12.md | 118 + providers/supermicro/errors.go | 5 +- providers/supermicro/firmware.go | 116 +- providers/supermicro/firmware_bios_test.go | 15 +- .../supermicro/fixtures/serviceroot.json | 62 + providers/supermicro/floppy.go | 159 + providers/supermicro/supermicro.go | 345 +- providers/supermicro/supermicro_test.go | 136 +- providers/supermicro/x11.go | 154 + ...{firmware_bios.go => x11_firmware_bios.go} | 96 +- .../{firmware_bmc.go => x11_firmware_bmc.go} | 84 +- ...e_bmc_test.go => x11_firmware_bmc_test.go} | 72 +- providers/supermicro/x12.go | 334 ++ 159 files changed, 15123 insertions(+), 2048 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 bmc/floppy.go create mode 100644 bmc/floppy_test.go create mode 100644 bmc/nmi.go create mode 100644 bmc/nmi_test.go create mode 100644 bmc/sel.go create mode 100644 bmc/sel_test.go create mode 100644 bmc/sol.go create mode 100644 bmc/sol_test.go create mode 100644 examples/floppy-image/doc.go create mode 100644 examples/floppy-image/main.go delete mode 100644 examples/main.go create mode 100644 examples/sel/main.go create mode 100644 examples/virtualmedia/doc.go create mode 100644 examples/virtualmedia/main.go create mode 100644 fixtures/internal/sum/ChangeBiosConfig create mode 100644 fixtures/internal/sum/ChangeBiosConfig-Changed create mode 100644 fixtures/internal/sum/ChangeBiosConfig-Changed-Reboot create mode 100644 fixtures/internal/sum/GetBIOSInfo create mode 100644 fixtures/internal/sum/GetBiosConfiguration create mode 100644 fixtures/internal/sum/SetBiosConfiguration create mode 100644 internal/executor/errors.go create mode 100644 internal/executor/executor.go create mode 100644 internal/executor/executor_test.go create mode 100644 internal/executor/fake_executor.go create mode 100644 internal/redfishwrapper/bios.go create mode 100644 internal/redfishwrapper/bios_test.go create mode 100644 internal/redfishwrapper/firmware.go create mode 100644 internal/redfishwrapper/firmware_test.go rename {providers/redfish/fixtures/v1 => internal/redfishwrapper/fixtures}/dell/bios.json (100%) create mode 100644 internal/redfishwrapper/fixtures/dell/serviceroot.json rename {providers/redfish/fixtures/v1 => internal/redfishwrapper/fixtures}/dell/system.embedded.1.json (100%) create mode 100644 internal/redfishwrapper/fixtures/dell/systems.json create mode 100644 internal/redfishwrapper/fixtures/managers.json create mode 100644 internal/redfishwrapper/fixtures/managers_1.json create mode 100644 internal/redfishwrapper/fixtures/serviceroot.json create mode 100644 internal/redfishwrapper/fixtures/serviceroot_no_manager.json create mode 100644 internal/redfishwrapper/fixtures/smc_1.14.0_serviceroot.json create mode 100644 internal/redfishwrapper/fixtures/smc_1.14.0_systems.json create mode 100644 internal/redfishwrapper/fixtures/smc_1.14.0_systems_1.json create mode 100644 internal/redfishwrapper/fixtures/smc_1.9.0_serviceroot.json create mode 100644 internal/redfishwrapper/fixtures/systems.json create mode 100644 internal/redfishwrapper/fixtures/systems_1.json create mode 100644 internal/redfishwrapper/fixtures/systems_1_no_bios.json create mode 100644 internal/redfishwrapper/fixtures/systems_bios.json create mode 100644 internal/redfishwrapper/fixtures/tasks.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_completed.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_failed.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_pending.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_running.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_scheduled.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_starting.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_1_unknown.json create mode 100644 internal/redfishwrapper/fixtures/tasks/tasks_2.json create mode 100644 internal/redfishwrapper/fixtures/taskservice.json create mode 100644 internal/redfishwrapper/fixtures/updateservice_disabled.json create mode 100644 internal/redfishwrapper/fixtures/updateservice_ok_response.json create mode 100644 internal/redfishwrapper/fixtures/updateservice_unexpected_response.json create mode 100644 internal/redfishwrapper/fixtures/updateservice_with_httppushuri.json create mode 100644 internal/redfishwrapper/fixtures/updateservice_with_multipart.json rename {providers/redfish => internal/redfishwrapper}/inventory.go (63%) rename {providers/redfish => internal/redfishwrapper}/inventory_collect.go (78%) rename {providers/redfish => internal/redfishwrapper}/inventory_collect_test.go (86%) create mode 100644 internal/redfishwrapper/main_test.go create mode 100644 internal/redfishwrapper/sel.go create mode 100644 internal/redfishwrapper/task.go create mode 100644 internal/redfishwrapper/task_test.go create mode 100644 internal/sum/sum.go create mode 100644 internal/sum/sum_test.go create mode 100644 providers/dell/firmware.go create mode 100644 providers/dell/firmware_test.go create mode 100644 providers/dell/fixtures/systems_embedded_no_manufacturer.1.json create mode 100644 providers/dell/fixtures/systems_embedded_not_dell.1.json create mode 100644 providers/openbmc/firmware.go create mode 100644 providers/openbmc/openbmc.go delete mode 100644 providers/redfish/bios.go delete mode 100644 providers/redfish/bios_test.go delete mode 100644 providers/redfish/firmware.go delete mode 100644 providers/redfish/firmware_test.go create mode 100644 providers/redfish/fixtures/v1/dell/entries.json create mode 100644 providers/redfish/fixtures/v1/dell/logservices.json create mode 100644 providers/redfish/fixtures/v1/dell/logservices.sel.json create mode 100644 providers/redfish/fixtures/v1/dell/manager.idrac.embedded.1.json create mode 100644 providers/redfish/fixtures/v1/dell/managers.json create mode 100644 providers/redfish/fixtures/v1/dell/selentries/1.json create mode 100644 providers/redfish/fixtures/v1/dell/selentries/2.json create mode 100644 providers/redfish/sel.go create mode 100644 providers/redfish/sel_test.go delete mode 100644 providers/redfish/tasks.go delete mode 100644 providers/redfish/tasks_test.go create mode 100644 providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf create mode 100644 providers/supermicro/docs/x11.md create mode 100644 providers/supermicro/docs/x12.md create mode 100644 providers/supermicro/fixtures/serviceroot.json create mode 100644 providers/supermicro/floppy.go create mode 100644 providers/supermicro/x11.go rename providers/supermicro/{firmware_bios.go => x11_firmware_bios.go} (82%) rename providers/supermicro/{firmware_bmc.go => x11_firmware_bmc.go} (78%) rename providers/supermicro/{firmware_bmc_test.go => x11_firmware_bmc_test.go} (84%) create mode 100644 providers/supermicro/x12.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..94d0550e5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,4 @@ +FROM mcr.microsoft.com/devcontainers/go:1-1.22-bullseye +RUN apt update +RUN apt install ipmitool -y + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..cf9ee8e30 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/go +{ + "name": "Go", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + // "image": "mcr.microsoft.com/devcontainers/go:1-1.21-bullseye", + "build": { + "dockerfile": "Dockerfile" + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "go version", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.makefile-tools", + "zxh404.vscode-proto3", + "humao.rest-client" + ] + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 759776ac5..637c879d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: - name: Install Go uses: actions/setup-go@v3 with: - go-version: '1.18' + go-version-file: go.mod - name: Run golangci-lint uses: golangci/golangci-lint-action@v3 with: @@ -34,7 +34,7 @@ jobs: - name: Install Go uses: actions/setup-go@v3 with: - go-version: '1.18' + go-version-file: go.mod - name: make all-tests run: make all-tests - name: upload codecov diff --git a/README.md b/README.md index 8ff71570a..ce9cb280f 100644 --- a/README.md +++ b/README.md @@ -234,12 +234,33 @@ The following one-time filters are available: - `cl.For("gofish").GetPowerState(ctx)` - This removes any provider from the registry that is not the `gofish` provider. - `cl.PreferProtocol("redfish").GetPowerState(ctx)` - This moves any provider that implements the `redfish` protocol to the beginning of the registry. +### Tracing + +To collect trace telemetry, set the `WithTraceProvider()` option on the client +which results in trace spans being collected for each client method. + +```go +cl := bmclib.NewClient( + host, + user, + pass, + bmclib.WithLogger(log), + bmclib.WithTracerProvider(otel.GetTracerProvider()), + ) +``` + ## Versions The current bmclib version is `v2` and is being developed on the `main` branch. The previous bmclib version is in maintenance mode and can be found here [v1](https://github.com/bmc-toolbox/bmclib/v1). +## Go version in `go.mod` + +As a library we will only bump the version of Go in the `go.mod` file when there are required dependencies in bmclib that necessitate +a version bump. When consuming bmclib in your project, we recommend always building with the latest Go version but this +should be in your hands as a user as much as possible. + ## Acknowledgments bmclib v2 interfaces with Redfish on BMCs through the Gofish library https://github.com/stmcginnis/gofish diff --git a/bmc/bios.go b/bmc/bios.go index e1230fcaa..d578abe24 100644 --- a/bmc/bios.go +++ b/bmc/bios.go @@ -18,8 +18,27 @@ type biosConfigurationGetterProvider struct { BiosConfigurationGetter } +type BiosConfigurationSetter interface { + SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) + SetBiosConfigurationFromFile(ctx context.Context, cfg string) (err error) +} + +type biosConfigurationSetterProvider struct { + name string + BiosConfigurationSetter +} + +type BiosConfigurationResetter interface { + ResetBiosConfiguration(ctx context.Context) (err error) +} + +type biosConfigurationResetterProvider struct { + name string + BiosConfigurationResetter +} + func biosConfiguration(ctx context.Context, generic []biosConfigurationGetterProvider) (biosConfig map[string]string, metadata Metadata, err error) { - var metadataLocal Metadata + metadata = newMetadata() Loop: for _, elem := range generic { if elem.BiosConfigurationGetter == nil { @@ -30,7 +49,7 @@ Loop: err = multierror.Append(err, ctx.Err()) break Loop default: - metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) biosConfig, vErr := elem.GetBiosConfiguration(ctx) if vErr != nil { err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) @@ -38,12 +57,96 @@ Loop: continue } - metadataLocal.SuccessfulProvider = elem.name - return biosConfig, metadataLocal, nil + metadata.SuccessfulProvider = elem.name + return biosConfig, metadata, nil } } - return biosConfig, metadataLocal, multierror.Append(err, errors.New("failure to get bios configuration")) + return biosConfig, metadata, multierror.Append(err, errors.New("failure to get bios configuration")) +} + +func setBiosConfiguration(ctx context.Context, generic []biosConfigurationSetterProvider, biosConfig map[string]string) (metadata Metadata, err error) { + metadata = newMetadata() +Loop: + for _, elem := range generic { + if elem.BiosConfigurationSetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + break Loop + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + vErr := elem.SetBiosConfiguration(ctx, biosConfig) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadata.SuccessfulProvider = elem.name + return metadata, nil + } + } + + return metadata, multierror.Append(err, errors.New("failure to set bios configuration")) +} + +func setBiosConfigurationFromFile(ctx context.Context, generic []biosConfigurationSetterProvider, cfg string) (metadata Metadata, err error) { + metadata = newMetadata() +Loop: + for _, elem := range generic { + if elem.BiosConfigurationSetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + break Loop + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + vErr := elem.SetBiosConfigurationFromFile(ctx, cfg) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadata.SuccessfulProvider = elem.name + return metadata, nil + } + } + + return metadata, multierror.Append(err, errors.New("failure to set bios configuration from file")) +} + +func resetBiosConfiguration(ctx context.Context, generic []biosConfigurationResetterProvider) (metadata Metadata, err error) { + metadata = newMetadata() +Loop: + for _, elem := range generic { + if elem.BiosConfigurationResetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + break Loop + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + vErr := elem.ResetBiosConfiguration(ctx) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + err = multierror.Append(err, vErr) + continue + + } + metadata.SuccessfulProvider = elem.name + return metadata, nil + } + } + + return metadata, multierror.Append(err, errors.New("failure to reset bios configuration")) } func GetBiosConfigurationInterfaces(ctx context.Context, generic []interface{}) (biosConfig map[string]string, metadata Metadata, err error) { @@ -71,3 +174,81 @@ func GetBiosConfigurationInterfaces(ctx context.Context, generic []interface{}) return biosConfiguration(ctx, implementations) } + +func SetBiosConfigurationInterfaces(ctx context.Context, generic []interface{}, biosConfig map[string]string) (metadata Metadata, err error) { + implementations := make([]biosConfigurationSetterProvider, 0) + for _, elem := range generic { + temp := biosConfigurationSetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case BiosConfigurationSetter: + temp.BiosConfigurationSetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a BiosConfigurationSetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no BiosConfigurationSetter implementations found"), + ), + ) + } + + return setBiosConfiguration(ctx, implementations, biosConfig) +} + +func SetBiosConfigurationFromFileInterfaces(ctx context.Context, generic []interface{}, cfg string) (metadata Metadata, err error) { + implementations := make([]biosConfigurationSetterProvider, 0) + for _, elem := range generic { + temp := biosConfigurationSetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case BiosConfigurationSetter: + temp.BiosConfigurationSetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a BiosConfigurationSetterFromFile implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no BiosConfigurationSetterFromFile implementations found"), + ), + ) + } + + return setBiosConfigurationFromFile(ctx, implementations, cfg) +} + +func ResetBiosConfigurationInterfaces(ctx context.Context, generic []interface{}) (metadata Metadata, err error) { + implementations := make([]biosConfigurationResetterProvider, 0) + for _, elem := range generic { + temp := biosConfigurationResetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case BiosConfigurationResetter: + temp.BiosConfigurationResetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a BiosConfigurationResetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no BiosConfigurationResetter implementations found"), + ), + ) + } + + return resetBiosConfiguration(ctx, implementations) +} diff --git a/bmc/bmc.go b/bmc/bmc.go index fdbd73297..5fbd275d0 100644 --- a/bmc/bmc.go +++ b/bmc/bmc.go @@ -1,5 +1,12 @@ package bmc +import ( + "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + // Metadata represents details about a bmc method type Metadata struct { // SuccessfulProvider is the name of the provider that successfully executed @@ -13,3 +20,33 @@ type Metadata struct { // FailedProviderDetail holds the failed providers error messages for called methods FailedProviderDetail map[string]string } + +func newMetadata() Metadata { + return Metadata{ + FailedProviderDetail: make(map[string]string), + } +} + +func (m *Metadata) RegisterSpanAttributes(host string, span trace.Span) { + span.SetAttributes(attribute.String("host", host)) + + span.SetAttributes(attribute.String("successful-provider", m.SuccessfulProvider)) + + span.SetAttributes( + attribute.String("successful-open-conns", strings.Join(m.SuccessfulOpenConns, ",")), + ) + + span.SetAttributes( + attribute.String("successful-close-conns", strings.Join(m.SuccessfulCloseConns, ",")), + ) + + span.SetAttributes( + attribute.String("attempted-providers", strings.Join(m.ProvidersAttempted, ",")), + ) + + for p, e := range m.FailedProviderDetail { + span.SetAttributes( + attribute.String("provider-errs-"+p, e), + ) + } +} diff --git a/bmc/boot_device.go b/bmc/boot_device.go index ceabb9919..27c236cfd 100644 --- a/bmc/boot_device.go +++ b/bmc/boot_device.go @@ -9,25 +9,56 @@ import ( "github.com/pkg/errors" ) +type BootDeviceType string + +const ( + BootDeviceTypeBIOS BootDeviceType = "bios" + BootDeviceTypeCDROM BootDeviceType = "cdrom" + BootDeviceTypeDiag BootDeviceType = "diag" + BootDeviceTypeFloppy BootDeviceType = "floppy" + BootDeviceTypeDisk BootDeviceType = "disk" + BootDeviceTypeNone BootDeviceType = "none" + BootDeviceTypePXE BootDeviceType = "pxe" + BootDeviceTypeRemoteDrive BootDeviceType = "remote_drive" + BootDeviceTypeSDCard BootDeviceType = "sd_card" + BootDeviceTypeUSB BootDeviceType = "usb" + BootDeviceTypeUtil BootDeviceType = "utilities" +) + // BootDeviceSetter sets the next boot device for a machine type BootDeviceSetter interface { BootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) } +// BootDeviceOverrideGetter gets boot override settings for a machine +type BootDeviceOverrideGetter interface { + BootDeviceOverrideGet(ctx context.Context) (override BootDeviceOverride, err error) +} + // bootDeviceProviders is an internal struct to correlate an implementation/provider and its name type bootDeviceProviders struct { name string bootDeviceSetter BootDeviceSetter } +// bootOverrideProvider is an internal struct to correlate an implementation/provider and its name +type bootOverrideProvider struct { + name string + bootOverrideGetter BootDeviceOverrideGetter +} + +type BootDeviceOverride struct { + IsPersistent bool + IsEFIBoot bool + Device BootDeviceType +} + // setBootDevice sets the next boot device. // // setPersistent persists the next boot device. // efiBoot sets up the device to boot off UEFI instead of legacy. func setBootDevice(ctx context.Context, timeout time.Duration, bootDevice string, setPersistent, efiBoot bool, b []bootDeviceProviders) (ok bool, metadata Metadata, err error) { - metadataLocal := Metadata{ - FailedProviderDetail: make(map[string]string), - } + metadataLocal := newMetadata() for _, elem := range b { if elem.bootDeviceSetter == nil { @@ -78,3 +109,63 @@ func SetBootDeviceFromInterfaces(ctx context.Context, timeout time.Duration, boo } return setBootDevice(ctx, timeout, bootDevice, setPersistent, efiBoot, bdSetters) } + +// getBootDeviceOverride gets the boot device override settings for the given provider, +// and updates the given metadata with provider attempts and errors. +func getBootDeviceOverride( + ctx context.Context, + timeout time.Duration, + provider *bootOverrideProvider, + metadata *Metadata, +) (override BootDeviceOverride, ok bool, err error) { + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + return override, ok, err + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, provider.name) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + override, err = provider.bootOverrideGetter.BootDeviceOverrideGet(ctx) + if err != nil { + metadata.FailedProviderDetail[provider.name] = err.Error() + return override, ok, nil + } + + metadata.SuccessfulProvider = provider.name + return override, true, nil + } +} + +// GetBootDeviceOverrideFromInterface will get boot device override settings from the first successful +// call to a BootDeviceOverrideGetter in the array of providers. +func GetBootDeviceOverrideFromInterface( + ctx context.Context, + timeout time.Duration, + providers []interface{}, +) (override BootDeviceOverride, metadata Metadata, err error) { + metadata = newMetadata() + + for _, elem := range providers { + switch p := elem.(type) { + case BootDeviceOverrideGetter: + provider := &bootOverrideProvider{name: getProviderName(elem), bootOverrideGetter: p} + override, ok, getErr := getBootDeviceOverride(ctx, timeout, provider, &metadata) + if getErr != nil || ok { + return override, metadata, getErr + } + default: + e := fmt.Errorf("not a BootDeviceOverrideGetter implementation: %T", p) + err = multierror.Append(err, e) + } + } + + if len(metadata.ProvidersAttempted) == 0 { + err = multierror.Append(err, errors.New("no BootDeviceOverrideGetter implementations found")) + } else { + err = multierror.Append(err, errors.New("failed to get boot device override settings")) + } + + return override, metadata, err +} diff --git a/bmc/boot_device_test.go b/bmc/boot_device_test.go index 103c904b8..17e10549b 100644 --- a/bmc/boot_device_test.go +++ b/bmc/boot_device_test.go @@ -6,8 +6,10 @@ import ( "testing" "time" + "fmt" "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-multierror" + "github.com/stretchr/testify/assert" ) type bootDeviceTester struct { @@ -117,3 +119,129 @@ func TestSetBootDeviceFromInterfaces(t *testing.T) { }) } } + +type mockBootDeviceOverrideGetter struct { + overrideReturn BootDeviceOverride + errReturn error +} + +func (m *mockBootDeviceOverrideGetter) Name() string { + return "Mock" +} + +func (m *mockBootDeviceOverrideGetter) BootDeviceOverrideGet(_ context.Context) (BootDeviceOverride, error) { + return m.overrideReturn, m.errReturn +} + +func TestBootDeviceOverrideGet(t *testing.T) { + successOverride := BootDeviceOverride{ + IsPersistent: false, + IsEFIBoot: true, + Device: BootDeviceTypeDisk, + } + + successMetadata := &Metadata{ + SuccessfulProvider: "Mock", + ProvidersAttempted: []string{"Mock"}, + SuccessfulOpenConns: nil, + SuccessfulCloseConns: []string(nil), + FailedProviderDetail: map[string]string{}, + } + + mixedMetadata := &Metadata{ + SuccessfulProvider: "Mock", + ProvidersAttempted: []string{"Mock", "Mock"}, + SuccessfulOpenConns: nil, + SuccessfulCloseConns: []string(nil), + FailedProviderDetail: map[string]string{"Mock": "foo-failure"}, + } + + failMetadata := &Metadata{ + SuccessfulProvider: "", + ProvidersAttempted: []string{"Mock"}, + SuccessfulOpenConns: nil, + SuccessfulCloseConns: []string(nil), + FailedProviderDetail: map[string]string{"Mock": "foo-failure"}, + } + + emptyMetadata := &Metadata{ + FailedProviderDetail: make(map[string]string), + } + + testCases := []struct { + name string + hasCanceledContext bool + expectedErrorMsg string + expectedMetadata *Metadata + expectedOverride BootDeviceOverride + getters []interface{} + }{ + { + name: "success", + expectedMetadata: successMetadata, + expectedOverride: successOverride, + getters: []interface{}{ + &mockBootDeviceOverrideGetter{overrideReturn: successOverride}, + }, + }, + { + name: "multiple getters", + expectedMetadata: mixedMetadata, + expectedOverride: successOverride, + getters: []interface{}{ + "not a getter", + &mockBootDeviceOverrideGetter{errReturn: fmt.Errorf("foo-failure")}, + &mockBootDeviceOverrideGetter{overrideReturn: successOverride}, + }, + }, + { + name: "error", + expectedMetadata: failMetadata, + expectedErrorMsg: "failed to get boot device override settings", + getters: []interface{}{ + &mockBootDeviceOverrideGetter{errReturn: fmt.Errorf("foo-failure")}, + }, + }, + { + name: "nil BootDeviceOverrideGetters", + expectedMetadata: emptyMetadata, + expectedErrorMsg: "no BootDeviceOverrideGetter implementations found", + }, + { + name: "nil BootDeviceOverrideGetter", + expectedMetadata: emptyMetadata, + expectedErrorMsg: "no BootDeviceOverrideGetter implementations found", + getters: []interface{}{nil}, + }, + { + name: "with canceled context", + hasCanceledContext: true, + expectedMetadata: emptyMetadata, + expectedErrorMsg: "context canceled", + getters: []interface{}{ + &mockBootDeviceOverrideGetter{}, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if testCase.hasCanceledContext { + cancel() + } + + override, metadata, err := GetBootDeviceOverrideFromInterface(ctx, 0, testCase.getters) + + if testCase.expectedErrorMsg != "" { + assert.ErrorContains(t, err, testCase.expectedErrorMsg) + } else { + assert.Nil(t, err) + } + assert.Equal(t, testCase.expectedOverride, override) + assert.Equal(t, testCase.expectedMetadata, &metadata) + }) + } +} diff --git a/bmc/connection.go b/bmc/connection.go index f861bd130..947fc1c1a 100644 --- a/bmc/connection.go +++ b/bmc/connection.go @@ -30,9 +30,7 @@ type connectionProviders struct { // The reason failed ones need to be removed is so that when other methods are called (like powerstate) // implementations that have connections wont nil pointer error when their connection fails. func OpenConnectionFromInterfaces(ctx context.Context, timeout time.Duration, providers []interface{}) (opened []interface{}, metadata Metadata, err error) { - metadata = Metadata{ - FailedProviderDetail: make(map[string]string), - } + metadata = newMetadata() // Return immediately if the context is done. select { @@ -110,10 +108,8 @@ func OpenConnectionFromInterfaces(ctx context.Context, timeout time.Duration, pr } // closeConnection closes a connection to a BMC, trying all interface implementations passed in -func closeConnection(ctx context.Context, c []connectionProviders) (_ Metadata, err error) { - var metadata = Metadata{ - FailedProviderDetail: make(map[string]string), - } +func closeConnection(ctx context.Context, c []connectionProviders) (metadata Metadata, err error) { + metadata = newMetadata() var connClosed bool for _, elem := range c { @@ -138,6 +134,8 @@ func closeConnection(ctx context.Context, c []connectionProviders) (_ Metadata, // CloseConnectionFromInterfaces identifies implementations of the Closer() interface and and passes the found implementations to the closeConnection() wrapper func CloseConnectionFromInterfaces(ctx context.Context, generic []interface{}) (metadata Metadata, err error) { + metadata = newMetadata() + closers := make([]connectionProviders, 0) for _, elem := range generic { temp := connectionProviders{name: getProviderName(elem)} diff --git a/bmc/firmware.go b/bmc/firmware.go index 15849fd09..523ade734 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,8 +36,8 @@ 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) { - var metadataLocal Metadata +func firmwareInstall(ctx context.Context, component, operationApplyTime string, forceInstall bool, reader io.Reader, generic []firmwareInstallerProvider) (taskID string, metadata Metadata, err error) { + metadata = newMetadata() for _, elem := range generic { if elem.FirmwareInstaller == nil { @@ -46,24 +49,26 @@ 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) + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + 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) + metadata.FailedProviderDetail[elem.name] = err.Error() continue } - metadataLocal.SuccessfulProvider = elem.name - return taskID, metadataLocal, nil + metadata.SuccessfulProvider = elem.name + return taskID, metadata, nil } } - return taskID, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareInstall")) + return taskID, metadata, multierror.Append(err, errors.New("failure in FirmwareInstall")) } // 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) { + metadata = newMetadata() + implementations := make([]firmwareInstallerProvider, 0) for _, elem := range generic { temp := firmwareInstallerProvider{name: getProviderName(elem)} @@ -86,9 +91,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. @@ -111,7 +118,7 @@ type firmwareInstallVerifierProvider struct { // firmwareInstallStatus returns the status of the firmware install process func firmwareInstallStatus(ctx context.Context, installVersion, component, taskID string, generic []firmwareInstallVerifierProvider) (status string, metadata Metadata, err error) { - var metadataLocal Metadata + metadata = newMetadata() for _, elem := range generic { if elem.FirmwareInstallVerifier == nil { @@ -123,24 +130,26 @@ func firmwareInstallStatus(ctx context.Context, installVersion, component, taskI return status, metadata, err default: - metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) status, vErr := elem.FirmwareInstallStatus(ctx, installVersion, component, taskID) if vErr != nil { err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) - err = multierror.Append(err, vErr) + metadata.FailedProviderDetail[elem.name] = err.Error() continue } - metadataLocal.SuccessfulProvider = elem.name - return status, metadataLocal, nil + metadata.SuccessfulProvider = elem.name + return status, metadata, nil } } - return status, metadataLocal, multierror.Append(err, errors.New("failure in FirmwareInstallStatus")) + return status, metadata, multierror.Append(err, errors.New("failure in FirmwareInstallStatus")) } // FirmwareInstallStatusFromInterfaces identifies implementations of the FirmwareInstallVerifier interface and passes the found implementations to the firmwareInstallStatus() wrapper. func FirmwareInstallStatusFromInterfaces(ctx context.Context, installVersion, component, taskID string, generic []interface{}) (status string, metadata Metadata, err error) { + metadata = newMetadata() + implementations := make([]firmwareInstallVerifierProvider, 0) for _, elem := range generic { temp := firmwareInstallVerifierProvider{name: getProviderName(elem)} @@ -165,3 +174,378 @@ func FirmwareInstallStatusFromInterfaces(ctx context.Context, installVersion, co return firmwareInstallStatus(ctx, installVersion, component, taskID, implementations) } + +// FirmwareInstallProvider defines an interface to upload and initiate a firmware install in the same implementation method +// +// Its intended to deprecate the FirmwareInstall interface +type FirmwareInstallProvider interface { + // FirmwareInstallUploadAndInitiate uploads _and_ initiates the firmware install process. + // + // return values: + // taskID - A taskID is returned if the update process on the BMC returns an identifier for the update process. + FirmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File) (taskID string, err error) +} + +// firmwareInstallProvider is an internal struct to correlate an implementation/provider and its name +type firmwareInstallProvider struct { + name string + FirmwareInstallProvider +} + +// firmwareInstall uploads and initiates firmware update for the component +func firmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File, generic []firmwareInstallProvider) (taskID string, metadata Metadata, err error) { + metadata = newMetadata() + + for _, elem := range generic { + if elem.FirmwareInstallProvider == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return taskID, metadata, err + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + taskID, vErr := elem.FirmwareInstallUploadAndInitiate(ctx, component, file) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + metadata.FailedProviderDetail[elem.name] = err.Error() + continue + } + metadata.SuccessfulProvider = elem.name + return taskID, metadata, nil + } + } + + return taskID, metadata, multierror.Append(err, errors.New("failure in FirmwareInstallUploadAndInitiate")) +} + +// FirmwareInstallUploadAndInitiateFromInterfaces identifies implementations of the FirmwareInstallProvider interface and passes the found implementations to the firmwareInstallUploadAndInitiate() wrapper +func FirmwareInstallUploadAndInitiateFromInterfaces(ctx context.Context, component string, file *os.File, generic []interface{}) (taskID string, metadata Metadata, err error) { + metadata = newMetadata() + + implementations := make([]firmwareInstallProvider, 0) + for _, elem := range generic { + temp := firmwareInstallProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FirmwareInstallProvider: + temp.FirmwareInstallProvider = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a FirmwareInstallProvider 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 FirmwareInstallProvider implementations found"), + ), + ) + } + + return firmwareInstallUploadAndInitiate(ctx, component, file, implementations) +} + +// FirmwareInstallerUploaded 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) { + metadata = newMetadata() + + for _, elem := range generic { + if elem.FirmwareInstallerUploaded == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return installTaskID, metadata, err + default: + metadata.ProvidersAttempted = append(metadata.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)) + metadata.FailedProviderDetail[elem.name] = err.Error() + continue + + } + metadata.SuccessfulProvider = elem.name + return installTaskID, metadata, nil + } + } + + return installTaskID, metadata, 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) { + metadata = newMetadata() + + 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) { + metadata = newMetadata() + + 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) { + metadata = newMetadata() + + for _, elem := range generic { + if elem.FirmwareInstallStepsGetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return steps, metadata, err + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + steps, vErr := elem.FirmwareInstallSteps(ctx, component) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + metadata.FailedProviderDetail[elem.name] = err.Error() + continue + + } + metadata.SuccessfulProvider = elem.name + return steps, metadata, nil + } + } + + return steps, metadata, 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) { + metadata = newMetadata() + + 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) { + metadata = newMetadata() + + for _, elem := range generic { + if elem.FirmwareUploader == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return taskID, metadata, err + default: + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, elem.name) + taskID, vErr := elem.FirmwareUpload(ctx, component, file) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + metadata.FailedProviderDetail[elem.name] = err.Error() + continue + + } + metadata.SuccessfulProvider = elem.name + return taskID, metadata, nil + } + } + + return taskID, metadata, 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 constants.TaskState, 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 constants.TaskState, status string, metadata Metadata, err error) { + metadata = newMetadata() + + 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: + metadata.ProvidersAttempted = append(metadata.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)) + metadata.FailedProviderDetail[elem.name] = err.Error() + continue + } + + metadata.SuccessfulProvider = elem.name + return state, status, metadata, nil + } + } + + return state, status, metadata, 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 constants.TaskState, status string, metadata Metadata, err error) { + metadata = newMetadata() + + 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 26504a191..dd6f29b02 100644 --- a/bmc/firmware_test.go +++ b/bmc/firmware_test.go @@ -3,13 +3,14 @@ package bmc import ( "context" "io" + "os" "testing" "time" "github.com/bmc-toolbox/bmclib/v2/constants" - "github.com/bmc-toolbox/bmclib/v2/errors" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/bmc-toolbox/common" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) @@ -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", bmclibErrs.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 { @@ -135,7 +136,7 @@ func TestFirmwareInstallStatus(t *testing.T) { providersAttempted int }{ {"success with metadata", common.SlugBIOS, "1.1", "1234", constants.FirmwareInstallComplete, nil, 5 * time.Second, "foo", 1}, - {"failure with metadata", common.SlugBIOS, "1.1", "1234", constants.FirmwareInstallFailed, errors.ErrNon200Response, 5 * time.Second, "foo", 1}, + {"failure with metadata", common.SlugBIOS, "1.1", "1234", constants.FirmwareInstallFailed, bmclibErrs.ErrNon200Response, 5 * time.Second, "foo", 1}, {"failure with context timeout", common.SlugBIOS, "1.1", "1234", "", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, } @@ -162,6 +163,7 @@ func TestFirmwareInstallStatus(t *testing.T) { }) } } + func TestFirmwareInstallStatusFromInterfaces(t *testing.T) { testCases := []struct { testName string @@ -202,3 +204,455 @@ func TestFirmwareInstallStatusFromInterfaces(t *testing.T) { }) } } + +type firmwareInstallUploadAndInitiateTester struct { + returnTaskID string + returnError error +} + +func (f *firmwareInstallUploadAndInitiateTester) FirmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File) (taskID string, err error) { + return f.returnTaskID, f.returnError +} + +func (r *firmwareInstallUploadAndInitiateTester) Name() string { + return "foo" +} + +func TestFirmwareInstallUploadAndInitiate(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", "componentA", &os.File{}, "1234", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", "componentB", &os.File{}, "1234", errors.New("failed to upload and initiate"), 5 * time.Second, "foo", 1}, + {"failure with context timeout", "componentC", &os.File{}, "", context.DeadlineExceeded, 1 * time.Nanosecond, "foo", 1}, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + testImplementation := &firmwareInstallUploadAndInitiateTester{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 := firmwareInstallUploadAndInitiate(ctx, tc.component, tc.file, []firmwareInstallProvider{{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)) + }) + } +} + +func TestFirmwareInstallUploadAndInitiateFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + component string + file *os.File + returnTaskID string + returnError error + providerName string + badImplementation bool + }{ + {"success with metadata", "componentA", &os.File{}, "1234", nil, "foo", false}, + {"failure with bad implementation", "componentB", &os.File{}, "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 { + testImplementation := &firmwareInstallUploadAndInitiateTester{returnTaskID: tc.returnTaskID, returnError: tc.returnError} + generic = []interface{}{testImplementation} + } + taskID, metadata, err := FirmwareInstallUploadAndInitiateFromInterfaces(context.Background(), tc.component, tc.file, generic) + 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) + }) + } +} + +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", "", bmclibErrs.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", bmclibErrs.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, bmclibErrs.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 constants.TaskState + returnStatus string + returnError error +} + +func (f *firmwareTaskStatusTester) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state constants.TaskState, 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 constants.TaskState + 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", bmclibErrs.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 constants.TaskState + returnStatus string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + }{ + {"success with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.Complete, "uploading", nil, 5 * time.Second, "foo", 1}, + {"failure with metadata", constants.FirmwareInstallStepUpload, common.SlugBIOS, "1234", "1.0", constants.Failed, "failed", bmclibErrs.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/bmc/floppy.go b/bmc/floppy.go new file mode 100644 index 000000000..891a11315 --- /dev/null +++ b/bmc/floppy.go @@ -0,0 +1,150 @@ +package bmc + +import ( + "context" + "fmt" + "io" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +// FloppyImageMounter defines methods to upload a floppy image +type FloppyImageMounter interface { + MountFloppyImage(ctx context.Context, image io.Reader) (err error) +} + +// floppyImageUploaderProvider is an internal struct to correlate an implementation/provider and its name +type floppyImageUploaderProvider struct { + name string + impl FloppyImageMounter +} + +// mountFloppyImage is a wrapper method to invoke methods for the FloppyImageMounter interface +func mountFloppyImage(ctx context.Context, image io.Reader, p []floppyImageUploaderProvider) (metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range p { + if elem.impl == nil { + continue + } + + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + uploadErr := elem.impl.MountFloppyImage(ctx, image) + if uploadErr != nil { + err = multierror.Append(err, errors.WithMessagef(uploadErr, "provider: %v", elem.name)) + continue + } + + metadataLocal.SuccessfulProvider = elem.name + return metadataLocal, nil + } + } + + return metadataLocal, multierror.Append(err, errors.New("failed to mount floppy image")) +} + +// MountFloppyImageFromInterfaces identifies implementations of the FloppyImageMounter interface and passes the found implementations to the mountFloppyImage() wrapper +func MountFloppyImageFromInterfaces(ctx context.Context, image io.Reader, p []interface{}) (metadata Metadata, err error) { + providers := make([]floppyImageUploaderProvider, 0) + for _, elem := range p { + temp := floppyImageUploaderProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FloppyImageMounter: + temp.impl = p + providers = append(providers, temp) + default: + e := fmt.Sprintf("not a FloppyImageMounter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + + if len(providers) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + "no FloppyImageMounter implementations found", + ), + ) + + } + + return mountFloppyImage(ctx, image, providers) +} + +// FloppyImageMounter defines methods to unmount a floppy image +type FloppyImageUnmounter interface { + UnmountFloppyImage(ctx context.Context) (err error) +} + +// floppyImageUnmounterProvider is an internal struct to correlate an implementation/provider and its name +type floppyImageUnmounterProvider struct { + name string + impl FloppyImageUnmounter +} + +// unmountFloppyImage is a wrapper method to invoke methods for the FloppyImageUnmounter interface +func unmountFloppyImage(ctx context.Context, p []floppyImageUnmounterProvider) (metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range p { + if elem.impl == nil { + continue + } + + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + uploadErr := elem.impl.UnmountFloppyImage(ctx) + if uploadErr != nil { + err = multierror.Append(err, errors.WithMessagef(uploadErr, "provider: %v", elem.name)) + continue + } + + metadataLocal.SuccessfulProvider = elem.name + return metadataLocal, nil + } + } + + return metadataLocal, multierror.Append(err, errors.New("failed to unmount floppy image")) +} + +// MountFloppyImageFromInterfaces identifies implementations of the FloppyImageUnmounter interface and passes the found implementations to the unmountFloppyImage() wrapper +func UnmountFloppyImageFromInterfaces(ctx context.Context, p []interface{}) (metadata Metadata, err error) { + providers := make([]floppyImageUnmounterProvider, 0) + for _, elem := range p { + temp := floppyImageUnmounterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case FloppyImageUnmounter: + temp.impl = p + providers = append(providers, temp) + default: + e := fmt.Sprintf("not a FloppyImageUnmounter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + + if len(providers) == 0 { + return metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + "no FloppyImageUnmounter implementations found", + ), + ) + } + + return unmountFloppyImage(ctx, providers) +} diff --git a/bmc/floppy_test.go b/bmc/floppy_test.go new file mode 100644 index 000000000..3107b8c9d --- /dev/null +++ b/bmc/floppy_test.go @@ -0,0 +1,114 @@ +package bmc + +import ( + "context" + "io" + "testing" + "time" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/stretchr/testify/assert" +) + +type mountFloppyImageTester struct { + returnError error +} + +func (p *mountFloppyImageTester) MountFloppyImage(ctx context.Context, reader io.Reader) (err error) { + return p.returnError +} + +func (p *mountFloppyImageTester) Name() string { + return "foo" +} + +func TestMountFloppyFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + image io.Reader + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + badImplementation bool + }{ + {"success with metadata", nil, nil, 5 * time.Second, "foo", 1, false}, + {"failure with bad implementation", nil, bmclibErrs.ErrProviderImplementation, 1 * time.Nanosecond, "foo", 1, 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 { + testImplementation := &mountFloppyImageTester{returnError: tc.returnError} + generic = []interface{}{testImplementation} + } + metadata, err := MountFloppyImageFromInterfaces(context.Background(), tc.image, generic) + if tc.returnError != nil { + assert.ErrorContains(t, err, tc.returnError.Error()) + return + } + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.returnError, err) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + }) + } +} + +type unmountFloppyImageTester struct { + returnError error +} + +func (p *unmountFloppyImageTester) UnmountFloppyImage(ctx context.Context) (err error) { + return p.returnError +} + +func (p *unmountFloppyImageTester) Name() string { + return "foo" +} + +func TestUnmountFloppyFromInterfaces(t *testing.T) { + testCases := []struct { + testName string + returnError error + ctxTimeout time.Duration + providerName string + providersAttempted int + badImplementation bool + }{ + {"success with metadata", nil, 5 * time.Second, "foo", 1, false}, + {"failure with bad implementation", bmclibErrs.ErrProviderImplementation, 1 * time.Nanosecond, "foo", 1, 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 { + testImplementation := &unmountFloppyImageTester{returnError: tc.returnError} + generic = []interface{}{testImplementation} + } + metadata, err := UnmountFloppyImageFromInterfaces(context.Background(), generic) + if tc.returnError != nil { + assert.ErrorContains(t, err, tc.returnError.Error()) + return + } + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.returnError, err) + assert.Equal(t, tc.providerName, metadata.SuccessfulProvider) + }) + } +} diff --git a/bmc/inventory.go b/bmc/inventory.go index 44a901178..7ce067ea5 100644 --- a/bmc/inventory.go +++ b/bmc/inventory.go @@ -53,6 +53,8 @@ func inventory(ctx context.Context, generic []inventoryGetterProvider) (device * // GetInventoryFromInterfaces identifies implementations of the InventoryGetter interface and passes the found implementations to the inventory() wrapper method func GetInventoryFromInterfaces(ctx context.Context, generic []interface{}) (device *common.Device, metadata Metadata, err error) { + metadata = newMetadata() + implementations := make([]inventoryGetterProvider, 0) for _, elem := range generic { temp := inventoryGetterProvider{name: getProviderName(elem)} diff --git a/bmc/nmi.go b/bmc/nmi.go new file mode 100644 index 000000000..9512a94ec --- /dev/null +++ b/bmc/nmi.go @@ -0,0 +1,66 @@ +package bmc + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/hashicorp/go-multierror" +) + +type NMISender interface { + SendNMI(ctx context.Context) error +} + +func sendNMI(ctx context.Context, timeout time.Duration, sender NMISender, metadata *Metadata) error { + senderName := getProviderName(sender) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + metadata.ProvidersAttempted = append(metadata.ProvidersAttempted, senderName) + + err := sender.SendNMI(ctx) + if err != nil { + metadata.FailedProviderDetail[senderName] = err.Error() + return err + } + + metadata.SuccessfulProvider = senderName + + return nil +} + +// SendNMIFromInterface will look for providers that implement NMISender +// and attempt to call SendNMI until a provider is successful, +// or all providers have been exhausted. +func SendNMIFromInterface( + ctx context.Context, + timeout time.Duration, + providers []interface{}, +) (metadata Metadata, err error) { + metadata = newMetadata() + + for _, provider := range providers { + sender, ok := provider.(NMISender) + if !ok { + err = multierror.Append(err, fmt.Errorf("not an NMISender implementation: %T", provider)) + continue + } + + sendNMIErr := sendNMI(ctx, timeout, sender, &metadata) + if sendNMIErr != nil { + err = multierror.Append(err, sendNMIErr) + continue + } + return metadata, nil + } + + if len(metadata.ProvidersAttempted) == 0 { + err = multierror.Append(err, errors.New("no NMISender implementations found")) + } else { + err = multierror.Append(err, errors.New("failed to send NMI")) + } + + return metadata, err +} diff --git a/bmc/nmi_test.go b/bmc/nmi_test.go new file mode 100644 index 000000000..c9a8e4182 --- /dev/null +++ b/bmc/nmi_test.go @@ -0,0 +1,124 @@ +package bmc + +import ( + "context" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +type mockNMISender struct { + err error +} + +func (m *mockNMISender) SendNMI(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return m.err + } +} + +func (m *mockNMISender) Name() string { + return "mock" +} + +func TestSendNMIFromInterface(t *testing.T) { + testCases := []struct { + name string + mockSenders []interface{} + errMsg string + isTimedout bool + expectedMetadata Metadata + }{ + { + name: "success", + mockSenders: []interface{}{&mockNMISender{}}, + expectedMetadata: Metadata{ + SuccessfulProvider: "mock", + ProvidersAttempted: []string{"mock"}, + FailedProviderDetail: make(map[string]string), + }, + }, + { + name: "success with multiple senders", + mockSenders: []interface{}{ + nil, + "foo", + &mockNMISender{err: errors.New("err from sender")}, + &mockNMISender{}, + }, + expectedMetadata: Metadata{ + SuccessfulProvider: "mock", + ProvidersAttempted: []string{"mock", "mock"}, + FailedProviderDetail: map[string]string{"mock": "err from sender"}, + }, + }, + { + name: "not an nmisender", + mockSenders: []interface{}{nil}, + errMsg: "not an NMISender", + expectedMetadata: Metadata{ + FailedProviderDetail: make(map[string]string), + }, + }, + { + name: "no nmisenders", + mockSenders: []interface{}{}, + errMsg: "no NMISender implementations found", + expectedMetadata: Metadata{ + FailedProviderDetail: make(map[string]string), + }, + }, + { + name: "timed out", + mockSenders: []interface{}{&mockNMISender{}}, + isTimedout: true, + errMsg: "context deadline exceeded", + expectedMetadata: Metadata{ + ProvidersAttempted: []string{"mock"}, + FailedProviderDetail: map[string]string{"mock": "context deadline exceeded"}, + }, + }, + { + name: "error from nmisender", + mockSenders: []interface{}{&mockNMISender{err: errors.New("foobar")}}, + errMsg: "foobar", + expectedMetadata: Metadata{ + ProvidersAttempted: []string{"mock"}, + FailedProviderDetail: map[string]string{"mock": "foobar"}, + }, + }, + { + name: "error when fail to send", + mockSenders: []interface{}{&mockNMISender{err: errors.New("err from sender")}}, + errMsg: "failed to send NMI", + expectedMetadata: Metadata{ + ProvidersAttempted: []string{"mock"}, + FailedProviderDetail: map[string]string{"mock": "err from sender"}, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + timeout := time.Second * 60 + if tt.isTimedout { + timeout = 0 + } + + metadata, err := SendNMIFromInterface(context.Background(), timeout, tt.mockSenders) + + if tt.errMsg == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.errMsg) + } + + assert.Equal(t, tt.expectedMetadata, metadata) + }) + } +} diff --git a/bmc/power.go b/bmc/power.go index 1f5ca512c..1b24edfa3 100644 --- a/bmc/power.go +++ b/bmc/power.go @@ -75,6 +75,8 @@ func setPowerState(ctx context.Context, timeout time.Duration, state string, p [ // SetPowerStateFromInterfaces identifies implementations of the PostStateSetter interface and passes the found implementations to the setPowerState() wrapper. func SetPowerStateFromInterfaces(ctx context.Context, timeout time.Duration, state string, generic []interface{}) (ok bool, metadata Metadata, err error) { + metadata = newMetadata() + powerSetter := make([]powerProviders, 0) for _, elem := range generic { temp := powerProviders{name: getProviderName(elem)} @@ -126,6 +128,8 @@ func getPowerState(ctx context.Context, timeout time.Duration, p []powerProvider // GetPowerStateFromInterfaces identifies implementations of the PostStateGetter interface and passes the found implementations to the getPowerState() wrapper. func GetPowerStateFromInterfaces(ctx context.Context, timeout time.Duration, generic []interface{}) (state string, metadata Metadata, err error) { + metadata = newMetadata() + powerStateGetter := make([]powerProviders, 0) for _, elem := range generic { temp := powerProviders{name: getProviderName(elem)} diff --git a/bmc/reset.go b/bmc/reset.go index b08334917..b7c2d7066 100644 --- a/bmc/reset.go +++ b/bmc/reset.go @@ -57,6 +57,8 @@ func resetBMC(ctx context.Context, timeout time.Duration, resetType string, b [] // ResetBMCFromInterfaces identifies implementations of the BMCResetter interface and passes them to the resetBMC() wrapper method. func ResetBMCFromInterfaces(ctx context.Context, timeout time.Duration, resetType string, generic []interface{}) (ok bool, metadata Metadata, err error) { + metadata = newMetadata() + bmcSetters := make([]bmcProviders, 0) for _, elem := range generic { temp := bmcProviders{name: getProviderName(elem)} diff --git a/bmc/sel.go b/bmc/sel.go new file mode 100644 index 000000000..002a25d1d --- /dev/null +++ b/bmc/sel.go @@ -0,0 +1,175 @@ +package bmc + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +// System Event Log Services for related services +type SystemEventLog interface { + ClearSystemEventLog(ctx context.Context) (err error) + GetSystemEventLog(ctx context.Context) (entries [][]string, err error) + GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) +} + +type systemEventLogProviders struct { + name string + systemEventLogProvider SystemEventLog +} + +type SystemEventLogEntries [][]string + +func clearSystemEventLog(ctx context.Context, timeout time.Duration, s []systemEventLogProviders) (metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range s { + if elem.systemEventLogProvider == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + selErr := elem.systemEventLogProvider.ClearSystemEventLog(ctx) + if selErr != nil { + err = multierror.Append(err, errors.WithMessagef(selErr, "provider: %v", elem.name)) + continue + } + metadataLocal.SuccessfulProvider = elem.name + return metadataLocal, nil + } + + } + + return metadataLocal, multierror.Append(err, errors.New("failed to reset System Event Log")) +} + +func ClearSystemEventLogFromInterfaces(ctx context.Context, timeout time.Duration, generic []interface{}) (metadata Metadata, err error) { + selServices := make([]systemEventLogProviders, 0) + for _, elem := range generic { + temp := systemEventLogProviders{name: getProviderName(elem)} + switch p := elem.(type) { + case SystemEventLog: + temp.systemEventLogProvider = p + selServices = append(selServices, temp) + default: + e := fmt.Sprintf("not a SystemEventLog service implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(selServices) == 0 { + return metadata, multierror.Append(err, errors.New("no SystemEventLog implementations found")) + } + return clearSystemEventLog(ctx, timeout, selServices) +} + +func getSystemEventLog(ctx context.Context, timeout time.Duration, s []systemEventLogProviders) (sel SystemEventLogEntries, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range s { + if elem.systemEventLogProvider == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return sel, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + sel, selErr := elem.systemEventLogProvider.GetSystemEventLog(ctx) + if selErr != nil { + err = multierror.Append(err, errors.WithMessagef(selErr, "provider: %v", elem.name)) + continue + } + + metadataLocal.SuccessfulProvider = elem.name + return sel, metadataLocal, nil + } + + } + + return nil, metadataLocal, multierror.Append(err, errors.New("failed to get System Event Log")) +} + +func GetSystemEventLogFromInterfaces(ctx context.Context, timeout time.Duration, generic []interface{}) (sel SystemEventLogEntries, metadata Metadata, err error) { + selServices := make([]systemEventLogProviders, 0) + for _, elem := range generic { + temp := systemEventLogProviders{name: getProviderName(elem)} + switch p := elem.(type) { + case SystemEventLog: + temp.systemEventLogProvider = p + selServices = append(selServices, temp) + default: + e := fmt.Sprintf("not a SystemEventLog service implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(selServices) == 0 { + return sel, metadata, multierror.Append(err, errors.New("no SystemEventLog implementations found")) + } + return getSystemEventLog(ctx, timeout, selServices) +} + +func getSystemEventLogRaw(ctx context.Context, timeout time.Duration, s []systemEventLogProviders) (eventlog string, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range s { + if elem.systemEventLogProvider == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return eventlog, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + eventlog, selErr := elem.systemEventLogProvider.GetSystemEventLogRaw(ctx) + if selErr != nil { + err = multierror.Append(err, errors.WithMessagef(selErr, "provider: %v", elem.name)) + continue + } + + metadataLocal.SuccessfulProvider = elem.name + return eventlog, metadataLocal, nil + } + + } + + return eventlog, metadataLocal, multierror.Append(err, errors.New("failed to get System Event Log")) +} + +func GetSystemEventLogRawFromInterfaces(ctx context.Context, timeout time.Duration, generic []interface{}) (eventlog string, metadata Metadata, err error) { + selServices := make([]systemEventLogProviders, 0) + for _, elem := range generic { + temp := systemEventLogProviders{name: getProviderName(elem)} + switch p := elem.(type) { + case SystemEventLog: + temp.systemEventLogProvider = p + selServices = append(selServices, temp) + default: + e := fmt.Sprintf("not a SystemEventLog service implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(selServices) == 0 { + return eventlog, metadata, multierror.Append(err, errors.New("no SystemEventLog implementations found")) + } + return getSystemEventLogRaw(ctx, timeout, selServices) +} diff --git a/bmc/sel_test.go b/bmc/sel_test.go new file mode 100644 index 000000000..1273e59af --- /dev/null +++ b/bmc/sel_test.go @@ -0,0 +1,135 @@ +package bmc + +import ( + "context" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +type mockSystemEventLogService struct { + name string + err error +} + +func (m *mockSystemEventLogService) ClearSystemEventLog(ctx context.Context) error { + return m.err +} + +func (m *mockSystemEventLogService) GetSystemEventLog(ctx context.Context) (entries [][]string, err error) { + return nil, m.err +} + +func (m *mockSystemEventLogService) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + return "", m.err +} + +func (m *mockSystemEventLogService) Name() string { + return m.name +} + +func TestClearSystemEventLog(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1", err: nil} + metadata, err := clearSystemEventLog(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.Nil(t, err) + assert.Equal(t, mockService.name, metadata.SuccessfulProvider) + + // Test with a mock SystemEventLogService that returns an error + mockService = &mockSystemEventLogService{name: "mock2", err: errors.New("mock error")} + metadata, err = clearSystemEventLog(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.NotNil(t, err) + assert.NotEqual(t, mockService.name, metadata.SuccessfulProvider) +} + +func TestClearSystemEventLogFromInterfaces(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with an empty slice + metadata, err := ClearSystemEventLogFromInterfaces(ctx, timeout, []interface{}{}) + assert.NotNil(t, err) + assert.Empty(t, metadata.SuccessfulProvider) + + // Test with a slice containing a non-SystemEventLog object + metadata, err = ClearSystemEventLogFromInterfaces(ctx, timeout, []interface{}{"not a SystemEventLog Service"}) + assert.NotNil(t, err) + assert.Empty(t, metadata.SuccessfulProvider) + + // Test with a slice containing a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1"} + metadata, err = ClearSystemEventLogFromInterfaces(ctx, timeout, []interface{}{mockService}) + assert.Nil(t, err) + assert.Equal(t, mockService.name, metadata.SuccessfulProvider) +} + +func TestGetSystemEventLog(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1", err: nil} + _, _, err := getSystemEventLog(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.Nil(t, err) + + // Test with a mock SystemEventLogService that returns an error + mockService = &mockSystemEventLogService{name: "mock2", err: errors.New("mock error")} + _, _, err = getSystemEventLog(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.NotNil(t, err) +} + +func TestGetSystemEventLogFromInterfaces(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with an empty slice + _, _, err := GetSystemEventLogFromInterfaces(ctx, timeout, []interface{}{}) + assert.NotNil(t, err) + + // Test with a slice containing a non-SystemEventLog object + _, _, err = GetSystemEventLogFromInterfaces(ctx, timeout, []interface{}{"not a SystemEventLog Service"}) + assert.NotNil(t, err) + + // Test with a slice containing a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1"} + _, _, err = GetSystemEventLogFromInterfaces(ctx, timeout, []interface{}{mockService}) + assert.Nil(t, err) +} + +func TestGetSystemEventLogRaw(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1", err: nil} + _, _, err := getSystemEventLogRaw(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.Nil(t, err) + + // Test with a mock SystemEventLogService that returns an error + mockService = &mockSystemEventLogService{name: "mock2", err: errors.New("mock error")} + _, _, err = getSystemEventLogRaw(ctx, timeout, []systemEventLogProviders{{name: mockService.name, systemEventLogProvider: mockService}}) + assert.NotNil(t, err) +} + +func TestGetSystemEventLogRawFromInterfaces(t *testing.T) { + ctx := context.Background() + timeout := 1 * time.Second + + // Test with an empty slice + _, _, err := GetSystemEventLogRawFromInterfaces(ctx, timeout, []interface{}{}) + assert.NotNil(t, err) + + // Test with a slice containing a non-SystemEventLog object + _, _, err = GetSystemEventLogRawFromInterfaces(ctx, timeout, []interface{}{"not a SystemEventLog Service"}) + assert.NotNil(t, err) + + // Test with a slice containing a mock SystemEventLogService that returns nil + mockService := &mockSystemEventLogService{name: "mock1"} + _, _, err = GetSystemEventLogRawFromInterfaces(ctx, timeout, []interface{}{mockService}) + assert.Nil(t, err) +} diff --git a/bmc/sol.go b/bmc/sol.go new file mode 100644 index 000000000..89c172b0e --- /dev/null +++ b/bmc/sol.go @@ -0,0 +1,70 @@ +package bmc + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +// SOLDeactivator for deactivating SOL sessions on a BMC. +type SOLDeactivator interface { + DeactivateSOL(ctx context.Context) (err error) +} + +// deactivatorProvider is an internal struct to correlate an implementation/provider and its name +type deactivatorProvider struct { + name string + solDeactivator SOLDeactivator +} + +// deactivateSOL tries all implementations for a successful SOL deactivation +func deactivateSOL(ctx context.Context, timeout time.Duration, b []deactivatorProvider) (metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range b { + if elem.solDeactivator == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + newErr := elem.solDeactivator.DeactivateSOL(ctx) + if newErr != nil { + err = multierror.Append(err, errors.WithMessagef(newErr, "provider: %v", elem.name)) + continue + } + metadataLocal.SuccessfulProvider = elem.name + return metadataLocal, nil + } + } + return metadataLocal, multierror.Append(err, errors.New("failed to deactivate SOL session")) +} + +// DeactivateSOLFromInterfaces identifies implementations of the SOLDeactivator interface and passes them to the deactivateSOL() wrapper method. +func DeactivateSOLFromInterfaces(ctx context.Context, timeout time.Duration, generic []interface{}) (metadata Metadata, err error) { + deactivators := make([]deactivatorProvider, 0) + for _, elem := range generic { + temp := deactivatorProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case SOLDeactivator: + temp.solDeactivator = p + deactivators = append(deactivators, temp) + default: + e := fmt.Sprintf("not an SOLDeactivator implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(deactivators) == 0 { + return metadata, multierror.Append(err, errors.New("no SOLDeactivator implementations found")) + } + return deactivateSOL(ctx, timeout, deactivators) +} diff --git a/bmc/sol_test.go b/bmc/sol_test.go new file mode 100644 index 000000000..1623a027b --- /dev/null +++ b/bmc/sol_test.go @@ -0,0 +1,99 @@ +package bmc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-multierror" +) + +type solTermTester struct { + MakeErrorOut bool +} + +func (r *solTermTester) DeactivateSOL(ctx context.Context) (err error) { + if r.MakeErrorOut { + return errors.New("SOL deactivation failed") + } + return nil +} + +func (r *solTermTester) Name() string { + return "test provider" +} + +func TestDeactivateSOL(t *testing.T) { + testCases := map[string]struct { + makeErrorOut bool + err error + ctxTimeout time.Duration + }{ + "success": {makeErrorOut: false}, + "error": {makeErrorOut: true, err: &multierror.Error{Errors: []error{errors.New("provider: test provider: SOL deactivation failed"), errors.New("failed to deactivate SOL session")}}}, + "error context timeout": {makeErrorOut: false, err: &multierror.Error{Errors: []error{errors.New("context deadline exceeded")}}, ctxTimeout: time.Nanosecond * 1}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + testImplementation := solTermTester{MakeErrorOut: tc.makeErrorOut} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + _, err := deactivateSOL(ctx, 0, []deactivatorProvider{{"test provider", &testImplementation}}) + var diff string + if err != nil && tc.err != nil { + diff = cmp.Diff(err.Error(), tc.err.Error()) + } else { + diff = cmp.Diff(err, tc.err) + } + if diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestDeactivateSOLFromInterfaces(t *testing.T) { + testCases := map[string]struct { + err error + badImplementation bool + withName bool + }{ + "success": {}, + "success with metadata": {withName: true}, + "no implementations found": {badImplementation: true, err: &multierror.Error{Errors: []error{errors.New("not an SOLDeactivator implementation: *struct {}"), errors.New("no SOLDeactivator implementations found")}}}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var generic []interface{} + if tc.badImplementation { + badImplementation := struct{}{} + generic = []interface{}{&badImplementation} + } else { + testImplementation := solTermTester{} + generic = []interface{}{&testImplementation} + } + metadata, err := DeactivateSOLFromInterfaces(context.Background(), 0, generic) + var diff string + if err != nil && tc.err != nil { + diff = cmp.Diff(err.Error(), tc.err.Error()) + } else { + diff = cmp.Diff(err, tc.err) + } + if diff != "" { + t.Fatal(diff) + } + if tc.withName { + if diff := cmp.Diff(metadata.SuccessfulProvider, "test provider"); diff != "" { + t.Fatal(diff) + } + } + }) + } +} diff --git a/client.go b/client.go index b552cce56..4bd095173 100644 --- a/client.go +++ b/client.go @@ -7,27 +7,35 @@ import ( "fmt" "io" "net/http" + "os" + "strings" "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" "github.com/bmc-toolbox/bmclib/v2/providers/intelamt" "github.com/bmc-toolbox/bmclib/v2/providers/ipmitool" + "github.com/bmc-toolbox/bmclib/v2/providers/openbmc" "github.com/bmc-toolbox/bmclib/v2/providers/redfish" "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/bmc-toolbox/bmclib/v2/providers/supermicro" "github.com/bmc-toolbox/common" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" + "go.opentelemetry.io/otel/attribute" + oteltrace "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" ) const ( // default connection timeout defaultConnectTimeout = 30 * time.Second + pkgName = "github.com/bmc-toolbox/bmclib" ) // Client for BMC interactions @@ -44,6 +52,7 @@ type Client struct { oneTimeRegistry *registrar.Registry oneTimeRegistryEnabled bool providerConfig providerConfig + traceprovider oteltrace.TracerProvider } // Auth details for connecting to a BMC @@ -62,6 +71,7 @@ type providerConfig struct { dell dell.Config supermicro supermicro.Config rpc rpc.Provider + openbmc openbmc.Config } // NewClient returns a new Client struct @@ -72,6 +82,7 @@ func NewClient(host, user, pass string, opts ...Option) *Client { oneTimeRegistryEnabled: false, oneTimeRegistry: registrar.NewRegistry(), httpClient: httpclient.Build(), + traceprovider: tracenoop.NewTracerProvider(), providerConfig: providerConfig{ ipmitool: ipmitool.Config{ Port: "623", @@ -95,6 +106,9 @@ func NewClient(host, user, pass string, opts ...Option) *Client { Port: "443", }, rpc: rpc.Provider{}, + openbmc: openbmc.Config{ + Port: "443", + }, }, } @@ -137,6 +151,9 @@ func (c *Client) defaultTimeout(ctx context.Context) time.Duration { func (c *Client) registerRPCProvider() error { driverRPC := rpc.New(c.providerConfig.rpc.ConsumerURL, c.Auth.Host, c.providerConfig.rpc.Opts.HMAC.Secrets) c.providerConfig.rpc.Logger = c.Logger + httpClient := *c.httpClient + httpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() + c.providerConfig.rpc.HTTPClient = &httpClient if err := mergo.Merge(driverRPC, c.providerConfig.rpc, mergo.WithOverride, mergo.WithTransformers(&rpc.Provider{})); err != nil { return fmt.Errorf("failed to merge user specified rpc config with the config defaults, rpc provider not available: %w", err) } @@ -145,38 +162,35 @@ func (c *Client) registerRPCProvider() error { return nil } -func (c *Client) registerProviders() { - // register the rpc provider - // without the consumer URL there is no way to send RPC requests. - if c.providerConfig.rpc.ConsumerURL != "" { - // when the rpc provider is to be used, we won't register any other providers. - err := c.registerRPCProvider() - if err == nil { - c.Logger.Info("note: with the rpc provider registered, no other providers will be registered and available") - return - } - c.Logger.Info("failed to register rpc provider, falling back to registering all other providers", "error", err.Error()) - } - // register ipmitool provider +// register ipmitool provider +func (c *Client) registerIPMIProvider() error { ipmiOpts := []ipmitool.Option{ ipmitool.WithLogger(c.Logger), ipmitool.WithPort(c.providerConfig.ipmitool.Port), ipmitool.WithCipherSuite(c.providerConfig.ipmitool.CipherSuite), ipmitool.WithIpmitoolPath(c.providerConfig.ipmitool.IpmitoolPath), } - if driverIpmitool, err := ipmitool.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, ipmiOpts...); err == nil { - c.Registry.Register(ipmitool.ProviderName, ipmitool.ProviderProtocol, ipmitool.Features, nil, driverIpmitool) - } else { - c.Logger.Info("ipmitool provider not available", "error", err.Error()) + + driverIpmitool, err := ipmitool.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, ipmiOpts...) + if err != nil { + return err } - // register ASRR vendorapi provider + c.Registry.Register(ipmitool.ProviderName, ipmitool.ProviderProtocol, ipmitool.Features, nil, driverIpmitool) + + return nil +} + +// register ASRR vendorapi provider +func (c *Client) registerASRRProvider() { asrHttpClient := *c.httpClient asrHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() driverAsrockrack := asrockrack.NewWithOptions(c.Auth.Host+":"+c.providerConfig.asrock.Port, c.Auth.User, c.Auth.Pass, c.Logger, asrockrack.WithHTTPClient(&asrHttpClient)) c.Registry.Register(asrockrack.ProviderName, asrockrack.ProviderProtocol, asrockrack.Features, nil, driverAsrockrack) +} - // register gofish provider +// register gofish provider +func (c *Client) registerGofishProvider() { gfHttpClient := *c.httpClient gfHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() gofishOpts := []redfish.Option{ @@ -184,11 +198,17 @@ func (c *Client) registerProviders() { redfish.WithVersionsNotCompatible(c.providerConfig.gofish.VersionsNotCompatible), redfish.WithUseBasicAuth(c.providerConfig.gofish.UseBasicAuth), redfish.WithPort(c.providerConfig.gofish.Port), + redfish.WithEtagMatchDisabled(c.providerConfig.gofish.DisableEtagMatch), + redfish.WithSystemName(c.providerConfig.gofish.SystemName), } + driverGoFish := redfish.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, gofishOpts...) c.Registry.Register(redfish.ProviderName, redfish.ProviderProtocol, redfish.Features, nil, driverGoFish) +} + +// register Intel AMT provider +func (c *Client) registerIntelAMTProvider() { - // register Intel AMT provider iamtOpts := []intelamt.Option{ intelamt.WithLogger(c.Logger), intelamt.WithHostScheme(c.providerConfig.intelamt.HostScheme), @@ -196,8 +216,10 @@ func (c *Client) registerProviders() { } driverAMT := intelamt.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, iamtOpts...) c.Registry.Register(intelamt.ProviderName, intelamt.ProviderProtocol, intelamt.Features, nil, driverAMT) +} - // register Dell gofish provider +// register Dell gofish provider +func (c *Client) registerDellProvider() { dellGofishHttpClient := *c.httpClient //dellGofishHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() dellGofishOpts := []dell.Option{ @@ -208,14 +230,64 @@ func (c *Client) registerProviders() { } driverGoFishDell := dell.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, dellGofishOpts...) c.Registry.Register(dell.ProviderName, redfish.ProviderProtocol, dell.Features, nil, driverGoFishDell) +} - // register supermicro vendorapi provider +// register supermicro vendorapi provider +func (c *Client) registerSupermicroProvider() { smcHttpClient := *c.httpClient smcHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() - driverSupermicro := supermicro.NewClient(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, supermicro.WithHttpClient(&smcHttpClient), supermicro.WithPort(c.providerConfig.supermicro.Port)) + driverSupermicro := supermicro.NewClient( + c.Auth.Host, + c.Auth.User, + c.Auth.Pass, + c.Logger, + supermicro.WithHttpClient(&smcHttpClient), + supermicro.WithPort(c.providerConfig.supermicro.Port), + ) + c.Registry.Register(supermicro.ProviderName, supermicro.ProviderProtocol, supermicro.Features, nil, driverSupermicro) } +func (c *Client) registerOpenBMCProvider() { + httpClient := *c.httpClient + httpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() + driver := openbmc.New( + c.Auth.Host, + c.Auth.User, + c.Auth.Pass, + c.Logger, + openbmc.WithHttpClient(&httpClient), + openbmc.WithPort(c.providerConfig.openbmc.Port), + ) + + c.Registry.Register(openbmc.ProviderName, openbmc.ProviderProtocol, openbmc.Features, nil, driver) +} + +func (c *Client) registerProviders() { + // register the rpc provider + // without the consumer URL there is no way to send RPC requests. + if c.providerConfig.rpc.ConsumerURL != "" { + // when the rpc provider is to be used, we won't register any other providers. + err := c.registerRPCProvider() + if err == nil { + c.Logger.Info("note: with the rpc provider registered, no other providers will be registered and available") + return + } + c.Logger.Info("failed to register rpc provider, falling back to registering all other providers", "error", err.Error()) + } + + if err := c.registerIPMIProvider(); err != nil { + c.Logger.Info("ipmitool provider not available", "error", err.Error()) + } + + c.registerASRRProvider() + c.registerGofishProvider() + c.registerIntelAMTProvider() + c.registerDellProvider() + c.registerSupermicroProvider() + c.registerOpenBMCProvider() +} + // GetMetadata returns the metadata that is populated after each BMC function/method call func (c *Client) GetMetadata() bmc.Metadata { if c.metadata != nil { @@ -247,12 +319,40 @@ func (c *Client) registry() *registrar.Registry { return c.Registry } +func (c *Client) RegisterSpanAttributes(m bmc.Metadata, span oteltrace.Span) { + span.SetAttributes(attribute.String("host", c.Auth.Host)) + + span.SetAttributes(attribute.String("successful-provider", m.SuccessfulProvider)) + + span.SetAttributes( + attribute.String("successful-open-conns", strings.Join(m.SuccessfulOpenConns, ",")), + ) + + span.SetAttributes( + attribute.String("successful-close-conns", strings.Join(m.SuccessfulCloseConns, ",")), + ) + + span.SetAttributes( + attribute.String("attempted-providers", strings.Join(m.ProvidersAttempted, ",")), + ) + + for p, e := range m.FailedProviderDetail { + span.SetAttributes( + attribute.String("provider-errs-"+p, e), + ) + } +} + // Open calls the OpenConnectionFromInterfaces library function // Any providers/drivers that do not successfully connect are removed // from the client.Registry.Drivers. If client.Registry.Drivers ends up // being empty then we error. func (c *Client) Open(ctx context.Context) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Open") + defer span.End() + ifs, metadata, err := bmc.OpenConnectionFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + metadata.RegisterSpanAttributes(c.Auth.Host, span) defer c.setMetadata(metadata) if err != nil { return err @@ -273,6 +373,10 @@ func (c *Client) Open(ctx context.Context) error { // Close pass through to library function func (c *Client) Close(ctx context.Context) (err error) { + + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Close") + defer span.End() + // Generally, we always want the close function to run. // We don't want a context timeout or cancellation to prevent this. // But because the current model is to pass just a single context to all @@ -285,6 +389,8 @@ func (c *Client) Close(ctx context.Context) (err error) { } metadata, err := bmc.CloseConnectionFromInterfaces(ctx, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return err } @@ -300,50 +406,96 @@ func (c *Client) FilterForCompatible(ctx context.Context) { // GetPowerState pass through to library function func (c *Client) GetPowerState(ctx context.Context) (state string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetPowerState") + defer span.End() + state, metadata, err := bmc.GetPowerStateFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return state, err } // SetPowerState pass through to library function func (c *Client) SetPowerState(ctx context.Context, state string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetPowerState") + defer span.End() + ok, metadata, err := bmc.SetPowerStateFromInterfaces(ctx, c.perProviderTimeout(ctx), state, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } // CreateUser pass through to library function func (c *Client) CreateUser(ctx context.Context, user, pass, role string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "CreateUser") + defer span.End() + ok, metadata, err := bmc.CreateUserFromInterfaces(ctx, c.perProviderTimeout(ctx), user, pass, role, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } // UpdateUser pass through to library function func (c *Client) UpdateUser(ctx context.Context, user, pass, role string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "UpdateUser") + defer span.End() + ok, metadata, err := bmc.UpdateUserFromInterfaces(ctx, c.perProviderTimeout(ctx), user, pass, role, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } // DeleteUser pass through to library function func (c *Client) DeleteUser(ctx context.Context, user string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "DeleteUser") + defer span.End() + ok, metadata, err := bmc.DeleteUserFromInterfaces(ctx, c.perProviderTimeout(ctx), user, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } // ReadUsers pass through to library function func (c *Client) ReadUsers(ctx context.Context) (users []map[string]string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ReadUsers") + defer span.End() + users, metadata, err := bmc.ReadUsersFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return users, err } +// GetBootDeviceOverride pass through to library function +func (c *Client) GetBootDeviceOverride(ctx context.Context) (override bmc.BootDeviceOverride, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetBootDeviceOverride") + defer span.End() + + override, metadata, err := bmc.GetBootDeviceOverrideFromInterface(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + + return override, err +} + // SetBootDevice pass through to library function func (c *Client) SetBootDevice(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetBootDevice") + defer span.End() + ok, metadata, err := bmc.SetBootDeviceFromInterfaces(ctx, c.perProviderTimeout(ctx), bootDevice, setPersistent, efiBoot, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } @@ -352,56 +504,260 @@ func (c *Client) SetBootDevice(ctx context.Context, bootDevice string, setPersis // mediaURL isn't empty, attaches a virtual media device of type kind whose contents are // streamed from the indicated URL. func (c *Client) SetVirtualMedia(ctx context.Context, kind string, mediaURL string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetVirtualMedia") + defer span.End() + ok, metadata, err := bmc.SetVirtualMediaFromInterfaces(ctx, kind, mediaURL, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } // ResetBMC pass through to library function func (c *Client) ResetBMC(ctx context.Context, resetType string) (ok bool, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ResetBMC") + defer span.End() + ok, metadata, err := bmc.ResetBMCFromInterfaces(ctx, c.perProviderTimeout(ctx), resetType, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return ok, err } +// DeactivateSOL pass through library function to deactivate active SOL sessions +func (c *Client) DeactivateSOL(ctx context.Context) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "DeactivateSOL") + defer span.End() + metadata, err := bmc.DeactivateSOLFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return err +} + // Inventory pass through library function to collect hardware and firmware inventory func (c *Client) Inventory(ctx context.Context) (device *common.Device, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Inventory") + defer span.End() + device, metadata, err := bmc.GetInventoryFromInterfaces(ctx, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) return device, err } func (c *Client) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetBiosConfiguration") + defer span.End() + biosConfig, metadata, err := bmc.GetBiosConfigurationInterfaces(ctx, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return biosConfig, err } +func (c *Client) SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetBiosConfiguration") + defer span.End() + + metadata, err := bmc.SetBiosConfigurationInterfaces(ctx, c.registry().GetDriverInterfaces(), biosConfig) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +func (c *Client) SetBiosConfigurationFromFile(ctx context.Context, cfg string) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SetBiosConfigurationFromFile") + defer span.End() + + metadata, err := bmc.SetBiosConfigurationFromFileInterfaces(ctx, c.registry().GetDriverInterfaces(), cfg) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +func (c *Client) ResetBiosConfiguration(ctx context.Context) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ResetBiosConfiguration") + defer span.End() + + metadata, err := bmc.ResetBiosConfigurationInterfaces(ctx, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + // FirmwareInstall pass through library function to upload firmware and install firmware -func (c *Client) FirmwareInstall(ctx context.Context, component, 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) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstall") + defer span.End() + + taskID, metadata, err := bmc.FirmwareInstallFromInterfaces(ctx, component, operationApplyTime, forceInstall, reader, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + 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) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstallStatus") + defer span.End() + status, metadata, err := bmc.FirmwareInstallStatusFromInterfaces(ctx, installVersion, component, taskID, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return status, err } // PostCodeGetter pass through library function to return the BIOS/UEFI POST code func (c *Client) PostCode(ctx context.Context) (status string, code int, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "PostCode") + defer span.End() + status, code, metadata, err := bmc.GetPostCodeInterfaces(ctx, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + return status, code, err } func (c *Client) Screenshot(ctx context.Context) (image []byte, fileType string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "Screenshot") + defer span.End() + image, fileType, metadata, err := bmc.ScreenshotFromInterfaces(ctx, c.registry().GetDriverInterfaces()) c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) return image, fileType, err } + +func (c *Client) ClearSystemEventLog(ctx context.Context) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "ClearSystemEventLog") + defer span.End() + + metadata, err := bmc.ClearSystemEventLogFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +func (c *Client) MountFloppyImage(ctx context.Context, image io.Reader) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "MountFloppyImage") + defer span.End() + + metadata, err := bmc.MountFloppyImageFromInterfaces(ctx, image, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return err +} + +func (c *Client) UnmountFloppyImage(ctx context.Context) (err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "UnmountFloppyImage") + defer span.End() + + metadata, err := bmc.UnmountFloppyImageFromInterfaces(ctx, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + 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) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstallSteps") + defer span.End() + + status, metadata, err := bmc.FirmwareInstallStepsFromInterfaces(ctx, component, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + 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) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareUpload") + defer span.End() + + uploadVerifyTaskID, metadata, err := bmc.FirmwareUploadFromInterfaces(ctx, component, file, c.Registry.GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + 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 constants.TaskState, status string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareTaskStatus") + defer span.End() + + state, status, metadata, err := bmc.FirmwareTaskStatusFromInterfaces(ctx, kind, component, taskID, installVersion, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + 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) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstallUploaded") + defer span.End() + + installTaskID, metadata, err := bmc.FirmwareInstallerUploadedFromInterfaces(ctx, component, uploadVerifyTaskID, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return installTaskID, err +} + +func (c *Client) FirmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File) (taskID string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "FirmwareInstallUploadAndInitiate") + defer span.End() + + taskID, metadata, err := bmc.FirmwareInstallUploadAndInitiateFromInterfaces(ctx, component, file, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + metadata.RegisterSpanAttributes(c.Auth.Host, span) + + return taskID, err +} + +// GetSystemEventLog queries for the SEL and returns the entries in an opinionated format. +func (c *Client) GetSystemEventLog(ctx context.Context) (entries bmc.SystemEventLogEntries, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetSystemEventLog") + defer span.End() + + entries, metadata, err := bmc.GetSystemEventLogFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return entries, err +} + +// GetSystemEventLogRaw queries for the SEL and returns the raw response. +func (c *Client) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "GetSystemEventLogRaw") + defer span.End() + + eventlog, metadata, err := bmc.GetSystemEventLogRawFromInterfaces(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + return eventlog, err +} + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Client) SendNMI(ctx context.Context) error { + ctx, span := c.traceprovider.Tracer(pkgName).Start(ctx, "SendNMI") + defer span.End() + + metadata, err := bmc.SendNMIFromInterface(ctx, c.perProviderTimeout(ctx), c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + + return err +} diff --git a/client_test.go b/client_test.go index 321a7e25e..4b25deb17 100644 --- a/client_test.go +++ b/client_test.go @@ -228,10 +228,10 @@ func TestOpenFiltered(t *testing.T) { defer cl.Close(context.Background()) want := []string{"tester3", "tester1", "tester2"} if diff := cmp.Diff(cl.GetMetadata().ProvidersAttempted, want); diff != "" { - t.Errorf(diff) + t.Errorf("diff: %s", diff) } want = []string{"tester1", "tester2", "tester3"} if diff := cmp.Diff(registryNames(cl.Registry.Drivers), want); diff != "" { - t.Errorf(diff) + t.Errorf("diff: %s", diff) } } diff --git a/constants/constants.go b/constants/constants.go index b4e7ce227..5b3f44fa8 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -1,11 +1,16 @@ package constants -import "strings" +type ( + // Redfish operation apply time parameter + OperationApplyTime string -const ( - // Unknown is the constant that defines unknown things - Unknown = "Unknown" + // The FirmwareInstallStep identifies each phase of a firmware install process. + FirmwareInstallStep string + + TaskState string +) +const ( // EnvEnableDebug is the const for the environment variable to cause bmclib to dump debugging debugging information. // the valid parameter for this environment variable is 'true' EnvEnableDebug = "DEBUG_BMCLIB" @@ -27,9 +32,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 // @@ -39,33 +48,62 @@ const ( // FirmwareInstallInitializing indicates the device is performing init actions to install the update // this covers the redfish states - 'starting', 'downloading' // no action is required from the callers part in this state - FirmwareInstallInitializing = "initializing" + FirmwareInstallInitializing = "initializing" + Initializing TaskState = "initializing" // FirmwareInstallQueued indicates the device has queued the update, but has not started the update task yet // this covers the redfish states - 'pending', 'new' // no action is required from the callers part in this state - FirmwareInstallQueued = "queued" + FirmwareInstallQueued = "queued" + Queued TaskState = "queued" // FirmwareInstallRunner indicates the device is installing the update // this covers the redfish states - 'running', 'stopping', 'cancelling' // no action is required from the callers part in this state - FirmwareInstallRunning = "running" + FirmwareInstallRunning = "running" + Running TaskState = "running" // FirmwareInstallComplete indicates the device completed the firmware install // this covers the redfish state - 'complete' - FirmwareInstallComplete = "complete" + FirmwareInstallComplete = "complete" + Complete TaskState = "complete" // FirmwareInstallFailed indicates the firmware install failed // this covers the redfish states - 'interrupted', 'killed', 'exception', 'cancelled', 'suspended' - FirmwareInstallFailed = "failed" + FirmwareInstallFailed = "failed" + Failed TaskState = "failed" // FirmwareInstallPowerCycleHost indicates the firmware install requires a host power cycle - FirmwareInstallPowerCyleHost = "powercycle-host" + FirmwareInstallPowerCycleHost = "powercycle-host" + PowerCycleHost TaskState = "powercycle-host" + + FirmwareInstallUnknown = "unknown" + Unknown TaskState = "unknown" + + // FirmwareInstallStepUploadInitiateInstall identifies the step to upload _and_ initialize the firmware install. + // as part of the same call. + FirmwareInstallStepUploadInitiateInstall FirmwareInstallStep = "upload-initiate-install" - // FirmwareInstallPowerCycleBMC indicates the firmware install requires a BMC power cycle - FirmwareInstallPowerCycleBMC = "powercycle-bmc" + // FirmwareInstallStepInstallStatus identifies the step to verify the status of the firmware install. + FirmwareInstallStepInstallStatus FirmwareInstallStep = "install-status" - FirmwareInstallUnknown = "unknown" + // 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" + + // FirmwareInstallStepResetBMCPostInstall indicates the BMC requires a reset after the install. + FirmwareInstallStepResetBMCPostInstall FirmwareInstallStep = "reset-bmc-post-install" + + // FirmwareInstallStepResetBMCOnInstallFailure indicates the BMC requires a reset if an install fails. + FirmwareInstallStepResetBMCOnInstallFailure FirmwareInstallStep = "reset-bmc-on-install-failure" // device BIOS/UEFI POST code bmclib identifiers POSTStateBootINIT = "boot-init/pxe" @@ -78,22 +116,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 3986acf52..37a7d5d16 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -42,6 +42,9 @@ var ( // ErrUserAccountUpdate is returned when the user account failed to be updated ErrUserAccountUpdate = errors.New("user account attributes could not be updated") + // ErrRedfishVersionIncompatible is returned when a given version of redfish doesn't support a feature + ErrRedfishVersionIncompatible = errors.New("operation not supported in this redfish version") + // ErrRedfishChassisOdataID is returned when no compatible Chassis Odata IDs were identified ErrRedfishChassisOdataID = errors.New("no compatible Chassis Odata IDs identified") @@ -63,9 +66,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 +117,15 @@ 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") + + // ErrRedfishNoSystems is returned when the API of the device provides and empty array of systems. + ErrRedfishNoSystems = errors.New("redfish: no Systems were found on the device") + + // ErrBMCUpdating is returned when the BMC is going through an update and will not serve other queries. + ErrBMCUpdating = errors.New("a BMC firmware update is in progress") ) type ErrUnsupportedHardware struct { diff --git a/examples/bios/main.go b/examples/bios/main.go index c8a515ae4..f9bc5c1d1 100644 --- a/examples/bios/main.go +++ b/examples/bios/main.go @@ -2,30 +2,51 @@ package main import ( "context" + "encoding/json" + "flag" "fmt" + "io" "os" + "strings" + "time" bmclib "github.com/bmc-toolbox/bmclib/v2" + "github.com/bmc-toolbox/bmclib/v2/providers" logrusr "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" ) func main() { - // setup logger + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Command line option flag parsing + user := flag.String("user", "", "Username to login with") + pass := flag.String("password", "", "Username to login with") + host := flag.String("host", "", "BMC hostname to connect to") + mode := flag.String("mode", "get", "Mode [get,set,reset]") + dfile := flag.String("file", "", "Read data from file") + + flag.Parse() + + // Logger configuration l := logrus.New() - l.Level = logrus.TraceLevel + l.Level = logrus.DebugLevel + // l.Level = logrus.TraceLevel logger := logrusr.New(l) + logger.V(9) + // bmclib client abstraction clientOpts := []bmclib.Option{bmclib.WithLogger(logger)} - host := os.Getenv("BMC_HOST") - bmcPass := os.Getenv("BMC_PASSWORD") - bmcUser := os.Getenv("BMC_USERNAME") - // init client - client := bmclib.NewClient(host, bmcUser, bmcPass, clientOpts...) + client := bmclib.NewClient(*host, *user, *pass, clientOpts...) + client.Registry.Drivers = client.Registry.Supports( + providers.FeatureGetBiosConfiguration, + providers.FeatureSetBiosConfiguration, + providers.FeatureResetBiosConfiguration, + providers.FeatureSetBiosConfigurationFromFile) - ctx := context.TODO() - // open BMC session err := client.Open(ctx) if err != nil { l.Fatal(err, "bmc login failed") @@ -33,11 +54,62 @@ func main() { defer client.Close(ctx) - // retrieve bios configuration - biosConfig, err := client.GetBiosConfiguration(ctx) - if err != nil { - l.Error(err) - } + // Operating mode selection + switch strings.ToLower(*mode) { + case "get": + // retrieve bios configuration + biosConfig, err := client.GetBiosConfiguration(ctx) + if err != nil { + l.Fatal(err) + } + + fmt.Printf("biosConfig: %#v\n", biosConfig) + case "set": + exampleConfig := make(map[string]string) + + if *dfile != "" { + jsonFile, err := os.Open(*dfile) + if err != nil { + l.Fatal(err) + } - fmt.Println(biosConfig) + defer jsonFile.Close() + + jsonData, _ := io.ReadAll(jsonFile) + + err = json.Unmarshal(jsonData, &exampleConfig) + if err != nil { + l.Fatal(err) + } + } else { + exampleConfig["TpmSecurity"] = "Off" + } + + fmt.Println("Attempting to set BIOS configuration:") + fmt.Printf("exampleConfig: %+v\n", exampleConfig) + + err := client.SetBiosConfiguration(ctx, exampleConfig) + if err != nil { + l.Error(err) + } + case "setfile": + fmt.Println("Attempting to set BIOS configuration:") + + contents, err := os.ReadFile(*dfile) + if err != nil { + l.Fatal(err) + } + + err = client.SetBiosConfigurationFromFile(ctx, string(contents)) + if err != nil { + l.Error(err) + } + case "reset": + err := client.ResetBiosConfiguration(ctx) + if err != nil { + l.Error(err) + } + default: + l.Fatal("Unknown mode: " + *mode) + } } diff --git a/examples/floppy-image/doc.go b/examples/floppy-image/doc.go new file mode 100644 index 000000000..c89443f97 --- /dev/null +++ b/examples/floppy-image/doc.go @@ -0,0 +1,19 @@ +/* +inventory is an example commmand that utilizes the 'v1' bmclib interface +methods to upload and mount, unmount a floppy image. + + # mount image + $ go run examples/floppy-image/main.go \ + -host 10.1.2.3 \ + -user ADMIN \ + -password hunter2 \ + -image /tmp/floppy.img + + # un-mount image + $ go run examples/floppy-image/main.go \ + -host 10.1.2.3 \ + -user ADMIN \ + -password hunter2 \ + -unmount +*/ +package main diff --git a/examples/floppy-image/main.go b/examples/floppy-image/main.go new file mode 100644 index 000000000..60eb29e0c --- /dev/null +++ b/examples/floppy-image/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "crypto/x509" + "flag" + "log" + "os" + "time" + + bmclib "github.com/bmc-toolbox/bmclib/v2" + "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + 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") + imagePath := flag.String("image", "", "The .img file to be uploaded") + unmountImage := flag.Bool("unmount", false, "Unmount floppy image") + + withSecureTLS := flag.Bool("secure-tls", false, "Enable secure TLS") + certPoolFile := 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") + flag.Parse() + + l := logrus.New() + l.Level = logrus.DebugLevel + logger := logrusr.New(l) + + clientOpts := []bmclib.Option{bmclib.WithLogger(logger)} + + if *withSecureTLS { + var pool *x509.CertPool + if *certPoolFile != "" { + pool = x509.NewCertPool() + data, err := os.ReadFile(*certPoolFile) + 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 { + log.Fatal(err, "bmc login failed") + } + + defer cl.Close(ctx) + + if *unmountImage { + if err := cl.UnmountFloppyImage(ctx); err != nil { + log.Fatal(err) + } + + return + } + + // open file handle + fh, err := os.Open(*imagePath) + if err != nil { + l.Fatal(err) + } + defer fh.Close() + + err = cl.MountFloppyImage(ctx, fh) + if err != nil { + l.Fatal(err) + } + + l.WithField("img", *imagePath).Info("image mounted successfully") +} diff --git a/examples/install-firmware/main.go b/examples/install-firmware/main.go index fa7d757b4..098a0dbf0 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 0a10d07d0..000000000 --- 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/examples/rpc/main.go b/examples/rpc/main.go index 097ccfbce..cbcd58736 100644 --- a/examples/rpc/main.go +++ b/examples/rpc/main.go @@ -83,6 +83,8 @@ func testConsumer(ctx context.Context) error { case rpc.BootDeviceMethod: + case rpc.PingMethod: + rp.Result = "pong" default: w.WriteHeader(http.StatusNotFound) } diff --git a/examples/sel/main.go b/examples/sel/main.go new file mode 100644 index 000000000..e5bd97caf --- /dev/null +++ b/examples/sel/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "crypto/x509" + "flag" + "io/ioutil" + "time" + + "github.com/bmc-toolbox/bmclib/v2" + "github.com/bmc-toolbox/bmclib/v2/providers" + "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") + certPoolFile := 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") + action := flag.String("action", "get", "Action to perform on the System Event Log (clear|get)") + flag.Parse() + + 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), + bmclib.WithRedfishUseBasicAuth(true), + } + + if *withSecureTLS { + var pool *x509.CertPool + if *certPoolFile != "" { + pool = x509.NewCertPool() + data, err := ioutil.ReadFile(*certPoolFile) + 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...) + cl.Registry.Drivers = cl.Registry.Supports(providers.FeatureClearSystemEventLog) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + err := cl.Open(ctx) + if err != nil { + l.WithError(err).Fatal(err, "BMC login failed") + } + defer cl.Close(ctx) + + switch *action { + case "get": + entries, err := cl.GetSystemEventLog(ctx) + if err != nil { + l.WithError(err).Fatal(err, "failed to get System Event Log") + } + l.Info("System Event Log entries", "entries", entries) + return + case "get-raw": + eventlog, err := cl.GetSystemEventLogRaw(ctx) + if err != nil { + l.WithError(err).Fatal(err, "failed to get System Event Log Raw") + } + l.Info("System Event Log", "eventlog", eventlog) + return + case "clear": + err = cl.ClearSystemEventLog(ctx) + if err != nil { + l.WithError(err).Fatal(err, "failed to clear System Event Log") + } + l.Info("System Event Log cleared") + return + default: + l.Fatal("invalid action") + } +} diff --git a/examples/virtualmedia/doc.go b/examples/virtualmedia/doc.go new file mode 100644 index 000000000..2dd0ef3a1 --- /dev/null +++ b/examples/virtualmedia/doc.go @@ -0,0 +1,18 @@ +/* +Virtual Media is an example command to mount and umount virtual media (ISO) on a BMC. + + # mount an ISO + $ go run examples/virtualmedia/main.go \ + -host 10.1.2.3 \ + -user root \ + -password calvin \ + -iso http://example.com/image.iso + + # unmount an ISO + $ go run examples/virtualmedia/main.go \ + -host 10.1.2.3 \ + -user root \ + -password calvin \ + -iso "" +*/ +package main diff --git a/examples/virtualmedia/main.go b/examples/virtualmedia/main.go new file mode 100644 index 000000000..6dd0ec14d --- /dev/null +++ b/examples/virtualmedia/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "time" + + "github.com/bmc-toolbox/bmclib/v2" + "github.com/go-logr/logr" +) + +func main() { + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + user := flag.String("user", "", "BMC username, required") + pass := flag.String("password", "", "BMC password, required") + host := flag.String("host", "", "BMC hostname or IP address, required") + isoURL := flag.String("iso", "", "The HTTP URL to the ISO to be mounted, leave empty to unmount") + flag.Parse() + + if *user == "" || *pass == "" || *host == "" { + fmt.Fprintln(os.Stderr, "user, password, and host are required") + flag.PrintDefaults() + os.Exit(1) + } + + l := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})) + log := logr.FromSlogHandler(l.Handler()) + + cl := bmclib.NewClient(*host, *user, *pass, bmclib.WithLogger(log)) + if err := cl.Open(ctx); err != nil { + panic(err) + } + defer cl.Close(ctx) + + ok, err := cl.SetVirtualMedia(ctx, "CD", *isoURL) + if err != nil { + log.Info("debugging", "metadata", cl.GetMetadata()) + panic(err) + } + if !ok { + log.Info("debugging", "metadata", cl.GetMetadata()) + panic("failed virtual media operation") + } + log.Info("virtual media operation successful", "metadata", cl.GetMetadata()) +} diff --git a/fixtures/internal/sum/ChangeBiosConfig b/fixtures/internal/sum/ChangeBiosConfig new file mode 100644 index 000000000..99b91d0c4 --- /dev/null +++ b/fixtures/internal/sum/ChangeBiosConfig @@ -0,0 +1,11 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +................. + + +Note: No BIOS setting has been changed. + +Status: The BIOS configuration is updated for 10.145.129.168 + +Note: You have to reboot or power up the system for the changes to take effect. + diff --git a/fixtures/internal/sum/ChangeBiosConfig-Changed b/fixtures/internal/sum/ChangeBiosConfig-Changed new file mode 100644 index 000000000..9b49cf0bd --- /dev/null +++ b/fixtures/internal/sum/ChangeBiosConfig-Changed @@ -0,0 +1,7 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +.................. +Status: The BIOS configuration is updated for 10.145.129.168 + +Note: You have to reboot or power up the system for the changes to take effect. + diff --git a/fixtures/internal/sum/ChangeBiosConfig-Changed-Reboot b/fixtures/internal/sum/ChangeBiosConfig-Changed-Reboot new file mode 100644 index 000000000..3aa242f8d --- /dev/null +++ b/fixtures/internal/sum/ChangeBiosConfig-Changed-Reboot @@ -0,0 +1,16 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +................. +Status: The managed system 10.145.129.168 is rebooting. + +.............................Done +.... +................. + + +Note: No BIOS setting has been changed. + +Status: The BIOS configuration is updated for 10.145.129.168 + +WARNING: Without option --post_complete, please manually confirm the managed system is POST complete before executing next action. + diff --git a/fixtures/internal/sum/GetBIOSInfo b/fixtures/internal/sum/GetBIOSInfo new file mode 100644 index 000000000..f3b58f0cd --- /dev/null +++ b/fixtures/internal/sum/GetBIOSInfo @@ -0,0 +1,7 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +.... +Managed system..........................10.145.129.168 + Board ID............................1B0F + BIOS build date.....................2022/09/16 + BIOS version........................1.9 diff --git a/fixtures/internal/sum/GetBiosConfiguration b/fixtures/internal/sum/GetBiosConfiguration new file mode 100644 index 000000000..99d8f37eb --- /dev/null +++ b/fixtures/internal/sum/GetBiosConfiguration @@ -0,0 +1,3634 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +....................... + + + + + + + + + Supermicro X11SCM-F + BIOS Version(1.9) + Build Date(09/16/2022) + CPLD Version(03.B3.05) + + Memory Information + Total Memory(32768 MB) + + + + + + + + + + + + + + Disabled + + + + + + + + Checked + + + + + + + + + + + On + + + + + + + + + + Force BIOS + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Disabled + + + + + + + + + + + Last State + + + + + + + + + + Instant Off + + + + + + + + + + Disabled + + + + + + 32767 + 1 + 1 + 100 + + + + + + + + + CPU Configuration + + Intel(R) Xeon(R) E-2278G CPU @ 3.40GHz() + CPU Signature(0x906ED) + Microcode Revision(F4) + CPU Speed(3400 MHz) + L1 Data Cache(32 KB x 8) + L1 Instruction Cache(32 KB x 8) + L2 Cache(256 KB x 8) + L3 Cache(16 MB) + L4 Cache(N/A) + VMX(Supported) + SMX/TXT(Supported) + + + + + + + + Disabled + + + + + + 63 + 0 + 0 + 20 + + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + + + + + + + All + + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + Enabled + + + + + + + + + + + Max Non-Turbo Performance + + + + + + + + + + Enabled + + + + + + + + + + Disabled + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + + + + C1 and C3 + + + + + + + + + + + + + C1 and C3 + + + + + + + + + + + Disabled + + + + + + + + + + + Disabled + + + + + + + + + + + Enabled + + + + + + + + + + + + + + + + + + + + Auto + + + + + + + + + + + Disabled + + + + + + + 4095875 + 0 + 125 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + Enabled + + + + + + + 4095875 + 0 + 125 + 0 + + + + + + + + + + Unchecked + + + + + + + + + + WARNING: Setting wrong values in below sections may cause + system to malfunction. + + + + + System Agent (SA) Configuration + + SA PCIe Code Version(7.0.88.80) + VT-d(Supported) + + + + + + Memory Configuration + + Memory RC Version(0.7.1.115) + Memory Frequency( 2667 MHz) + Memory Timings (tCL-tRCD-tRP-tRAS)(19-19-19-43) + + DIMMA1(Not Present) + DIMMA2(Populated & Enabled) + Size(16384 MB (DDR4)) + Number of Ranks(2) + Manufacturer(Micron Technology) + DIMMB1(Not Present) + DIMMB2(Populated & Enabled) + Size(16384 MB (DDR4)) + Number of Ranks(2) + Manufacturer(Micron Technology) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + + + Dynamic + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + DMI/OPI Configuration + + DMI(X4 Gen3) + + + + + + + + + + L1 + + + + + + + + + + Disabled + + + + + + + + + + -3.5 dB + + + + + + + + + PEG Port Configuration + + CPU Slot6 PCI-E 3.0 X16(x8 Gen3) + + + + + + + + Auto + + + + + + + + + + + + Auto + + + + + + + + + + + + + Auto + + + + + + + + + + + + + Auto + + + + + + + + + + + Both Root and Endpoint Ports + + + + + + + + + + + -3.5 dB + + + + + + 255 + 0 + 1 + 75 + + + + + + + + + + + + 1.0x + + + + + + 8191 + 0 + 1 + 1 + + + + + + + + + + + Auto + + + + + + + + + + + Disabled + + + + + + + + + + + + Enabled + + + + + + + + + + + Software Controlled + + + + + + + + + + + No Change in Owner EPOCHs + + + + + + + 18446744073709551615 + 0 + 1 + 6142344250440060711 + + + + + + + 18446744073709551615 + 0 + 1 + 15521488688214965697 + + + + + + + + + + + + Unlocked + + + + + + + 18446744073709551615 + 0 + 1 + 0 + + + + + + + 18446744073709551615 + 0 + 1 + 0 + + + + + + + 18446744073709551615 + 0 + 1 + 0 + + + + + + + 18446744073709551615 + 0 + 1 + 0 + + + + + + + + + + + INVALID PRMRR + + + + + + + + + + + Enabled + + + + + + + + + + Disabled + + + + + + + + + + PCH-IO Configuration + + + + + + PCI Express Configuration + + + + + + + + + + + Auto + + + + + + + + + + Disabled + + + + + + + + + + + + + + + + + Auto + + + + + + + + + + + L1.1 & L1.2 + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + Auto + + + + + + + + + + + L1.1 & L1.2 + + + + + + + + + + + + Auto + + + + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Super IO Configuration + + Super IO Chip(AST2500) + + + + + Serial Port 1 Configuration + + + + + Checked + + + + Device Settings(IO=3F8h; IRQ=4;) + + + + + + + + + + + + Auto + + + + + + + + + + Serial Port 2 Configuration + + + + + Checked + + + + Device Settings(IO=2F8h; IRQ=3;) + + + + + + + + + + + + Auto + + + + + + + + + + + + COM1 + + + + Unchecked + + + + + + + + + COM1 + Console Redirection Settings + + + + + + + + + + VT100+ + + + + + + + + + + + + + 115200 + + + + + + + + + + 8 + + + + + + + + + + + + + None + + + + + + + + + + 1 + + + + + + + + + + None + + + + + + + Checked + + + + + + + Unchecked + + + + + + + Checked + + + + + + + + + + + + + + VT100 + + + + + + + SOL + + + + Checked + + + + + + + + + SOL + Console Redirection Settings + + + + + + + + + + VT100+ + + + + + + + + + + + + + 115200 + + + + + + + + + + 8 + + + + + + + + + + + + + None + + + + + + + + + + 1 + + + + + + + + + + None + + + + + + + Checked + + + + + + + Unchecked + + + + + + + Checked + + + + + + + + + + + + + + VT100 + + + + + + + Legacy Console Redirection + + + + + Legacy Console Redirection Settings + + + + + + + + COM1 + + + + + + + + + + 80x25 + + + + + + + + + + Always Enable + + + + + + Serial Port for Out-of-Band Management/ + Windows Emergency Management Services (EMS) + + + + Unchecked + + + + + + + + + + + + + + + COM1 + + + + + + + + + + + + VT-UTF8 + + + + + + + + + + + + + 115200 + + + + + + + + + + + + None + + + + + Data Bits(8) + + Parity(None) + + Stop Bits(1) + + + + + + + + SATA And RSTe Configuration + + + + + + + + Enabled + + + + + + + + + + AHCI + + + + + + + + + + + + MSI + + + + + + + + + + + Disabled + + + + + + + + + + + + Legacy + + + + + Serial ATA Port 0(Micron_5300_MT (480.1GB)) + + Software Preserve(SUPPORTED) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 1(Micron_5300_MT (480.1GB)) + + Software Preserve(SUPPORTED) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 2(Empty) + + Software Preserve(Unknown) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 3(Empty) + + Software Preserve(Unknown) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 4(Empty) + + Software Preserve(Unknown) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 5(Empty) + + Software Preserve(Unknown) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + Serial ATA Port 6(Empty) + + Software Preserve(Unknown) + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Hard Disk Drive + + + + + + + + + + PCH-FW Configuration + Operational Firmware Version(5.1.4.700) + Backup Firmware Version(N/A) + Recovery Firmware Version(5.1.4.700) + ME Firmware Features(SiEn +NM +PECIProxy +ICC +MeStorageServices +BootGuard +PmBusProxy +HSIO +PCHDebug +PowerThermalUtility +PCHThermalSensorInit +DeepSx +DirectMeUpdate +TelemetryHub +) + ME Firmware Status #1(0x00000255) + ME Firmware Status #2(0x89118027) + Current State(Operational) + Error Code(No Error) + + + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + + Auto + + + + + + + + + + Disabled + + + + + + + + + USB Configuration + + USB Module Version(23) + + USB Controllers:() + 1 XHCI + USB Devices:() + 1 Keyboard, 1 Mouse, 1 Hub + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + Option ROM execution + + + + + + + Disabled + + + + + + + + + + Disabled + + + + + + + + + + Disabled + + + + + + + + + + Onboard + + + + + + + + + + + Legacy + + + + + + + + + + Vendor Defined Firmware + + + + + PCIe/PCI/PnP Configuration + + + + + + + + Legacy + + + + + + + + + + + Legacy + + + + + + + + + + + Legacy + + + + + + + + + + Legacy + + + + + + + + + + + PXE + + + + + + + + + + + Disabled + + + + + + + + + + + Enabled + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Enabled + + + + + + + + + + + Disabled + + + + + + + + + + + Disabled + + + + + + + 5 + 0 + 1 + 0 + + + + + + + 50 + 1 + 1 + 1 + + + + + + + + + + + + + + + + TPM20 Device Found + Firmware Version:(7.62) + Vendor:(IFX) + + + + + + + + Enable + + + + Active PCR banks(SHA-1,SHA256) + + Available PCR banks(SHA-1,SHA256) + + + + + + + + + Enabled + + + + + + + + + + + Enabled + + + + + + + + + + + + None + + + + + + + + + + + Enabled + + + + + + + + + + + Enabled + + + + + + + + + + + Enabled + + + + + + + + + + + TCG_2 + + + + + + + + + + + 1.3 + + + + + + + + + + + Enabled + + + + + + + + + + + Auto + + + + + + + + + + + Disabled + + + + + + + + + + Disabled + + + + + + + + + + HTTP BOOT Configuration + + + + + + + Disabled + + + + + + 0 + 75 + + + False + + + + + + 0 + 80 + + + False + + + + + + + + + + + to change the SMBIOS Event Log configuration.]]> + + Enabling/Disabling Options + + + + + + + Enabled + + + + + + + + + + Disabled + + + + + Erasing Settings + + + + + + + + No + + + + + + + + + + + Do Nothing + + + + + + SMBIOS Event Log Standard Settings + + + + + + + Disabled + + + + + + + 255 + 1 + 1 + 1 + + + + + + + 99 + 0 + 1 + 60 + + + + + + + + to view the SMBIOS Event Log records.]]> + + DATE TIME ERROR CODE SEVERITY + + + + + + + Password Description + + If the Administrator / User password is set, then this only limits access to Setup and is asked for when entering Setup. Please set Administrator password first in order for setting User password, if clear Administrator password, User password will be cleared as well. + The password length must be in the following range: + Minimum length(3) + Maximum length(20) + + + + + + + + Setup + + + + + + + + + + Disabled + + + + + + + + + Security Module Version(1.00) + HDD Name(Micron_5300_MTFDDAK480TDT) + HDD Serial Number(20422B887014) + Security Mode(SAT3 Supported) + Estimated Time(2 Minutes) + HDD User Pwd Status(NOT INSTALLED) + + + + + + + + + Disable + + + + + + 0 + 32 + + + False + + + + + HDD Name(Micron_5300_MTFDDAK480TDT) + HDD Serial Number(20422B8885C5) + Security Mode(SAT3 Supported) + Estimated Time(2 Minutes) + HDD User Pwd Status(NOT INSTALLED) + + + + + + + + + Disable + + + + + + 0 + 32 + + + False + + + + + + + + + + 3 + 20 + False + + + + + + + + + 3 + 20 + False + + + + + + + + + + + + + + + + + + Disabled + + + + + + + + + + + + Disabled + + + + System Mode(Setup) + Secure Boot(Not Active) + + + + + + + Setup + + + + + HDD Security Configuration: + + + + + HDD Password Description : + + Allows Access to Set, Modify and Clear + + HDD PASSWORD CONFIGURATION: + + Security Supported :(Yes) + Security Supported :(No) + Security Enabled :(No) + Security Locked :(Yes) + Security Locked :(No) + Security Frozen :(No) + HDD User Pwd Status:(INSTALLED) + HDD User Pwd Status:(NOT INSTALLED) + HDD Master Pwd Status :(NOT INSTALLED) + + + + + 0 + 32 + False + + + + + + + + + + + HDD Password Description : + + Allows Access to Set, Modify and Clear + + HDD PASSWORD CONFIGURATION: + + Security Supported :(No) + Security Enabled :(Yes) + Security Locked :(No) + Security Frozen :(Yes) + Security Frozen :(No) + HDD User Pwd Status:(NOT INSTALLED) + HDD Master Pwd Status :(INSTALLED) + HDD Master Pwd Status :(NOT INSTALLED) + + + + + 0 + 32 + False + + + + + + + + + + + + Boot Configuration + + + 65535 + 1 + 1 + 1 + + + + + + + + + + + + DUAL + + + + + FIXED BOOT ORDER Priorities + + + + + + + + + + + + + + + UEFI Hard Disk + + + + + + + + + + + + + + + + + + + UEFI CD/DVD + + + + + + + + + + + + + + + + + + + UEFI USB Hard Disk + + + + + + + + + + + + + + + + + + + UEFI USB CD/DVD + + + + + + + + + + + + + + + + + + + UEFI USB Key + + + + + + + + + + + + + + + + + + + UEFI USB Floppy + + + + + + + + + + + + + + + + + + + UEFI USB Lan + + + + + + + + + + + + + + + + + + + UEFI Network + + + + + + + + + + + + + + + + + + + UEFI AP:UEFI: Built-in EFI Shell + + + + + + + + + + + + + + + + + + Hard Disk: Micron_5300_MTFDDAK480TDT + + + + + + + + + + + + + + + + + + CD/DVD + + + + + + + + + + + + + + + + + + USB Hard Disk + + + + + + + + + + + + + + + + + + USB CD/DVD + + + + + + + + + + + + + + + + + + USB Key + + + + + + + + + + + + + + + + + + USB Floppy + + + + + + + + + + + + + + + + + + USB Lan + + + + + + + + + + + + + + + + + + Network:FlexBoot v3.5.901 (PCI 01:00.0) + + + + + + + + + + + + + + + + + + + + + + + + + + + Hard Disk: Micron_5300_MTFDDAK480TDT + + + + + + + + + + + + + + + + + + + + + + + + + + + CD/DVD + + + + + + + + + + + + + + + + + + + + + + + + + + + USB Hard Disk + + + + + + + + + + + + + + + + + + + + + + + + + + + USB CD/DVD + + + + + + + + + + + + + + + + + + + + + + + + + + + USB Key + + + + + + + + + + + + + + + + + + + + + + + + + + + USB Floppy + + + + + + + + + + + + + + + + + + + + + + + + + + + USB Lan + + + + + + + + + + + + + + + + + + + + + + + + + + + Network:FlexBoot v3.5.901 (PCI 01:00.0) + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI Hard Disk + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI CD/DVD + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI USB Hard Disk + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI USB CD/DVD + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI USB Key + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI USB Floppy + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI USB Lan + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI Network + + + + + + + + + + + + + + + + + + + + + + + + + + + UEFI AP:UEFI: Built-in EFI Shell + + + + + + + + + + + + + + + + + + + UEFI: Built-in EFI Shell + + + + + + + + + + + + + + + + + P0: Micron_5300_MTFDDAK480TDT(SATA,Port:0) + + + + + + + + + + + P1: Micron_5300_MTFDDAK480TDT(SATA,Port:1) + + + + + + + + + + + + + + + + + FlexBoot v3.5.901 (PCI 01:00.0) + + + + + + + + + + + FlexBoot v3.5.901 (PCI 01:00.1) + + + + + + + diff --git a/fixtures/internal/sum/SetBiosConfiguration b/fixtures/internal/sum/SetBiosConfiguration new file mode 100644 index 000000000..3aa242f8d --- /dev/null +++ b/fixtures/internal/sum/SetBiosConfiguration @@ -0,0 +1,16 @@ +Supermicro Update Manager (for UEFI BIOS) 2.14.0 (2024/02/15) (ARM64) +Copyright(C) 2013-2024 Super Micro Computer, Inc. All rights reserved. +................. +Status: The managed system 10.145.129.168 is rebooting. + +.............................Done +.... +................. + + +Note: No BIOS setting has been changed. + +Status: The BIOS configuration is updated for 10.145.129.168 + +WARNING: Without option --post_complete, please manually confirm the managed system is POST complete before executing next action. + diff --git a/go.mod b/go.mod index d73adff92..2370ec265 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,30 @@ module github.com/bmc-toolbox/bmclib/v2 -go 1.18 +go 1.21 require ( dario.cat/mergo v1.0.0 github.com/Jeffail/gabs/v2 v2.7.0 - github.com/bmc-toolbox/common v0.0.0-20230220061748-93ff001f4a1d + github.com/bmc-toolbox/common v0.0.0-20240806132831-ba8adc6a35e3 github.com/bombsimon/logrusr/v2 v2.0.1 github.com/ghodss/yaml v1.0.0 - github.com/go-logr/logr v1.2.4 + github.com/go-logr/logr v1.4.1 github.com/go-logr/zerologr v1.2.3 - github.com/google/go-cmp v0.5.9 + github.com/google/go-cmp v0.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/jacobweinstock/iamt v0.0.0-20230502042727-d7cdbe67d9ef github.com/jacobweinstock/registrar v0.4.7 github.com/pkg/errors v0.9.1 - github.com/rs/zerolog v1.30.0 - github.com/sirupsen/logrus v1.8.1 - github.com/stmcginnis/gofish v0.14.0 - github.com/stretchr/testify v1.8.0 + github.com/rs/zerolog v1.31.0 + github.com/sirupsen/logrus v1.9.3 + github.com/stmcginnis/gofish v0.19.0 + github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/trace v1.24.0 go.uber.org/goleak v1.2.1 - golang.org/x/crypto v0.1.0 - golang.org/x/exp v0.0.0-20230127130021-4ca2cb1a16b7 - golang.org/x/net v0.1.0 + golang.org/x/crypto v0.23.0 + golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 + golang.org/x/net v0.25.0 gopkg.in/go-playground/assert.v1 v1.2.1 ) @@ -31,11 +33,12 @@ require ( github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cdb371e35..c7e105fa5 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ 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-20240806132831-ba8adc6a35e3 h1:/BjZSX/sphptIdxpYo4wxAQkgMLyMMgfdl48J9DKNeE= +github.com/bmc-toolbox/common v0.0.0-20240806132831-ba8adc6a35e3/go.mod h1:Cdnkm+edb6C0pVkyCrwh3JTXAe0iUF9diDG/DztPI9I= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= github.com/bombsimon/logrusr/v2 v2.0.1/go.mod h1:ByVAX+vHdLGAfdroiMg6q0zgq2FODY2lc5YJvzmOJio= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -18,13 +18,13 @@ 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.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -41,46 +41,56 @@ 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-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-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.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= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/stmcginnis/gofish v0.14.0 h1:geECNAiG33JDB2x2xDkerpOOuXFqxp5YP3EFE3vd5iM= -github.com/stmcginnis/gofish v0.14.0/go.mod h1:BLDSFTp8pDlf/xDbLZa+F7f7eW0E/CHCboggsu8CznI= +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.19.0 h1:fmxdRZ5WHfs+4ExArMYoeRfoh+SAxLELKtmoVplBkU4= +github.com/stmcginnis/gofish v0.19.0/go.mod h1:lq2jHj2t8Krg0Gx02ABk8MbK7Dz9jvWpO/TGnVksn00= 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= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 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/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/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= +golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/executor/errors.go b/internal/executor/errors.go new file mode 100644 index 000000000..d37b70c03 --- /dev/null +++ b/internal/executor/errors.go @@ -0,0 +1,44 @@ +package executor + +import ( + "errors" + "fmt" +) + +var ( + ErrNoCommandOutput = errors.New("command returned no output") + ErrVersionStrExpectedSemver = errors.New("expected version string to follow semver format") + ErrFakeExecutorInvalidArgs = errors.New("invalid number of args passed to fake executor") + ErrRepositoryBaseURL = errors.New("repository base URL undefined, ensure UpdateOptions.BaseURL OR UPDATE_BASE_URL env var is set") + ErrNoUpdatesApplicable = errors.New("no updates applicable") + ErrDmiDecodeRun = errors.New("error running dmidecode") + ErrComponentListExpected = errors.New("expected a list of components to apply updates") + ErrDeviceInventory = errors.New("failed to collect device inventory") + ErrUnsupportedDiskVendor = errors.New("unsupported disk vendor") + ErrNoUpdateHandlerForComponent = errors.New("component slug has no update handler declared") + ErrBinNotExecutable = errors.New("bin has no executable bit set") + ErrBinLstat = errors.New("failed to run lstat on bin") + ErrBinLookupPath = errors.New("failed to lookup bin path") +) + +// ExecError is returned when the command exits with an error or a non zero exit status +type ExecError struct { + Cmd string + Stderr string + Stdout string + ExitCode int +} + +// Error implements the error interface +func (u *ExecError) Error() string { + return fmt.Sprintf("cmd %s exited with error: %s\n\t exitCode: %d\n\t stdout: %s", u.Cmd, u.Stderr, u.ExitCode, u.Stdout) +} + +func newExecError(cmd string, r *Result) *ExecError { + return &ExecError{ + Cmd: cmd, + Stderr: string(r.Stderr), + Stdout: string(r.Stdout), + ExitCode: r.ExitCode, + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 000000000..e22ec6d2c --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,126 @@ +package executor + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "strings" + + "github.com/pkg/errors" +) + +// Executor interface lets us implement dummy executors for tests +type Executor interface { + ExecWithContext(context.Context) (*Result, error) + SetArgs([]string) + SetEnv([]string) + GetCmd() string + CheckExecutable() error + SetStdout([]byte) +} + +func NewExecutor(cmd string) Executor { + return &Execute{Cmd: cmd, CheckBin: true} +} + +// An execute instace +type Execute struct { + Cmd string + Args []string + Env []string + Stdin io.Reader + CheckBin bool + Quiet bool +} + +// The result of a command execution +type Result struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +// GetCmd returns the command with args as a string +func (e *Execute) GetCmd() string { + cmd := []string{e.Cmd} + cmd = append(cmd, e.Args...) + + return strings.Join(cmd, " ") +} + +// SetArgs sets the command args +func (e *Execute) SetArgs(a []string) { + e.Args = a +} + +// SetEnv sets the env variables +func (e *Execute) SetEnv(env []string) { + e.Env = env +} + +// SetStdout doesn't do much, is around for tests +func (e *Execute) SetStdout(_ []byte) { +} + +// ExecWithContext executes the command and returns the Result object +func (e *Execute) ExecWithContext(ctx context.Context) (result *Result, err error) { + if e.CheckBin { + err = e.CheckExecutable() + if err != nil { + return nil, err + } + } + + cmd := exec.CommandContext(ctx, e.Cmd, e.Args...) + cmd.Env = append(cmd.Env, e.Env...) + cmd.Stdin = e.Stdin + + var stdoutBuf, stderrBuf bytes.Buffer + if !e.Quiet { + cmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + } else { + cmd.Stderr = &stderrBuf + cmd.Stdout = &stdoutBuf + } + + if err := cmd.Run(); err != nil { + result = &Result{stdoutBuf.Bytes(), stderrBuf.Bytes(), cmd.ProcessState.ExitCode()} + return result, newExecError(e.GetCmd(), result) + } + + result = &Result{stdoutBuf.Bytes(), stderrBuf.Bytes(), cmd.ProcessState.ExitCode()} + + return result, nil +} + +// CheckExecutable determines if the set Cmd value exists as a file and is an executable. +func (e *Execute) CheckExecutable() error { + var path string + + if strings.Contains(e.Cmd, "/") { + path = e.Cmd + } else { + var err error + path, err = exec.LookPath(e.Cmd) + if err != nil { + return errors.Wrap(ErrBinLookupPath, err.Error()) + } + + e.Cmd = path + } + + fileInfo, err := os.Lstat(path) + if err != nil { + return errors.Wrap(ErrBinLstat, err.Error()) + } + + // bit mask 0111 indicates atleast one of owner, group, others has an executable bit set + if fileInfo.Mode()&0o111 == 0 { + return ErrBinNotExecutable + } + + return nil +} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 000000000..87079c843 --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,116 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func Test_Stdin(t *testing.T) { + e := new(Execute) + e.Cmd = "grep" + e.Args = []string{"hello"} + e.Stdin = bytes.NewReader([]byte("hello")) + + result, err := e.ExecWithContext(context.Background()) + if err != nil { + fmt.Println(err.Error()) + } + + assert.Equal(t, []byte("hello\n"), result.Stdout) +} + +type checkBinTester struct { + createFile bool + filePath string + expectedErr error + fileMode uint + testName string +} + +func initCheckBinTests() []checkBinTester { + return []checkBinTester{ + { + false, + "f", + ErrBinLookupPath, + 0, + "bin path lookup err test", + }, + { + false, + "/tmp/f", + ErrBinLstat, + 0, + "bin exists err test", + }, + { + true, + "/tmp/f", + ErrBinNotExecutable, + 0o666, + "bin exists with no executable bit test", + }, + { + true, + "/tmp/j", + nil, + 0o667, + "bin with executable bit returns no error", + }, + { + true, + "/tmp/k", + nil, + 0o700, + "bin with owner executable bit returns no error", + }, + { + true, + "/tmp/l", + nil, + 0o070, + "bin with group executable bit returns no error", + }, + { + true, + "/tmp/m", + nil, + 0o007, + "bin with other executable bit returns no error", + }, + } +} + +func Test_CheckExecutable(t *testing.T) { + tests := initCheckBinTests() + for _, c := range tests { + if c.createFile { + f, err := os.Create(c.filePath) + if err != nil { + t.Error(err) + } + + // nolint:gocritic // test code + defer os.Remove(c.filePath) + + if c.fileMode != 0 { + err = f.Chmod(fs.FileMode(c.fileMode)) + if err != nil { + t.Error(err) + } + } + } + + e := new(Execute) + e.Cmd = c.filePath + err := e.CheckExecutable() + assert.Equal(t, c.expectedErr, errors.Cause(err), c.testName) + } +} diff --git a/internal/executor/fake_executor.go b/internal/executor/fake_executor.go new file mode 100644 index 000000000..888a99835 --- /dev/null +++ b/internal/executor/fake_executor.go @@ -0,0 +1,100 @@ +package executor + +import ( + "context" + "io" + "strings" +) + +// FakeExecute implements the utils.Executor interface +// to enable testing +type FakeExecute struct { + Cmd string + Args []string + Env []string + CheckBin bool + Stdin io.Reader + Stdout []byte // Set this for the dummy data to be returned + Stderr []byte // Set this for the dummy data to be returned + Quiet bool + ExitCode int +} + +func NewFakeExecutor(cmd string) Executor { + return &FakeExecute{Cmd: cmd, CheckBin: false} +} + +// nolint:gocyclo // TODO: break this method up and move into each $util_test.go +// FakeExecute method returns whatever you want it to return +// Set e.Stdout and e.Stderr to data to be returned +func (e *FakeExecute) ExecWithContext(_ context.Context) (*Result, error) { + // switch e.Cmd { + // case "ipmicfg": + // if e.Args[0] == "-summary" { + // buf := new(bytes.Buffer) + + // _, err := buf.ReadFrom(e.Stdin) + // if err != nil { + // return nil, err + // } + + // e.Stdout = buf.Bytes() + // } + // } + + return &Result{Stdout: e.Stdout, Stderr: e.Stderr, ExitCode: 0}, nil +} + +// CheckExecutable implements the Executor interface +func (e *FakeExecute) CheckExecutable() error { + return nil +} + +// CmdPath returns the absolute path to the executable +// this means the caller should not have disabled CheckBin. +func (e *FakeExecute) CmdPath() string { + return e.Cmd +} + +func (e *FakeExecute) SetArgs(a []string) { + e.Args = a +} + +func (e *FakeExecute) SetEnv(env []string) { + e.Env = env +} + +func (e *FakeExecute) SetQuiet() { + e.Quiet = true +} + +func (e *FakeExecute) SetVerbose() { + e.Quiet = false +} + +func (e *FakeExecute) SetStdout(b []byte) { + e.Stdout = b +} + +func (e *FakeExecute) SetStderr(b []byte) { + e.Stderr = b +} + +func (e *FakeExecute) SetStdin(r io.Reader) { + e.Stdin = r +} + +func (e *FakeExecute) DisableBinCheck() { + e.CheckBin = false +} + +func (e *FakeExecute) SetExitCode(i int) { + e.ExitCode = i +} + +func (e *FakeExecute) GetCmd() string { + cmd := []string{e.Cmd} + cmd = append(cmd, e.Args...) + + return strings.Join(cmd, " ") +} diff --git a/internal/ipmi/ipmi.go b/internal/ipmi/ipmi.go index 8b0367941..351961783 100644 --- a/internal/ipmi/ipmi.go +++ b/internal/ipmi/ipmi.go @@ -377,3 +377,73 @@ func (i *Ipmi) ReadUsers(ctx context.Context) (users []map[string]string, err er return users, err } + +// ClearSystemEventLog clears the system event log +func (i *Ipmi) ClearSystemEventLog(ctx context.Context) (err error) { + _, err = i.run(ctx, []string{"sel", "clear"}) + return err +} + +// GetSystemEventLog returns the system event log entries in ID, Timestamp, Description, Message format +func (i *Ipmi) GetSystemEventLog(ctx context.Context) (entries [][]string, err error) { + output, err := i.GetSystemEventLogRaw(ctx) + if err != nil { + return nil, errors.Wrap(err, "error getting system event log") + } + + entries = parseSystemEventLog(output) + + return entries, nil +} + +// parseSystemEventLogRaw parses the raw output of the system event log. Helper +// function for GetSystemEventLog to make testing the parser easier. +func parseSystemEventLog(raw string) (entries [][]string) { + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := strings.Split(scanner.Text(), "|") + if len(line) < 6 { + continue + } + if line[0] == "ID" { + continue + } + for i := range line { + line[i] = strings.TrimSpace(line[i]) + } + // ID, Timestamp (date time), Description, Message (message : assertion) + entries = append(entries, []string{line[0], fmt.Sprintf("%s %s", line[1], line[2]), line[3], fmt.Sprintf("%s : %s", line[4], line[5])}) + } + + return entries +} + +// GetSystemEventLogRaw returns the raw SEL output +func (i *Ipmi) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + output, err := i.run(ctx, []string{"sel", "list"}) + if err != nil { + return "", errors.Wrap(err, "error getting system event log") + } + + return output, nil +} + +func (i *Ipmi) DeactivateSOL(ctx context.Context) (err error) { + out, err := i.run(ctx, []string{"sol", "deactivate"}) + // Don't treat this as a failure (we just want to ensure there + // isn't an active SOL session left open) + if strings.TrimSpace(out) == "Info: SOL payload already de-activated" { + err = nil + } + return err +} + +// SendPowerDiag tells the BMC to issue an NMI to the device +func (i *Ipmi) SendPowerDiag(ctx context.Context) error { + _, err := i.run(ctx, []string{"chassis", "power", "diag"}) + if err != nil { + err = errors.Wrap(err, "failed sending power diag") + } + + return err +} diff --git a/internal/redfishwrapper/bios.go b/internal/redfishwrapper/bios.go new file mode 100644 index 000000000..b6c5f8d54 --- /dev/null +++ b/internal/redfishwrapper/bios.go @@ -0,0 +1,97 @@ +package redfishwrapper + +import ( + "context" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/stmcginnis/gofish/common" + "github.com/stmcginnis/gofish/redfish" +) + +func (c *Client) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { + systems, err := c.Systems() + if err != nil { + return nil, err + } + + biosConfig = make(map[string]string) + for _, sys := range systems { + if !c.compatibleOdataID(sys.ODataID, knownSystemsOdataIDs) { + continue + } + + bios, err := sys.Bios() + if err != nil { + return nil, err + } + + if bios == nil { + return nil, bmclibErrs.ErrNoBiosAttributes + } + + for attr := range bios.Attributes { + biosConfig[attr] = bios.Attributes.String(attr) + } + } + + return biosConfig, nil +} + +func (c *Client) SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) { + systems, err := c.Systems() + if err != nil { + return err + } + + settingsAttributes := make(redfish.SettingsAttributes) + + for attr, value := range biosConfig { + settingsAttributes[attr] = value + } + + for _, sys := range systems { + if !c.compatibleOdataID(sys.ODataID, knownSystemsOdataIDs) { + continue + } + + bios, err := sys.Bios() + if err != nil { + return err + } + + // TODO(jwb) We should handle passing different apply times here + err = bios.UpdateBiosAttributesApplyAt(settingsAttributes, common.OnResetApplyTime) + + if err != nil { + return err + } + } + + return nil +} + +func (c *Client) ResetBiosConfiguration(ctx context.Context) (err error) { + systems, err := c.Systems() + if err != nil { + return err + } + + for _, sys := range systems { + if !c.compatibleOdataID(sys.ODataID, knownSystemsOdataIDs) { + continue + } + + bios, err := sys.Bios() + if err != nil { + return err + } + + err = bios.ResetBios() + + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/redfishwrapper/bios_test.go b/internal/redfishwrapper/bios_test.go new file mode 100644 index 000000000..643ebda22 --- /dev/null +++ b/internal/redfishwrapper/bios_test.go @@ -0,0 +1,94 @@ +package redfishwrapper + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func biosConfigFromFixture(t *testing.T) map[string]string { + t.Helper() + + fixturePath := fixturesDir + "/dell/bios.json" + fh, err := os.Open(fixturePath) + if err != nil { + t.Fatalf("%s, failed to open fixture: %s", err.Error(), fixturePath) + } + + defer fh.Close() + + b, err := io.ReadAll(fh) + if err != nil { + t.Fatalf("%s, failed to read fixture: %s", err.Error(), fixturePath) + } + + var bios map[string]any + err = json.Unmarshal([]byte(b), &bios) + if err != nil { + t.Fatalf("%s, failed to unmarshal fixture: %s", err.Error(), fixturePath) + } + + expectedBiosConfig := make(map[string]string) + for k, v := range bios["Attributes"].(map[string]any) { + expectedBiosConfig[k] = fmt.Sprintf("%v", v) + } + + return expectedBiosConfig +} + +func TestGetBiosConfiguration(t *testing.T) { + tests := []struct { + testName string + hfunc map[string]func(http.ResponseWriter, *http.Request) + expectedBiosConfig map[string]string + }{ + { + "GetBiosConfiguration", + map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "/dell/serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "/dell/systems.json"), + "/redfish/v1/Systems/System.Embedded.1": endpointFunc(t, "/dell/system.embedded.1.json"), + "/redfish/v1/Systems/System.Embedded.1/Bios": endpointFunc(t, "/dell/bios.json"), + }, + biosConfigFromFixture(t), + }, + } + + for _, tc := range tests { + t.Run(tc.testName, 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) + } + + biosConfig, err := client.GetBiosConfiguration(ctx) + assert.Nil(t, err) + assert.Equal(t, tc.expectedBiosConfig, biosConfig) + }) + } +} diff --git a/internal/redfishwrapper/boot_device.go b/internal/redfishwrapper/boot_device.go index 630d55ad4..91b7b5fd9 100644 --- a/internal/redfishwrapper/boot_device.go +++ b/internal/redfishwrapper/boot_device.go @@ -3,18 +3,93 @@ package redfishwrapper import ( "context" + "github.com/bmc-toolbox/bmclib/v2/bmc" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/pkg/errors" rf "github.com/stmcginnis/gofish/redfish" ) -// Set the boot device for the system. -func (c *Client) SystemBootDeviceSet(ctx context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { +type bootDeviceMapping struct { + BootDeviceType bmc.BootDeviceType + RedFishTarget rf.BootSourceOverrideTarget +} + +var bootDeviceTypeMappings = []bootDeviceMapping{ + { + BootDeviceType: bmc.BootDeviceTypeBIOS, + RedFishTarget: rf.BiosSetupBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeCDROM, + RedFishTarget: rf.CdBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeDiag, + RedFishTarget: rf.DiagsBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeFloppy, + RedFishTarget: rf.FloppyBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeDisk, + RedFishTarget: rf.HddBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeNone, + RedFishTarget: rf.NoneBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypePXE, + RedFishTarget: rf.PxeBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeRemoteDrive, + RedFishTarget: rf.RemoteDriveBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeSDCard, + RedFishTarget: rf.SDCardBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeUSB, + RedFishTarget: rf.UsbBootSourceOverrideTarget, + }, + { + BootDeviceType: bmc.BootDeviceTypeUtil, + RedFishTarget: rf.UtilitiesBootSourceOverrideTarget, + }, +} + +// bootDeviceStringToTarget gets the RedFish BootSourceOverrideTarget that corresponds to the given device string, +// or an error if the device is not a RedFish BootSourceOverrideTarget. +func bootDeviceStringToTarget(device string) (rf.BootSourceOverrideTarget, error) { + for _, bootDevice := range bootDeviceTypeMappings { + if string(bootDevice.BootDeviceType) == device { + return bootDevice.RedFishTarget, nil + } + } + return "", errors.New("invalid boot device") +} + +// bootTargetToBootDeviceType converts the redfish boot target to a bmc.BootDeviceType. +// if the target is unknown or unsupported, then an error is returned. +func bootTargetToBootDeviceType(target rf.BootSourceOverrideTarget) (bmc.BootDeviceType, error) { + for _, bootDevice := range bootDeviceTypeMappings { + if bootDevice.RedFishTarget == target { + return bootDevice.BootDeviceType, nil + } + } + return "", errors.New("invalid boot device") +} + +// SystemBootDeviceSet set the boot device for the system. +func (c *Client) SystemBootDeviceSet(_ context.Context, bootDevice string, setPersistent, efiBoot bool) (ok bool, err error) { if err := c.SessionActive(); err != nil { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - systems, err := c.client.Service.Systems() + systems, err := c.Systems() if err != nil { return false, err } @@ -22,31 +97,9 @@ func (c *Client) SystemBootDeviceSet(ctx context.Context, bootDevice string, set for _, system := range systems { boot := system.Boot - switch bootDevice { - case "bios": - boot.BootSourceOverrideTarget = rf.BiosSetupBootSourceOverrideTarget - case "cdrom": - boot.BootSourceOverrideTarget = rf.CdBootSourceOverrideTarget - case "diag": - boot.BootSourceOverrideTarget = rf.DiagsBootSourceOverrideTarget - case "floppy": - boot.BootSourceOverrideTarget = rf.FloppyBootSourceOverrideTarget - case "disk": - boot.BootSourceOverrideTarget = rf.HddBootSourceOverrideTarget - case "none": - boot.BootSourceOverrideTarget = rf.NoneBootSourceOverrideTarget - case "pxe": - boot.BootSourceOverrideTarget = rf.PxeBootSourceOverrideTarget - case "remote_drive": - boot.BootSourceOverrideTarget = rf.RemoteDriveBootSourceOverrideTarget - case "sd_card": - boot.BootSourceOverrideTarget = rf.SDCardBootSourceOverrideTarget - case "usb": - boot.BootSourceOverrideTarget = rf.UsbBootSourceOverrideTarget - case "utilities": - boot.BootSourceOverrideTarget = rf.UtilitiesBootSourceOverrideTarget - default: - return false, errors.New("invalid boot device") + boot.BootSourceOverrideTarget, err = bootDeviceStringToTarget(bootDevice) + if err != nil { + return false, err } if setPersistent { @@ -76,3 +129,37 @@ func (c *Client) SystemBootDeviceSet(ctx context.Context, bootDevice string, set return true, nil } + +// GetBootDeviceOverride returns the current boot override settings +func (c *Client) GetBootDeviceOverride(_ context.Context) (override bmc.BootDeviceOverride, err error) { + if err := c.SessionActive(); err != nil { + return override, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + systems, err := c.Systems() + if err != nil { + return override, err + } + + for _, system := range systems { + if system == nil { + continue + } + + boot := system.Boot + bootDevice, err := bootTargetToBootDeviceType(boot.BootSourceOverrideTarget) + if err != nil { + return override, err + } + + override = bmc.BootDeviceOverride{ + IsPersistent: boot.BootSourceOverrideEnabled == rf.ContinuousBootSourceOverrideEnabled, + IsEFIBoot: boot.BootSourceOverrideMode == rf.UEFIBootSourceOverrideMode, + Device: bootDevice, + } + + return override, nil + } + + return override, bmclibErrs.ErrRedfishNoSystems +} diff --git a/internal/redfishwrapper/client.go b/internal/redfishwrapper/client.go index f600631cd..2172eff68 100644 --- a/internal/redfishwrapper/client.go +++ b/internal/redfishwrapper/client.go @@ -3,30 +3,42 @@ package redfishwrapper import ( "context" "crypto/x509" + "fmt" "io" "net/http" "os" + "strconv" "strings" "time" 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 port string user string pass string + systemName string basicAuth bool + disableEtagMatch bool versionsNotCompatible []string // a slice of redfish versions to ignore as incompatible client *gofish.APIClient httpClient *http.Client httpClientSetupFuncs []func(*http.Client) + logger logr.Logger } // Option is a function applied to a *Conn @@ -63,6 +75,28 @@ func WithBasicAuthEnabled(e bool) Option { } } +// WithEtagMatchDisabled disables the If-Match Etag header from being included by the Gofish driver. +// +// As of the current implementation this disables the header for POST/PATCH requests to the System entity endpoints. +func WithEtagMatchDisabled(d bool) Option { + return func(c *Client) { + c.disableEtagMatch = d + } +} + +// WithLogger sets the logger on the redfish wrapper client +func WithLogger(l *logr.Logger) Option { + return func(c *Client) { + c.logger = *l + } +} + +func WithSystemName(name string) Option { + return func(c *Client) { + c.systemName = name + } +} + // NewClient returns a redfishwrapper client func NewClient(host, port, user, pass string, opts ...Option) *Client { if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") { @@ -74,6 +108,7 @@ func NewClient(host, port, user, pass string, opts ...Option) *Client { port: port, user: user, pass: pass, + logger: logr.Discard(), versionsNotCompatible: []string{}, } @@ -201,6 +236,61 @@ func (c *Client) VersionCompatible() bool { return !slices.Contains(c.versionsNotCompatible, c.client.Service.RedfishVersion) } +// redfishVersionMeetsOrExceeds compares this connection's redfish version to what is provided +// as a requirement. We rely on the stated structure of the version string as described in the +// Protocol Version (section 6.6) of the Redfish spec. If an implementation's version string is +// non-conforming this function returns false. +func redfishVersionMeetsOrExceeds(version string, major, minor, patch int) bool { + if version == "" { + return false + } + + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + + var rfVer []int64 + for _, part := range parts { + ver, err := strconv.ParseInt(part, 10, 32) + if err != nil { + return false + } + rfVer = append(rfVer, ver) + } + + if rfVer[0] < int64(major) { + return false + } + + if rfVer[1] < int64(minor) { + return false + } + + return rfVer[2] >= int64(patch) +} + +func (c *Client) GetBootProgress() ([]*redfish.BootProgress, error) { + // The redfish standard adopts the BootProgress object in 1.13.0. Earlier versions of redfish return + // json NULL, which gofish turns into a zero-value object of BootProgress. We gate this on the RedfishVersion + // to avoid the complexity of interpreting whether a given value is legitimate. + if !redfishVersionMeetsOrExceeds(c.client.Service.RedfishVersion, 1, 13, 0) { + return nil, fmt.Errorf("%w: %s", bmclibErrs.ErrRedfishVersionIncompatible, c.client.Service.RedfishVersion) + } + + systems, err := c.client.Service.Systems() + if err != nil { + return nil, fmt.Errorf("retrieving redfish systems collection: %w", err) + } + + bps := []*redfish.BootProgress{} + for _, sys := range systems { + bps = append(bps, &sys.BootProgress) + } + + return bps, nil +} + func (c *Client) PostWithHeaders(ctx context.Context, url string, payload interface{}, headers map[string]string) (*http.Response, error) { return c.client.PostWithHeaders(url, payload, headers) } @@ -208,3 +298,60 @@ func (c *Client) PostWithHeaders(ctx context.Context, url string, payload interf func (c *Client) PatchWithHeaders(ctx context.Context, url string, payload interface{}, headers map[string]string) (*http.Response, error) { return c.client.PatchWithHeaders(url, payload, headers) } + +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 b0923816a..40190753e 100644 --- a/internal/redfishwrapper/client_test.go +++ b/internal/redfishwrapper/client_test.go @@ -1,8 +1,14 @@ package redfishwrapper import ( + "context" + "net/http" + "net/http/httptest" + "net/url" "testing" + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/stmcginnis/gofish/redfish" "github.com/stretchr/testify/assert" ) @@ -57,3 +63,281 @@ func TestWithBasicAuthEnabled(t *testing.T) { }) } } + +func TestWithEtagMatchDisabled(t *testing.T) { + host := "127.0.0.1" + user := "ADMIN" + pass := "ADMIN" + + tests := []struct { + name string + disabled bool + }{ + { + "disabled", + true, + }, + { + "enabled", + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(host, "", user, pass, WithEtagMatchDisabled(tt.disabled)) + assert.Equal(t, tt.disabled, client.disableEtagMatch) + }) + } +} + +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()) + }) + } +} + +func TestRedfishVersionMeetsOrExceeds(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + version string + exp bool + }{ + { + "empty string", + "", + false, + }, + { + "short string", + "1.2", + false, + }, + { + "bogus component", + "1.asdf.2", + false, + }, + { + "major too low", + "0.3.4", + false, + }, + { + "minor too low", + "1.1.3", + false, + }, + { + "patch too low", + "1.2.2", + false, + }, + { + "meets", + "1.2.3", + true, + }, + { + "exceeds", + "1.2.4", + true, + }, + } + + for _, tc := range testCases { + got := redfishVersionMeetsOrExceeds(tc.version, 1, 2, 3) + assert.Equal(t, tc.exp, got, "testcase %s", tc.name) + } +} + +func TestGetBootProgress(t *testing.T) { + tests := map[string]struct { + hfunc map[string]func(http.ResponseWriter, *http.Request) + expect []*redfish.BootProgress + err error + }{ + "happy case": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + // service root + "/redfish/v1/": endpointFunc(t, "smc_1.14.0_serviceroot.json"), + "/redfish/v1/Systems": endpointFunc(t, "smc_1.14.0_systems.json"), + "/redfish/v1/Systems/1": endpointFunc(t, "smc_1.14.0_systems_1.json"), + }, + expect: []*redfish.BootProgress{ + &redfish.BootProgress{ + LastState: redfish.SystemHardwareInitializationCompleteBootProgressTypes, + }, + }, + err: nil, + }, + "insufficient redfish version": { + hfunc: map[string]func(http.ResponseWriter, *http.Request){ + "/redfish/v1/": endpointFunc(t, "smc_1.9.0_serviceroot.json"), + }, + expect: nil, + err: bmclibErrs.ErrRedfishVersionIncompatible, + }, + } + + 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) + } + + client := NewClient(parsedURL.Hostname(), parsedURL.Port(), "", "") + + err = client.Open(context.TODO()) + if err != nil { + t.Fatal(err) + } + defer client.Close(context.TODO()) + + got, err := client.GetBootProgress() + if err != nil { + assert.ErrorIs(t, err, tc.err) + return + } + + assert.ElementsMatch(t, tc.expect, got) + }) + } + +} diff --git a/internal/redfishwrapper/firmware.go b/internal/redfishwrapper/firmware.go new file mode 100644 index 000000000..aed6e3fed --- /dev/null +++ b/internal/redfishwrapper/firmware.go @@ -0,0 +1,455 @@ +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/stmcginnis/gofish/redfish" + + "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 ( + // the URI for starting a firmware update via StartUpdate is defined in the Redfish Resource and + // Schema Guide (2024.1) + startUpdateURI = "/redfish/v1/UpdateService/Actions/UpdateService.StartUpdate" +) + +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() + 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(installURI, updateFile, parameters) + if uploadErr != nil { + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, uploadErr.Error()) + } + + case unstructuredHttpPush: + var uploadErr error + resp, uploadErr = c.unstructuredHttpUpload(installURI, updateFile) + 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) + } + + rfTask := &redfish.Task{} + if err := rfTask.UnmarshalJSON(response); err != nil { + // we got invalid JSON + return "", fmt.Errorf("unmarshaling redfish response: %w", err) + } + // it's possible to get well-formed JSON that isn't a Task (thanks SMC). Test that we have something sensible. + if strings.Contains(rfTask.ODataType, "Task") { + return rfTask.ID, nil + } + + 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 the hard way. We do this to get back the task object from the response body so that + // we can parse the task id out of it. + resp, err := updateService.GetClient().PostWithHeaders(startUpdateURI, 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) + } + + rfTask := &redfish.Task{} + if err := rfTask.UnmarshalJSON(response); err != nil { + // we got invalid JSON + return "", fmt.Errorf("unmarshaling redfish response: %w", err) + } + if strings.Contains(rfTask.ODataType, "Task") { + return rfTask.ID, nil + } + + 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 { + // 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(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(url string, update io.Reader) (*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() (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 000000000..5d8c70336 --- /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() + 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: "JID_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/providers/redfish/fixtures/v1/dell/bios.json b/internal/redfishwrapper/fixtures/dell/bios.json similarity index 100% rename from providers/redfish/fixtures/v1/dell/bios.json rename to internal/redfishwrapper/fixtures/dell/bios.json diff --git a/internal/redfishwrapper/fixtures/dell/serviceroot.json b/internal/redfishwrapper/fixtures/dell/serviceroot.json new file mode 100644 index 000000000..4bd38c6f5 --- /dev/null +++ b/internal/redfishwrapper/fixtures/dell/serviceroot.json @@ -0,0 +1,80 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ServiceRoot.ServiceRoot", + "@odata.id": "/redfish/v1", + "@odata.type": "#ServiceRoot.v1_6_0.ServiceRoot", + "AccountService": { + "@odata.id": "/redfish/v1/AccountService" + }, + "CertificateService": { + "@odata.id": "/redfish/v1/CertificateService" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "Description": "Root Service", + "EventService": { + "@odata.id": "/redfish/v1/EventService" + }, + "Fabrics": { + "@odata.id": "/redfish/v1/Fabrics" + }, + "Id": "RootService", + "JobService": { + "@odata.id": "/redfish/v1/JobService" + }, + "JsonSchemas": { + "@odata.id": "/redfish/v1/JsonSchemas" + }, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + }, + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Name": "Root Service", + "Oem": { + "Dell": { + "@odata.context": "/redfish/v1/$metadata#DellServiceRoot.DellServiceRoot", + "@odata.type": "#DellServiceRoot.v1_0_0.DellServiceRoot", + "IsBranded": 0, + "ManagerMACAddress": "d0:8e:79:bb:3e:ea", + "ServiceTag": "FOOBAR" + } + }, + "Product": "Integrated Dell Remote Access Controller", + "ProtocolFeaturesSupported": { + "ExcerptQuery": false, + "ExpandQuery": { + "ExpandAll": true, + "Levels": true, + "Links": true, + "MaxLevels": 1, + "NoLinks": true + }, + "FilterQuery": true, + "OnlyMemberQuery": true, + "SelectQuery": true + }, + "RedfishVersion": "1.9.0", + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService" + }, + "TelemetryService": { + "@odata.id": "/redfish/v1/TelemetryService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + }, + "Vendor": "Dell" +} \ No newline at end of file diff --git a/providers/redfish/fixtures/v1/dell/system.embedded.1.json b/internal/redfishwrapper/fixtures/dell/system.embedded.1.json similarity index 100% rename from providers/redfish/fixtures/v1/dell/system.embedded.1.json rename to internal/redfishwrapper/fixtures/dell/system.embedded.1.json diff --git a/internal/redfishwrapper/fixtures/dell/systems.json b/internal/redfishwrapper/fixtures/dell/systems.json new file mode 100644 index 000000000..1611fec88 --- /dev/null +++ b/internal/redfishwrapper/fixtures/dell/systems.json @@ -0,0 +1,13 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ComputerSystemCollection.ComputerSystemCollection", + "@odata.id": "/redfish/v1/Systems", + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "Description": "Collection of Computer Systems", + "Members": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1" + } + ], + "Members@odata.count": 1, + "Name": "Computer System Collection" +} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/managers.json b/internal/redfishwrapper/fixtures/managers.json new file mode 100644 index 000000000..e99f8a37c --- /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 000000000..6eda42a44 --- /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 000000000..110780829 --- /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 000000000..cec2bf4f3 --- /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/smc_1.14.0_serviceroot.json b/internal/redfishwrapper/fixtures/smc_1.14.0_serviceroot.json new file mode 100644 index 000000000..14c2af1e8 --- /dev/null +++ b/internal/redfishwrapper/fixtures/smc_1.14.0_serviceroot.json @@ -0,0 +1 @@ +{"@odata.type":"#ServiceRoot.v1_14_0.ServiceRoot","@odata.id":"/redfish/v1","Id":"ServiceRoot","Name":"Root Service","RedfishVersion":"1.14.0","UUID":"00000000-0000-0000-0000-3CECEFC84895","Vendor":"Supermicro","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"},"Product":null,"ServiceIdentification":"S482931X2814218","Links":{"Sessions":{"@odata.id":"/redfish/v1/SessionService/Sessions"}},"Oem":{"Supermicro":{"DumpService":{"@odata.id":"/redfish/v1/Oem/Supermicro/DumpService"}}},"ProtocolFeaturesSupported":{"FilterQuery":true,"SelectQuery":true,"ExcerptQuery":false,"OnlyMemberQuery":false,"DeepOperations":{"DeepPATCH":false,"DeepPOST":false,"MaxLevels":1},"ExpandQuery":{"Links":true,"NoLinks":true,"ExpandAll":true,"Levels":true,"MaxLevels":2}},"@odata.etag":"\"a3ee7c2898ae386781519de584c4dacd\""} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/smc_1.14.0_systems.json b/internal/redfishwrapper/fixtures/smc_1.14.0_systems.json new file mode 100644 index 000000000..f25f65dd6 --- /dev/null +++ b/internal/redfishwrapper/fixtures/smc_1.14.0_systems.json @@ -0,0 +1 @@ +{"@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"}],"@odata.etag":"\"e310554bb25b657853dd0b5f36f07991\""} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/smc_1.14.0_systems_1.json b/internal/redfishwrapper/fixtures/smc_1.14.0_systems_1.json new file mode 100644 index 000000000..a508f9ca2 --- /dev/null +++ b/internal/redfishwrapper/fixtures/smc_1.14.0_systems_1.json @@ -0,0 +1 @@ +{"@odata.type":"#ComputerSystem.v1_16_0.ComputerSystem","@odata.id":"/redfish/v1/Systems/1","Id":"1","Name":"System","Description":"Description of server","Status":{"State":"Enabled","Health":"Critical"},"SerialNumber":"S482931X2814218","PartNumber":"SYS-510T-MR-EI018","AssetTag":null,"IndicatorLED":"Off","LocationIndicatorActive":false,"SystemType":"Physical","BiosVersion":"2.0","Manufacturer":"Supermicro","Model":"SYS-510T-MR-EI018","SKU":"To be filled by O.E.M.","UUID":"B11CC600-6D10-11EC-8000-3CECEFC846F8","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"}},"PowerState":"On","PowerOnDelaySeconds":3,"PowerOnDelaySeconds@Redfish.AllowableNumbers":["3:254:1"],"PowerOffDelaySeconds":3,"PowerOffDelaySeconds@Redfish.AllowableNumbers":["3:254:1"],"PowerCycleDelaySeconds":5,"PowerCycleDelaySeconds@Redfish.AllowableNumbers":["5:254:1"],"Boot":{"AutomaticRetryConfig":"Disabled","BootSourceOverrideEnabled":"Continuous","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":null,"BootOrder":["Boot0003","Boot0004","Boot0005","Boot0006","Boot0007","Boot0008","Boot0009","Boot000A","Boot000B","Boot0002"]},"GraphicalConsole":{"ServiceEnabled":true,"Port":5900,"MaxConcurrentSessions":4,"ConnectTypesSupported":["KVMIP"]},"SerialConsole":{"MaxConcurrentSessions":1,"SSH":{"ServiceEnabled":true,"Port":22,"SharedWithManagerCLI":true,"ConsoleEntryCommand":"cd system1/sol1; start","HotKeySequenceDisplay":"press , , and then to terminate session"},"IPMI":{"HotKeySequenceDisplay":"Press ~. - terminate connection","ServiceEnabled":true,"Port":623}},"VirtualMediaConfig":{"ServiceEnabled":true,"Port":623},"BootProgress":{"OemLastState":null,"LastState":"SystemHardwareInitializationComplete"},"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"},"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"},"VirtualMedia":{"@odata.id":"/redfish/v1/Managers/1/VirtualMedia"},"Links":{"Chassis":[{"@odata.id":"/redfish/v1/Chassis/1"}],"ManagedBy":[{"@odata.id":"/redfish/v1/Managers/1"}],"PoweredBy":[{"@odata.id":"/redfish/v1/Chassis/1/PowerSubsystem/PowerSupplies/1"},{"@odata.id":"/redfish/v1/Chassis/1/PowerSubsystem/PowerSupplies/2"}]},"Actions":{"Oem":{},"#ComputerSystem.Reset":{"target":"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset","@Redfish.ActionInfo":"/redfish/v1/Systems/1/ResetActionInfo","ResetType@Redfish.AllowableValues":["On","ForceOff","GracefulShutdownGracefulRestart","ForceRestart","Nmi","ForceOn"]}},"Oem":{"Supermicro":{"@odata.type":"#SmcSystemExtensions.v1_0_0.System","NodeManager":{"@odata.id":"/redfish/v1/Systems/1/Oem/Supermicro/NodeManager"}}},"@odata.etag":"\"27ffd39c216000b3013c84008394dffd\""} \ No newline at end of file diff --git a/internal/redfishwrapper/fixtures/smc_1.9.0_serviceroot.json b/internal/redfishwrapper/fixtures/smc_1.9.0_serviceroot.json new file mode 100644 index 000000000..7c22b3267 --- /dev/null +++ b/internal/redfishwrapper/fixtures/smc_1.9.0_serviceroot.json @@ -0,0 +1 @@ +{"@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-3CECEFC8484F","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"}},"Oem":{"Supermicro":{"DumpService":{"@odata.id":"/redfish/v1/Oem/Supermicro/DumpService"}}},"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 000000000..7bf9aa0b8 --- /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 000000000..3b28cf031 --- /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 000000000..3b28cf031 --- /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 000000000..6e5cec190 --- /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 000000000..5e05607fc --- /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 000000000..0c2d24c5b --- /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 000000000..b735b319a --- /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 000000000..22d777fcd --- /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 000000000..cd18091c7 --- /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 000000000..75fc9bc0a --- /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 000000000..03c834102 --- /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 000000000..e67830ad2 --- /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 000000000..65c8c5aad --- /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 000000000..f6dbf9253 --- /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 000000000..99181eb21 --- /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 000000000..e245e3406 --- /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 000000000..4cf2293f3 --- /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 000000000..514cb68ec --- /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 000000000..f946cfa5d --- /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/providers/redfish/inventory.go b/internal/redfishwrapper/inventory.go similarity index 63% rename from providers/redfish/inventory.go rename to internal/redfishwrapper/inventory.go index 614ddeae1..7d4076aad 100644 --- a/providers/redfish/inventory.go +++ b/internal/redfishwrapper/inventory.go @@ -1,73 +1,80 @@ -package redfish +package redfishwrapper import ( "context" "strings" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" "github.com/pkg/errors" "github.com/bmc-toolbox/common" - gofishrf "github.com/stmcginnis/gofish/redfish" + redfish "github.com/stmcginnis/gofish/redfish" ) var ( // Supported Chassis Odata IDs - chassisOdataIDs = []string{ + KnownChassisOdataIDs = []string{ // Dells "/redfish/v1/Chassis/Enclosure.Internal.0-1", "/redfish/v1/Chassis/System.Embedded.1", "/redfish/v1/Chassis/Enclosure.Internal.0-1:NonRAID.Integrated.1-1", // Supermicro "/redfish/v1/Chassis/1", - // MegaRAC + // MegaRAC/ARockRack "/redfish/v1/Chassis/Self", // OpenBMC on ASRock "/redfish/v1/Chassis/ASRock_ROMED8HM3", } // Supported System Odata IDs - systemsOdataIDs = []string{ + knownSystemsOdataIDs = []string{ // Dells "/redfish/v1/Systems/System.Embedded.1", "/redfish/v1/Systems/System.Embedded.1/Bios", // Supermicros "/redfish/v1/Systems/1", + // MegaRAC/ARockRack + "/redfish/v1/Systems/Self", // OpenBMC on ASRock "/redfish/v1/Systems/system", } // Supported Manager Odata IDs (BMCs) managerOdataIDs = []string{ + // Dells "/redfish/v1/Managers/iDRAC.Embedded.1", // Supermicros "/redfish/v1/Managers/1", + // MegaRAC/ARockRack + "/redfish/v1/Managers/Self", // OpenBMC on ASRock "/redfish/v1/Managers/bmc", } ) -// inventory struct wraps redfish connection -type inventory struct { - client *redfishwrapper.Client - failOnError bool - softwareInventory []*gofishrf.SoftwareInventory -} +// TODO: consider removing this +func (c *Client) compatibleOdataID(OdataID string, knownOdataIDs []string) bool { + for _, url := range knownOdataIDs { + if url == OdataID { + return true + } + } -func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) { - // initialize inventory object - // the redfish client is assigned here to perform redfish Get/Delete requests - inv := &inventory{client: c.redfishwrapper, failOnError: c.failInventoryOnError} + return false +} - updateService, err := c.redfishwrapper.UpdateService() - if err != nil && inv.failOnError { +func (c *Client) Inventory(ctx context.Context, failOnError bool) (device *common.Device, err error) { + updateService, err := c.UpdateService() + if err != nil && failOnError { return nil, errors.Wrap(bmclibErrs.ErrRedfishSoftwareInventory, err.Error()) } + softwareInventory := []*redfish.SoftwareInventory{} + if updateService != nil { - inv.softwareInventory, err = updateService.FirmwareInventories() - if err != nil && inv.failOnError { + // nolint + softwareInventory, err = updateService.FirmwareInventories() + if err != nil && failOnError { return nil, errors.Wrap(bmclibErrs.ErrRedfishSoftwareInventory, err.Error()) } } @@ -77,20 +84,20 @@ func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) device = &newDevice // populate device Chassis components attributes - err = inv.chassisAttributes(ctx, device) - if err != nil && inv.failOnError { + err = c.chassisAttributes(ctx, device, failOnError, softwareInventory) + if err != nil && failOnError { return nil, err } // populate device System components attributes - err = inv.systemAttributes(ctx, device) - if err != nil && inv.failOnError { + err = c.systemAttributes(device, failOnError, softwareInventory) + if err != nil && failOnError { return nil, err } // populate device BMC component attributes - err = inv.bmcAttributes(ctx, device) - if err != nil && inv.failOnError { + err = c.bmcAttributes(ctx, device, softwareInventory) + if err != nil && failOnError { return nil, err } @@ -100,15 +107,15 @@ func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) // DeviceVendorModel returns the device vendor and model attributes // bmcAttributes collects BMC component attributes -func (i *inventory) bmcAttributes(ctx context.Context, device *common.Device) (err error) { - managers, err := i.client.Managers(ctx) +func (c *Client) bmcAttributes(ctx context.Context, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { + managers, err := c.Managers(ctx) if err != nil { return err } var compatible int for _, manager := range managers { - if !compatibleOdataID(manager.ODataID, managerOdataIDs) { + if !c.compatibleOdataID(manager.ODataID, managerOdataIDs) { continue } @@ -136,7 +143,7 @@ func (i *inventory) bmcAttributes(ctx context.Context, device *common.Device) (e } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes("", device.BMC.ID, device.BMC.Firmware) + c.firmwareAttributes("", device.BMC.ID, device.BMC.Firmware, softwareInventory) } if compatible == 0 { @@ -147,34 +154,34 @@ func (i *inventory) bmcAttributes(ctx context.Context, device *common.Device) (e } // chassisAttributes populates the device chassis attributes -func (i *inventory) chassisAttributes(ctx context.Context, device *common.Device) (err error) { - chassis, err := i.client.Chassis(ctx) +func (c *Client) chassisAttributes(ctx context.Context, device *common.Device, failOnError bool, softwareInventory []*redfish.SoftwareInventory) (err error) { + chassis, err := c.Chassis(ctx) if err != nil { return err } compatible := 0 for _, ch := range chassis { - if !compatibleOdataID(ch.ODataID, chassisOdataIDs) { + if !c.compatibleOdataID(ch.ODataID, KnownChassisOdataIDs) { continue } compatible++ - err = i.collectEnclosure(ch, device) - if err != nil && i.failOnError { + err = c.collectEnclosure(ch, device, softwareInventory) + if err != nil && failOnError { return err } - err = i.collectPSUs(ch, device) - if err != nil && i.failOnError { + err = c.collectPSUs(ch, device, softwareInventory) + if err != nil && failOnError { return err } } - err = i.collectCPLDs(device) - if err != nil && i.failOnError { + err = c.collectCPLDs(device, softwareInventory) + if err != nil && failOnError { return err } @@ -186,15 +193,15 @@ func (i *inventory) chassisAttributes(ctx context.Context, device *common.Device } -func (i *inventory) systemAttributes(ctx context.Context, device *common.Device) (err error) { - systems, err := i.client.Systems() +func (c *Client) systemAttributes(device *common.Device, failOnError bool, softwareInventory []*redfish.SoftwareInventory) (err error) { + systems, err := c.Systems() if err != nil { return err } compatible := 0 for _, sys := range systems { - if !compatibleOdataID(sys.ODataID, systemsOdataIDs) { + if !c.compatibleOdataID(sys.ODataID, knownSystemsOdataIDs) { continue } @@ -206,21 +213,27 @@ func (i *inventory) systemAttributes(ctx context.Context, device *common.Device) device.Serial = sys.SerialNumber } + type collectorFuncs []func( + sys *redfish.ComputerSystem, + device *common.Device, + softwareInventory []*redfish.SoftwareInventory, + ) error + // slice of collector methods - funcs := []func(sys *gofishrf.ComputerSystem, device *common.Device) error{ - i.collectCPUs, - i.collectDIMMs, - i.collectDrives, - i.collectBIOS, - i.collectNICs, - i.collectTPMs, - i.collectStorageControllers, + funcs := collectorFuncs{ + c.collectCPUs, + c.collectDIMMs, + c.collectDrives, + c.collectBIOS, + c.collectNICs, + c.collectTPMs, + c.collectStorageControllers, } // execute collector methods for _, f := range funcs { - err := f(sys, device) - if err != nil && i.failOnError { + err := f(sys, device, softwareInventory) + if err != nil && failOnError { return err } } @@ -240,8 +253,8 @@ func (i *inventory) systemAttributes(ctx context.Context, device *common.Device) // slug - the component slug constant // id - the component ID // previous - when true returns previously installed firmware, else returns the current -func (i *inventory) firmwareAttributes(slug, id string, firmwareObj *common.Firmware) { - if len(i.softwareInventory) == 0 { +func (c *Client) firmwareAttributes(slug, id string, firmwareObj *common.Firmware, softwareInventory []*redfish.SoftwareInventory) { + if len(softwareInventory) == 0 { return } @@ -249,7 +262,7 @@ func (i *inventory) firmwareAttributes(slug, id string, firmwareObj *common.Firm id = slug } - for _, inv := range i.softwareInventory { + for _, inv := range softwareInventory { // include previously installed firmware attributes if strings.HasPrefix(inv.ID, "Previous") { if strings.Contains(inv.ID, id) || strings.EqualFold(slug, inv.Name) { @@ -287,13 +300,3 @@ func (i *inventory) firmwareAttributes(slug, id string, firmwareObj *common.Firm } } } - -func compatibleOdataID(OdataID string, knownOdataIDs []string) bool { - for _, url := range knownOdataIDs { - if url == OdataID { - return true - } - } - - return false -} diff --git a/providers/redfish/inventory_collect.go b/internal/redfishwrapper/inventory_collect.go similarity index 78% rename from providers/redfish/inventory_collect.go rename to internal/redfishwrapper/inventory_collect.go index aad65d3bb..e441638d1 100644 --- a/providers/redfish/inventory_collect.go +++ b/internal/redfishwrapper/inventory_collect.go @@ -1,17 +1,17 @@ -package redfish +package redfishwrapper import ( "math" "strings" "github.com/bmc-toolbox/common" - gofishrf "github.com/stmcginnis/gofish/redfish" + "github.com/stmcginnis/gofish/redfish" ) // defines various inventory collection helper methods // collectEnclosure collects Enclosure information -func (i *inventory) collectEnclosure(ch *gofishrf.Chassis, device *common.Device) (err error) { +func (c *Client) collectEnclosure(ch *redfish.Chassis, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { e := &common.Enclosure{ Common: common.Common{ Description: ch.Description, @@ -34,7 +34,7 @@ func (i *inventory) collectEnclosure(ch *gofishrf.Chassis, device *common.Device } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(common.SlugEnclosure, e.ID, e.Firmware) + c.firmwareAttributes(common.SlugEnclosure, e.ID, e.Firmware, softwareInventory) device.Enclosures = append(device.Enclosures, e) @@ -42,7 +42,7 @@ func (i *inventory) collectEnclosure(ch *gofishrf.Chassis, device *common.Device } // collectPSUs collects Power Supply Unit component information -func (i *inventory) collectPSUs(ch *gofishrf.Chassis, device *common.Device) (err error) { +func (c *Client) collectPSUs(ch *redfish.Chassis, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { power, err := ch.Power() if err != nil { return err @@ -74,7 +74,7 @@ func (i *inventory) collectPSUs(ch *gofishrf.Chassis, device *common.Device) (er } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(common.SlugPSU, psu.ID, p.Firmware) + c.firmwareAttributes(common.SlugPSU, psu.ID, p.Firmware, softwareInventory) device.PSUs = append(device.PSUs, p) @@ -83,7 +83,7 @@ func (i *inventory) collectPSUs(ch *gofishrf.Chassis, device *common.Device) (er } // collectTPMs collects Trusted Platform Module component information -func (i *inventory) collectTPMs(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectTPMs(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { for _, module := range sys.TrustedModules { tpm := &common.TPM{ Common: common.Common{ @@ -100,7 +100,7 @@ func (i *inventory) collectTPMs(sys *gofishrf.ComputerSystem, device *common.Dev } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(common.SlugTPM, "TPM", tpm.Firmware) + c.firmwareAttributes(common.SlugTPM, "TPM", tpm.Firmware, softwareInventory) device.TPMs = append(device.TPMs, tpm) } @@ -109,7 +109,7 @@ func (i *inventory) collectTPMs(sys *gofishrf.ComputerSystem, device *common.Dev } // collectNICs collects network interface component information -func (i *inventory) collectNICs(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectNICs(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { if sys == nil || device == nil { return nil } @@ -163,17 +163,17 @@ func (i *inventory) collectNICs(sys *gofishrf.ComputerSystem, device *common.Dev // populate network ports general data nicPort := &common.NICPort{} - i.collectNetworkPortInfo(nicPort, adapter, networkPort, portFirmwareVersion) + c.collectNetworkPortInfo(nicPort, adapter, networkPort, portFirmwareVersion, softwareInventory) - if networkPort.ActiveLinkTechnology == gofishrf.EthernetLinkNetworkTechnology { + if networkPort.ActiveLinkTechnology == redfish.EthernetLinkNetworkTechnology { // ethernet specific data - i.collectEthernetInfo(nicPort, ethernetInterfaces) + c.collectEthernetInfo(nicPort, ethernetInterfaces) } n.NICPorts = append(n.NICPorts, nicPort) } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(common.SlugNIC, n.ID, n.Firmware) + c.firmwareAttributes(common.SlugNIC, n.ID, n.Firmware, softwareInventory) if len(portFirmwareVersion) > 0 { if n.Firmware == nil { n.Firmware = &common.Firmware{} @@ -187,8 +187,13 @@ func (i *inventory) collectNICs(sys *gofishrf.ComputerSystem, device *common.Dev return nil } -func (i *inventory) collectNetworkPortInfo( - nicPort *common.NICPort, adapter *gofishrf.NetworkAdapter, networkPort *gofishrf.NetworkPort, firmware string) { +func (c *Client) collectNetworkPortInfo( + nicPort *common.NICPort, + adapter *redfish.NetworkAdapter, + networkPort *redfish.NetworkPort, + firmware string, + softwareInventory []*redfish.SoftwareInventory, +) { if adapter != nil { nicPort.Vendor = adapter.Manufacturer @@ -221,7 +226,7 @@ func (i *inventory) collectNetworkPortInfo( } } - i.firmwareAttributes(common.SlugNIC, networkPort.ID, nicPort.Firmware) + c.firmwareAttributes(common.SlugNIC, networkPort.ID, nicPort.Firmware, softwareInventory) } if len(firmware) > 0 { if nicPort.Firmware == nil { @@ -231,7 +236,7 @@ func (i *inventory) collectNetworkPortInfo( } } -func (i *inventory) collectEthernetInfo(nicPort *common.NICPort, ethernetInterfaces []*gofishrf.EthernetInterface) { +func (c *Client) collectEthernetInfo(nicPort *common.NICPort, ethernetInterfaces []*redfish.EthernetInterface) { if nicPort == nil { return } @@ -273,7 +278,7 @@ func (i *inventory) collectEthernetInfo(nicPort *common.NICPort, ethernetInterfa } } -func getFirmwareVersionFromController(controllers []gofishrf.Controllers, portCount int) string { +func getFirmwareVersionFromController(controllers []redfish.Controllers, portCount int) string { for _, controller := range controllers { if controller.ControllerCapabilities.NetworkPortCount == portCount { return controller.FirmwarePackageVersion @@ -282,7 +287,7 @@ func getFirmwareVersionFromController(controllers []gofishrf.Controllers, portCo return "" } -func (i *inventory) collectBIOS(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectBIOS(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { device.BIOS = &common.BIOS{ Common: common.Common{ Firmware: &common.Firmware{ @@ -301,13 +306,13 @@ func (i *inventory) collectBIOS(sys *gofishrf.ComputerSystem, device *common.Dev } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(common.SlugBIOS, "BIOS", device.BIOS.Firmware) + c.firmwareAttributes(common.SlugBIOS, "BIOS", device.BIOS.Firmware, softwareInventory) return nil } // collectDrives collects drive component information -func (i *inventory) collectDrives(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectDrives(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { storage, err := sys.Storage() if err != nil { return err @@ -351,7 +356,7 @@ func (i *inventory) collectDrives(sys *gofishrf.ComputerSystem, device *common.D } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes("Disk", drive.ID, d.Firmware) + c.firmwareAttributes("Disk", drive.ID, d.Firmware, softwareInventory) device.Drives = append(device.Drives, d) @@ -363,7 +368,7 @@ func (i *inventory) collectDrives(sys *gofishrf.ComputerSystem, device *common.D } // collectStorageControllers populates the device with Storage controller component attributes -func (i *inventory) collectStorageControllers(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectStorageControllers(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { storage, err := sys.Storage() if err != nil { return err @@ -372,7 +377,7 @@ func (i *inventory) collectStorageControllers(sys *gofishrf.ComputerSystem, devi for _, member := range storage { for _, controller := range member.StorageControllers { - c := &common.StorageController{ + cs := &common.StorageController{ Common: common.Common{ Description: controller.Name, Vendor: common.FormatVendorName(controller.Manufacturer), @@ -392,23 +397,22 @@ func (i *inventory) collectStorageControllers(sys *gofishrf.ComputerSystem, devi } // In some cases the storage controller model number is present in the Name field - if strings.TrimSpace(c.Model) == "" && strings.TrimSpace(controller.Name) != "" { - c.Model = controller.Name + if strings.TrimSpace(cs.Model) == "" && strings.TrimSpace(controller.Name) != "" { + cs.Model = controller.Name } // include additional firmware attributes from redfish firmware inventory - i.firmwareAttributes(c.Description, c.ID, c.Firmware) + c.firmwareAttributes(cs.Description, cs.ID, cs.Firmware, softwareInventory) - device.StorageControllers = append(device.StorageControllers, c) + device.StorageControllers = append(device.StorageControllers, cs) } - } return nil } // collectCPUs populates the device with CPU component attributes -func (i *inventory) collectCPUs(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectCPUs(sys *redfish.ComputerSystem, device *common.Device, _ []*redfish.SoftwareInventory) (err error) { procs, err := sys.Processors() if err != nil { return err @@ -447,7 +451,7 @@ func (i *inventory) collectCPUs(sys *gofishrf.ComputerSystem, device *common.Dev } // collectDIMMs populates the device with memory component attributes -func (i *inventory) collectDIMMs(sys *gofishrf.ComputerSystem, device *common.Device) (err error) { +func (c *Client) collectDIMMs(sys *redfish.ComputerSystem, device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { dimms, err := sys.Memory() if err != nil { return err @@ -479,7 +483,7 @@ func (i *inventory) collectDIMMs(sys *gofishrf.ComputerSystem, device *common.De } // collecCPLDs populates the device with CPLD component attributes -func (i *inventory) collectCPLDs(device *common.Device) (err error) { +func (c *Client) collectCPLDs(device *common.Device, softwareInventory []*redfish.SoftwareInventory) (err error) { cpld := &common.CPLD{ Common: common.Common{ @@ -489,7 +493,7 @@ func (i *inventory) collectCPLDs(device *common.Device) (err error) { }, } - i.firmwareAttributes(common.SlugCPLD, "", cpld.Firmware) + c.firmwareAttributes(common.SlugCPLD, "", cpld.Firmware, softwareInventory) name, exists := cpld.Firmware.Metadata["name"] if exists { cpld.Description = name diff --git a/providers/redfish/inventory_collect_test.go b/internal/redfishwrapper/inventory_collect_test.go similarity index 86% rename from providers/redfish/inventory_collect_test.go rename to internal/redfishwrapper/inventory_collect_test.go index 766cf3127..35f95d5bb 100644 --- a/providers/redfish/inventory_collect_test.go +++ b/internal/redfishwrapper/inventory_collect_test.go @@ -1,20 +1,20 @@ -package redfish +package redfishwrapper import ( - "github.com/bmc-toolbox/common" - common2 "github.com/stmcginnis/gofish/common" - gofishrf "github.com/stmcginnis/gofish/redfish" "reflect" "testing" -) -func Test_inventory_collectNetworkPortInfo(t *testing.T) { + "github.com/bmc-toolbox/common" + common2 "github.com/stmcginnis/gofish/common" + redfish "github.com/stmcginnis/gofish/redfish" +) - testAdapter := &gofishrf.NetworkAdapter{ +func TestInventoryCollectNetworkPortInfo(t *testing.T) { + testAdapter := &redfish.NetworkAdapter{ Manufacturer: "Acme", Model: "Anvil 3000", } - testNetworkPort := &gofishrf.NetworkPort{ + testNetworkPort := &redfish.NetworkPort{ Entity: common2.Entity{ID: "NetworkPort-1"}, Description: "NetworkPort One", VendorID: "Vendor-ID", @@ -67,8 +67,8 @@ func Test_inventory_collectNetworkPortInfo(t *testing.T) { tests := []struct { name string nicPort *common.NICPort - adapter *gofishrf.NetworkAdapter - networkPort *gofishrf.NetworkPort + adapter *redfish.NetworkAdapter + networkPort *redfish.NetworkPort firmware string wantedNicPort *common.NICPort }{ @@ -103,8 +103,8 @@ func Test_inventory_collectNetworkPortInfo(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - i := &inventory{} - i.collectNetworkPortInfo(tt.nicPort, tt.adapter, tt.networkPort, tt.firmware) + c := Client{} + c.collectNetworkPortInfo(tt.nicPort, tt.adapter, tt.networkPort, tt.firmware, []*redfish.SoftwareInventory{}) if !reflect.DeepEqual(tt.nicPort, tt.wantedNicPort) { t.Errorf("collectNetworkPortInfo() gotNicPort = %v, want %v", tt.nicPort, tt.wantedNicPort) } @@ -113,17 +113,17 @@ func Test_inventory_collectNetworkPortInfo(t *testing.T) { } -func Test_inventory_collectEthernetInfo(t *testing.T) { +func TestInventoryCollectEthernetInfo(t *testing.T) { testNicPortID := "test NIC port ID" testEthernetID := "test NIC port ID ethernet" testNicPort := &common.NICPort{ ID: testNicPortID, } - testUnmatchingEthList := []*gofishrf.EthernetInterface{ + testUnmatchingEthList := []*redfish.EthernetInterface{ {Entity: common2.Entity{ID: "other ID"}}, {Entity: common2.Entity{ID: "another one"}}, } - testMatchingEth := &gofishrf.EthernetInterface{ + testMatchingEth := &redfish.EthernetInterface{ Entity: common2.Entity{ID: testEthernetID}, Description: "Ethernet Interface", Status: common2.Status{ @@ -155,12 +155,12 @@ func Test_inventory_collectEthernetInfo(t *testing.T) { tests := []struct { name string nicPort *common.NICPort - ethernetInterfaces []*gofishrf.EthernetInterface + ethernetInterfaces []*redfish.EthernetInterface wantedNicPort *common.NICPort }{ {name: "nil"}, {name: "empty", nicPort: testNicPort, wantedNicPort: testNicPort}, - {name: "empty ethernet list", nicPort: testNicPort, ethernetInterfaces: []*gofishrf.EthernetInterface{}, wantedNicPort: testNicPort}, + {name: "empty ethernet list", nicPort: testNicPort, ethernetInterfaces: []*redfish.EthernetInterface{}, wantedNicPort: testNicPort}, {name: "unmatching ethernet list", nicPort: testNicPort, ethernetInterfaces: testUnmatchingEthList, wantedNicPort: testNicPort}, { name: "full", @@ -170,8 +170,8 @@ func Test_inventory_collectEthernetInfo(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - i := &inventory{} - i.collectEthernetInfo(tt.nicPort, tt.ethernetInterfaces) + c := Client{} + c.collectEthernetInfo(tt.nicPort, tt.ethernetInterfaces) }) } } diff --git a/internal/redfishwrapper/main_test.go b/internal/redfishwrapper/main_test.go new file mode 100644 index 000000000..b1be7d678 --- /dev/null +++ b/internal/redfishwrapper/main_test.go @@ -0,0 +1,44 @@ +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) + return + } + + _, _ = w.Write(mustReadFile(t, file)) + } +} diff --git a/internal/redfishwrapper/power.go b/internal/redfishwrapper/power.go index 9bb22f5c8..f08b6e03b 100644 --- a/internal/redfishwrapper/power.go +++ b/internal/redfishwrapper/power.go @@ -11,13 +11,32 @@ import ( rf "github.com/stmcginnis/gofish/redfish" ) +// PowerSet sets the power state of a server +func (c *Client) PowerSet(ctx context.Context, state string) (ok bool, err error) { + // TODO: create consts for the state values + switch strings.ToLower(state) { + case "on": + return c.SystemPowerOn(ctx) + case "off": + return c.SystemForceOff(ctx) + case "soft": + return c.SystemPowerOff(ctx) + case "reset": + return c.SystemReset(ctx) + case "cycle": + return c.SystemPowerCycle(ctx) + default: + return false, errors.New("unknown power action") + } +} + // BMCReset powercycles the BMC. func (c *Client) BMCReset(ctx context.Context, resetType string) (ok bool, err error) { if err := c.SessionActive(); err != nil { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - managers, err := c.client.Service.Managers() + managers, err := c.Managers(ctx) if err != nil { return false, err } @@ -38,8 +57,7 @@ func (c *Client) SystemPowerOn(ctx context.Context) (ok bool, err error) { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return false, err } @@ -48,6 +66,9 @@ func (c *Client) SystemPowerOn(ctx context.Context) (ok bool, err error) { if system.PowerState == rf.OnPowerState { break } + + system.DisableEtagMatch(c.disableEtagMatch) + err = system.Reset(rf.OnResetType) if err != nil { return false, err @@ -62,8 +83,7 @@ func (c *Client) SystemPowerOff(ctx context.Context) (ok bool, err error) { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return false, err } @@ -72,6 +92,9 @@ func (c *Client) SystemPowerOff(ctx context.Context) (ok bool, err error) { if system.PowerState == rf.OffPowerState { break } + + system.DisableEtagMatch(c.disableEtagMatch) + err = system.Reset(rf.GracefulShutdownResetType) if err != nil { return false, err @@ -87,13 +110,13 @@ func (c *Client) SystemReset(ctx context.Context) (ok bool, err error) { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return false, err } for _, system := range ss { + system.DisableEtagMatch(c.disableEtagMatch) err = system.Reset(rf.PowerCycleResetType) if err != nil { @@ -121,8 +144,7 @@ func (c *Client) SystemPowerCycle(ctx context.Context) (ok bool, err error) { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return false, err } @@ -137,6 +159,8 @@ func (c *Client) SystemPowerCycle(ctx context.Context) (ok bool, err error) { } for _, system := range ss { + system.DisableEtagMatch(c.disableEtagMatch) + err = system.Reset(rf.ForceRestartResetType) if err != nil { return false, errors.WithMessage(err, "power cycle failed") @@ -152,8 +176,7 @@ func (c *Client) SystemPowerStatus(ctx context.Context) (result string, err erro return result, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return "", err } @@ -171,8 +194,7 @@ func (c *Client) SystemForceOff(ctx context.Context) (ok bool, err error) { return false, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - service := c.client.Service - ss, err := service.Systems() + ss, err := c.Systems() if err != nil { return false, err } @@ -181,6 +203,9 @@ func (c *Client) SystemForceOff(ctx context.Context) (ok bool, err error) { if system.PowerState == rf.OffPowerState { break } + + system.DisableEtagMatch(c.disableEtagMatch) + err = system.Reset(rf.ForceOffResetType) if err != nil { return false, err @@ -189,3 +214,23 @@ func (c *Client) SystemForceOff(ctx context.Context) (ok bool, err error) { return true, nil } + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Client) SendNMI(_ context.Context) error { + if err := c.SessionActive(); err != nil { + return errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + ss, err := c.Systems() + if err != nil { + return err + } + + for _, system := range ss { + if err = system.Reset(rf.NmiResetType); err != nil { + return err + } + } + + return nil +} diff --git a/internal/redfishwrapper/sel.go b/internal/redfishwrapper/sel.go new file mode 100644 index 000000000..f663de01b --- /dev/null +++ b/internal/redfishwrapper/sel.go @@ -0,0 +1,112 @@ +package redfishwrapper + +import ( + "context" + "encoding/json" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/pkg/errors" + "github.com/stmcginnis/gofish/redfish" +) + +// ClearSystemEventLog clears all of the LogServices logs +func (c *Client) ClearSystemEventLog(ctx context.Context) (err error) { + if err := c.SessionActive(); err != nil { + return errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + chassis, err := c.client.Service.Chassis() + if err != nil { + return err + } + + for _, c := range chassis { + logServices, err := c.LogServices() + if err != nil { + return err + } + + for _, logService := range logServices { + err = logService.ClearLog() + if err != nil { + return err + } + } + } + + return nil +} + +// GetSystemEventLog returns the SystemEventLogEntries +func (c *Client) GetSystemEventLog(ctx context.Context) (entries [][]string, err error) { + if err := c.SessionActive(); err != nil { + return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + managers, err := c.client.Service.Managers() + if err != nil { + return nil, err + } + + for _, m := range managers { + logServices, err := m.LogServices() + if err != nil { + return nil, err + } + + for _, logService := range logServices { + lentries, err := logService.Entries() + if err != nil { + return nil, err + } + + for _, entry := range lentries { + entries = append(entries, []string{ + entry.ID, + entry.Created, + entry.Description, + entry.Message, + }) + } + } + } + + return entries, nil +} + +// GetSystemEventLogRaw returns the raw SEL +func (c *Client) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + var allEntries []*redfish.LogEntry + + if err := c.SessionActive(); err != nil { + return "", errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) + } + + managers, err := c.client.Service.Managers() + if err != nil { + return "", err + } + + for _, m := range managers { + logServices, err := m.LogServices() + if err != nil { + return "", err + } + + for _, logService := range logServices { + lentries, err := logService.Entries() + if err != nil { + return "", err + } + + allEntries = append(allEntries, lentries...) + } + } + + rawEntries, err := json.Marshal(allEntries) + if err != nil { + return "", err + } + + return string(rawEntries), nil +} diff --git a/internal/redfishwrapper/system.go b/internal/redfishwrapper/system.go index 5d2683a7c..5fa10863d 100644 --- a/internal/redfishwrapper/system.go +++ b/internal/redfishwrapper/system.go @@ -6,13 +6,13 @@ import ( bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/pkg/errors" - gofishrf "github.com/stmcginnis/gofish/redfish" + redfish "github.com/stmcginnis/gofish/redfish" ) // The methods here should be a thin wrapper so as to only guard the client from authentication failures. // AccountService gets the Redfish AccountService.d -func (c *Client) AccountService() (*gofishrf.AccountService, error) { +func (c *Client) AccountService() (*redfish.AccountService, error) { if err := c.SessionActive(); err != nil { return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } @@ -21,7 +21,7 @@ func (c *Client) AccountService() (*gofishrf.AccountService, error) { } // UpdateService gets the update service instance. -func (c *Client) UpdateService() (*gofishrf.UpdateService, error) { +func (c *Client) UpdateService() (*redfish.UpdateService, error) { if err := c.SessionActive(); err != nil { return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } @@ -30,28 +30,60 @@ func (c *Client) UpdateService() (*gofishrf.UpdateService, error) { } // Systems get the system instances from the service. -func (c *Client) Systems() ([]*gofishrf.ComputerSystem, error) { +func (c *Client) Systems() ([]*redfish.ComputerSystem, error) { if err := c.SessionActive(); err != nil { return nil, err } - return c.client.Service.Systems() + s, err := c.client.Service.Systems() + if err != nil { + return nil, err + } + + return c.matchingSystem(s), nil } // Managers gets the manager instances of this service. -func (c *Client) Managers(ctx context.Context) ([]*gofishrf.Manager, error) { +func (c *Client) Managers(ctx context.Context) ([]*redfish.Manager, error) { if err := c.SessionActive(); err != nil { return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } - return c.client.Service.Managers() + ms, err := c.client.Service.Managers() + if err != nil { + return nil, err + } + + for _, m := range ms { + sys, err := m.ManagerForServers() + if err != nil { + continue + } + for _, s := range sys { + if s.Name == c.systemName { + return []*redfish.Manager{m}, nil + } + } + } + + return ms, nil } // Chassis gets the chassis instances managed by this service. -func (c *Client) Chassis(ctx context.Context) ([]*gofishrf.Chassis, error) { +func (c *Client) Chassis(ctx context.Context) ([]*redfish.Chassis, error) { if err := c.SessionActive(); err != nil { return nil, errors.Wrap(bmclibErrs.ErrNotAuthenticated, err.Error()) } return c.client.Service.Chassis() } + +func (c *Client) matchingSystem(systems []*redfish.ComputerSystem) []*redfish.ComputerSystem { + for _, s := range systems { + if s.Name == c.systemName { + return []*redfish.ComputerSystem{s} + } + } + + return systems +} diff --git a/internal/redfishwrapper/task.go b/internal/redfishwrapper/task.go new file mode 100644 index 000000000..d9f8a64da --- /dev/null +++ b/internal/redfishwrapper/task.go @@ -0,0 +1,103 @@ +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" + "github.com/stmcginnis/gofish/common" + redfish "github.com/stmcginnis/gofish/redfish" +) + +var ( + errUnexpectedTaskState = errors.New("unexpected task state") +) + +func (c *Client) Task(ctx context.Context, taskID string) (*redfish.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) (constants.TaskState, string, 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, + ) + + // task message include information that help debug a cause of failure + if msgs := c.taskMessagesAsString(task.Messages); msgs != "" { + taskInfo += ", messages: " + msgs + } + + s := c.ConvertTaskState(string(task.TaskState)) + return s, taskInfo, nil +} + +func (c *Client) taskMessagesAsString(messages []common.Message) string { + if len(messages) == 0 { + return "" + } + + var found []string + for _, m := range messages { + if m.Message == "" { + continue + } + + found = append(found, m.Message) + } + + return strings.Join(found, ",") +} + +func (c *Client) ConvertTaskState(state string) constants.TaskState { + switch strings.ToLower(state) { + case "starting", "downloading", "downloaded", "scheduling": + return constants.Initializing + case "running", "stopping", "cancelling": + return constants.Running + case "pending", "new": + return constants.Queued + case "scheduled": + return constants.PowerCycleHost + case "interrupted", "killed", "exception", "cancelled", "suspended", "failed": + return constants.Failed + case "completed": + return constants.Complete + default: + return constants.Unknown + } +} + +func (c *Client) TaskStateActive(state constants.TaskState) (bool, error) { + switch state { + case constants.Initializing, constants.Running, constants.Queued: + return true, nil + case constants.Complete, constants.Failed: + return false, nil + default: + return false, errors.Wrap(errUnexpectedTaskState, string(state)) + } +} diff --git a/internal/redfishwrapper/task_test.go b/internal/redfishwrapper/task_test.go new file mode 100644 index 000000000..d34077a72 --- /dev/null +++ b/internal/redfishwrapper/task_test.go @@ -0,0 +1,302 @@ +package redfishwrapper + +import ( + "context" + "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 TestConvertTaskState(t *testing.T) { + testCases := []struct { + testName string + state string + expected constants.TaskState + }{ + {"starting state", "starting", constants.Initializing}, + {"downloading state", "downloading", constants.Initializing}, + {"downloaded state", "downloaded", constants.Initializing}, + {"scheduling state", "scheduling", constants.Initializing}, + {"running state", "running", constants.Running}, + {"stopping state", "stopping", constants.Running}, + {"cancelling state", "cancelling", constants.Running}, + {"pending state", "pending", constants.Queued}, + {"new state", "new", constants.Queued}, + {"scheduled state", "scheduled", constants.PowerCycleHost}, + {"interrupted state", "interrupted", constants.Failed}, + {"killed state", "killed", constants.Failed}, + {"exception state", "exception", constants.Failed}, + {"cancelled state", "cancelled", constants.Failed}, + {"suspended state", "suspended", constants.Failed}, + {"failed state", "failed", constants.Failed}, + {"completed state", "completed", constants.Complete}, + {"unknown state", "unknown_state", constants.Unknown}, + } + + client := Client{} + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + result := client.ConvertTaskState(tc.state) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestTaskStateActive(t *testing.T) { + testCases := []struct { + testName string + taskState constants.TaskState + expected bool + err error + }{ + {"active initializing", constants.Initializing, true, nil}, + {"active running", constants.Running, true, nil}, + {"active queued", constants.Queued, true, nil}, + {"inactive complete", constants.Complete, false, nil}, + {"inactive failed", constants.Failed, false, nil}, + {"unknown state", "foobar", false, errUnexpectedTaskState}, + } + + client := &Client{} + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + active, err := client.TaskStateActive(tc.taskState) + + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + return + } + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.expected, active) + }) + } +} + +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 constants.TaskState + expectedStatus string + expectedErr error + }{ + "task in Initializing state": { + hmap: withHandler( + "/redfish/v1/TaskService/Tasks/1", + endpointFunc(t, "tasks/tasks_1_starting.json"), + ), + expectedState: constants.Initializing, + 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.Running, + 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.Queued, + 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.PowerCycleHost, + 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.Failed, + 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.Complete, + 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.Unknown, + 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 { + 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/internal/redfishwrapper/virtual_media.go b/internal/redfishwrapper/virtual_media.go index 4954eb3fe..f214bffc7 100644 --- a/internal/redfishwrapper/virtual_media.go +++ b/internal/redfishwrapper/virtual_media.go @@ -2,18 +2,22 @@ package redfishwrapper import ( "context" + "errors" "fmt" + "slices" - "github.com/pkg/errors" rf "github.com/stmcginnis/gofish/redfish" ) // Set the virtual media attached to the system, or just eject everything if mediaURL is empty. -func (c *Client) SetVirtualMedia(ctx context.Context, kind string, mediaURL string) (ok bool, err error) { +func (c *Client) SetVirtualMedia(ctx context.Context, kind string, mediaURL string) (bool, error) { managers, err := c.Managers(ctx) if err != nil { return false, err } + if len(managers) == 0 { + return false, errors.New("no redfish managers found") + } var mediaKind rf.VirtualMediaType switch kind { @@ -29,48 +33,71 @@ func (c *Client) SetVirtualMedia(ctx context.Context, kind string, mediaURL stri return false, errors.New("invalid media type") } - for _, manager := range managers { - virtualMedia, err := manager.VirtualMedia() + for _, m := range managers { + virtualMedia, err := m.VirtualMedia() if err != nil { return false, err } - for _, media := range virtualMedia { - if media.Inserted { - err = media.EjectMedia() - if err != nil { + if len(virtualMedia) == 0 { + return false, errors.New("no virtual media found") + } + + for _, vm := range virtualMedia { + var ejected bool + if vm.Inserted { + if err := vm.EjectMedia(); err != nil { + return false, err + } + ejected = true + } + if mediaURL == "" { + // Only ejecting the media was requested. + // For BMC's that don't support the "inserted" property, we need to eject the media if it's not already ejected. + if !ejected { + if err := vm.EjectMedia(); err != nil { + return false, err + } + } + return true, nil + } + if !slices.Contains(vm.MediaTypes, mediaKind) { + return false, fmt.Errorf("media kind %s not supported by BMC, supported media kinds %q", kind, vm.MediaTypes) + } + if err := vm.InsertMedia(mediaURL, true, true); err != nil { + // Some BMC's (Supermicro X11SDV-4C-TLN2F, for example) don't support the "inserted" and "writeProtected" properties, + // so we try to insert the media without them if the first attempt fails. + if err := vm.InsertMediaConfig(rf.VirtualMediaConfig{Image: mediaURL}); err != nil { return false, err } } + return true, nil } } - // An empty mediaURL means eject everything, so if that's the case we're done. Otherwise, we - // need to insert the media. - if mediaURL != "" { - setMedia := false - for _, manager := range managers { - virtualMedia, err := manager.VirtualMedia() - if err != nil { - return false, err - } + // If we actual get here, then something very unexpected happened as there isn't a known code path that would cause this error to be returned. + return false, errors.New("unexpected error setting virtual media") +} - for _, media := range virtualMedia { - for _, t := range media.MediaTypes { - if t == mediaKind { - err = media.InsertMedia(mediaURL, true, true) - if err != nil { - return false, err - } - setMedia = true - break - } - } - } +func (c *Client) InsertedVirtualMedia(ctx context.Context) ([]string, error) { + managers, err := c.Managers(ctx) + if err != nil { + return nil, err + } + + var inserted []string + + for _, m := range managers { + virtualMedia, err := m.VirtualMedia() + if err != nil { + return nil, err } - if !setMedia { - return false, fmt.Errorf("media kind %s not supported", kind) + + for _, media := range virtualMedia { + if media.Inserted { + inserted = append(inserted, media.ID) + } } } - return true, nil + return inserted, nil } diff --git a/internal/sum/sum.go b/internal/sum/sum.go new file mode 100644 index 000000000..b643a959f --- /dev/null +++ b/internal/sum/sum.go @@ -0,0 +1,262 @@ +package sum + +// SUM is Supermicro Update Manager +// https://www.supermicro.com/en/solutions/management-software/supermicro-update-manager + +import ( + "context" + "os" + "os/exec" + "strings" + + ex "github.com/bmc-toolbox/bmclib/v2/internal/executor" + + "github.com/bmc-toolbox/common" + "github.com/bmc-toolbox/common/config" + "github.com/go-logr/logr" +) + +// Sum is a sum command executor object +type Sum struct { + Executor ex.Executor + SumPath string + Log logr.Logger + Host string + Username string + Password string +} + +// Option for setting optional Client values +type Option func(*Sum) + +func WithSumPath(sumPath string) Option { + return func(c *Sum) { + c.SumPath = sumPath + } +} + +func WithLogger(log logr.Logger) Option { + return func(c *Sum) { + c.Log = log + } +} + +func New(host, user, pass string, opts ...Option) (*Sum, error) { + sum := &Sum{ + Host: host, + Username: user, + Password: pass, + Log: logr.Discard(), + } + + for _, opt := range opts { + opt(sum) + } + + var err error + + if sum.SumPath == "" { + sum.SumPath, err = exec.LookPath("sum") + if err != nil { + return nil, err + } + } else { + if _, err = os.Stat(sum.SumPath); err != nil { + return nil, err + } + } + + e := ex.NewExecutor(sum.SumPath) + e.SetEnv([]string{"LC_ALL=C.UTF-8"}) + sum.Executor = e + + return sum, nil +} + +// Open a connection to a BMC +func (c *Sum) Open(ctx context.Context) (err error) { + return nil +} + +// Close a connection to a BMC +func (c *Sum) Close(ctx context.Context) (err error) { + return nil +} + +func (s *Sum) run(ctx context.Context, command string, additionalArgs ...string) (output string, err error) { + // TODO(splaspood) use a tmp file here (as sum supports) to read the password + sumArgs := []string{"-i", s.Host, "-u", s.Username, "-p", s.Password, "-c", command} + sumArgs = append(sumArgs, additionalArgs...) + + s.Log.V(9).WithValues( + "sumArgs", + sumArgs, + ).Info("Calling sum") + + s.Executor.SetArgs(sumArgs) + + result, err := s.Executor.ExecWithContext(ctx) + if err != nil { + return string(result.Stderr), err + } + + return string(result.Stdout), err +} + +func (s *Sum) GetCurrentBiosCfg(ctx context.Context) (output string, err error) { + return s.run(ctx, "GetCurrentBiosCfg") +} + +func (s *Sum) LoadDefaultBiosCfg(ctx context.Context) (err error) { + _, err = s.run(ctx, "LoadDefaultBiosCfg") + return err +} + +func (s *Sum) ChangeBiosCfg(ctx context.Context, cfgFile string, reboot bool) (err error) { + args := []string{"--file", cfgFile} + + if reboot { + args = append(args, "--reboot") + } + + _, err = s.run(ctx, "ChangeBiosCfg", args...) + + return err +} + +// GetBiosConfiguration return bios configuration +func (s *Sum) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { + biosText, err := s.GetCurrentBiosCfg(ctx) + if err != nil { + return nil, err + } + + // We need to call vcm here to take the XML returned by SUM and convert it into a simple map + vcm, err := config.NewVendorConfigManager("xml", common.VendorSupermicro, map[string]string{}) + if err != nil { + return nil, err + } + + err = vcm.Unmarshal(biosText) + if err != nil { + return nil, err + } + + biosConfig, err = vcm.StandardConfig() + if err != nil { + return nil, err + } + + return biosConfig, nil +} + +// SetBiosConfiguration set bios configuration +func (s *Sum) SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) { + vcm, err := config.NewVendorConfigManager("xml", common.VendorSupermicro, map[string]string{}) + if err != nil { + return err + } + + for k, v := range biosConfig { + switch { + case k == "boot_mode": + if err = vcm.BootMode(v); err != nil { + return err + } + case k == "boot_order": + if err = vcm.BootOrder(v); err != nil { + return err + } + case k == "intel_sgx": + if err = vcm.IntelSGX(v); err != nil { + return err + } + case k == "secure_boot": + switch v { + case "Enabled": + if err = vcm.SecureBoot(true); err != nil { + return err + } + case "Disabled": + if err = vcm.SecureBoot(false); err != nil { + return err + } + } + case k == "tpm": + switch v { + case "Enabled": + if err = vcm.TPM(true); err != nil { + return err + } + case "Disabled": + if err = vcm.TPM(false); err != nil { + return err + } + } + case k == "smt": + switch v { + case "Enabled": + if err = vcm.SMT(true); err != nil { + return err + } + case "Disabled": + if err = vcm.SMT(false); err != nil { + return err + } + } + case k == "sr_iov": + switch v { + case "Enabled": + if err = vcm.SRIOV(true); err != nil { + return err + } + case "Disabled": + if err = vcm.SRIOV(false); err != nil { + return err + } + } + case strings.HasPrefix(k, "raw:"): + // k = raw:Menu1,SubMenu1,SubMenuMenu1,SettingName + pathStr := strings.TrimPrefix(k, "raw:") + path := strings.Split(pathStr, ",") + name := path[len(path)-1] + path = path[:len(path)-1] + + vcm.Raw(name, v, path) + } + } + + xmlData, err := vcm.Marshal() + if err != nil { + return err + } + + return s.SetBiosConfigurationFromFile(ctx, xmlData) +} + +func (s *Sum) SetBiosConfigurationFromFile(ctx context.Context, cfg string) (err error) { + // Open tmp file to hold cfg + inputConfigTmpFile, err := os.CreateTemp("", "bmclib") + if err != nil { + return err + } + + defer os.Remove(inputConfigTmpFile.Name()) + + _, err = inputConfigTmpFile.WriteString(cfg) + if err != nil { + return err + } + + err = inputConfigTmpFile.Close() + if err != nil { + return err + } + + return s.ChangeBiosCfg(ctx, inputConfigTmpFile.Name(), true) +} + +// ResetBiosConfiguration reset bios configuration +func (s *Sum) ResetBiosConfiguration(ctx context.Context) (err error) { + return s.LoadDefaultBiosCfg(ctx) +} diff --git a/internal/sum/sum_test.go b/internal/sum/sum_test.go new file mode 100644 index 000000000..c7bee90ea --- /dev/null +++ b/internal/sum/sum_test.go @@ -0,0 +1,90 @@ +package sum + +import ( + "context" + "os" + "testing" + + ex "github.com/bmc-toolbox/bmclib/v2/internal/executor" +) + +func newFakeSum(t *testing.T, fixtureName string) *Sum { + e := &Sum{ + Executor: ex.NewFakeExecutor("sum"), + } + + b, err := os.ReadFile("../../fixtures/internal/sum/" + fixtureName) + if err != nil { + t.Error(err) + } + + e.Executor.SetStdout(b) + + return e +} + +func TestExec_Run(t *testing.T) { + // Create a new instance of Sum + exec := newFakeSum(t, "GetBIOSInfo") + + // Create a new context + ctx := context.Background() + + // Call the run function + _, err := exec.run(ctx, "GetBIOSInfo") + + // Check the output and error + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } +} + +func TestExec_SetBiosConfiguration(t *testing.T) { + // Create a new context + ctx := context.Background() + + // Define the BIOS configuration + biosConfig := map[string]string{ + "boot_mode": "UEFI", + "boot_order": "UEFI", + "intel_sgx": "Enabled", + "secure_boot": "Enabled", + "tpm": "Enabled", + "smt": "Disabled", + "sr_iov": "Enabled", + "raw:Menu1,SubMenu1,SubMenuMenu1,SettingName": "Value", + } + + exec := newFakeSum(t, "SetBiosConfiguration") + + // Call the SetBiosConfiguration function + err := exec.SetBiosConfiguration(ctx, biosConfig) + + // Check for any errors + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // Additional assertions can be added to verify the behavior of the function +} + +func TestExec_GetBiosConfiguration(t *testing.T) { + // Create a new context + ctx := context.Background() + + exec := newFakeSum(t, "GetBiosConfiguration") + + // Call the SetBiosConfiguration function + biosConfig, err := exec.GetBiosConfiguration(ctx) + + // Check for any errors + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // Confirm boot_mode exists + _, ok := biosConfig["boot_mode"] + if !ok { + t.Fail() + } +} diff --git a/lint.mk b/lint.mk index d492cc708..09ba98fd6 100644 --- a/lint.mk +++ b/lint.mk @@ -20,7 +20,7 @@ LINTERS := FIXERS := GOLANGCI_LINT_CONFIG := $(LINT_ROOT)/.golangci.yml -GOLANGCI_LINT_VERSION ?= v1.53.3 +GOLANGCI_LINT_VERSION ?= v1.61.0 GOLANGCI_LINT_BIN := $(LINT_ROOT)/out/linters/golangci-lint-$(GOLANGCI_LINT_VERSION)-$(LINT_ARCH) $(GOLANGCI_LINT_BIN): mkdir -p $(LINT_ROOT)/out/linters diff --git a/option.go b/option.go index f79cab5fe..3039cfdca 100644 --- a/option.go +++ b/option.go @@ -10,6 +10,7 @@ import ( "github.com/bmc-toolbox/bmclib/v2/providers/rpc" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" + oteltrace "go.opentelemetry.io/otel/trace" ) // Option for setting optional Client values @@ -111,6 +112,18 @@ func WithRedfishUseBasicAuth(useBasicAuth bool) Option { } } +func WithRedfishEtagMatchDisabled(d bool) Option { + return func(args *Client) { + args.providerConfig.gofish.DisableEtagMatch = d + } +} + +func WithRedfishSystemName(name string) Option { + return func(args *Client) { + args.providerConfig.gofish.SystemName = name + } +} + func WithIntelAMTHostScheme(hostScheme string) Option { return func(args *Client) { args.providerConfig.intelamt.HostScheme = hostScheme @@ -144,3 +157,13 @@ func WithRPCOpt(opt rpc.Provider) Option { args.providerConfig.rpc = opt } } + +// WithTracerProvider specifies a tracer provider to use for creating a tracer. +// If none is specified a noop tracerprovider is used. +func WithTracerProvider(provider oteltrace.TracerProvider) Option { + return func(args *Client) { + if provider != nil { + args.traceprovider = provider + } + } +} diff --git a/providers/asrockrack/asrockrack.go b/providers/asrockrack/asrockrack.go index a3d7fa95f..721c10dc6 100644 --- a/providers/asrockrack/asrockrack.go +++ b/providers/asrockrack/asrockrack.go @@ -1,16 +1,19 @@ package asrockrack import ( - "bytes" "context" "crypto/x509" + "fmt" "net/http" + "strings" "github.com/bmc-toolbox/bmclib/v2/constants" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/providers" + "github.com/bmc-toolbox/common" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" + "github.com/pkg/errors" ) const ( @@ -18,18 +21,26 @@ const ( ProviderName = "asrockrack" // ProviderProtocol for the provider implementation ProviderProtocol = "vendorapi" + + E3C256D4ID_NL = "E3C256D4ID-NL" + E3C246D4ID_NL = "E3C246D4ID-NL" + E3C246D4I_NL = "E3C246D4I-NL" ) var ( // Features implemented by asrockrack https Features = registrar.Features{ - providers.FeatureInventoryRead, - providers.FeatureFirmwareInstall, - providers.FeatureFirmwareInstallStatus, providers.FeaturePostCodeRead, providers.FeatureBmcReset, providers.FeatureUserCreate, providers.FeatureUserUpdate, + providers.FeatureFirmwareUpload, + providers.FeatureFirmwareInstallUploaded, + providers.FeatureFirmwareTaskStatus, + providers.FeatureFirmwareInstallSteps, + providers.FeatureInventoryRead, + providers.FeaturePowerSet, + providers.FeaturePowerState, } ) @@ -38,6 +49,7 @@ type ASRockRack struct { ip string username string password string + deviceModel string loginSession *loginSession httpClient *http.Client resetRequired bool // Indicates if the BMC requires a reset @@ -100,24 +112,45 @@ func (a *ASRockRack) Name() string { return ProviderName } -// Compatible implements the registrar.Verifier interface -// returns true if the BMC is identified to be an asrockrack -func (a *ASRockRack) Compatible(ctx context.Context) bool { - resp, statusCode, err := a.queryHTTPS(ctx, "/", "GET", nil, nil, 0) - if err != nil { - return false +// Open a connection to a BMC, implements the Opener interface +func (a *ASRockRack) Open(ctx context.Context) (err error) { + if err := a.httpsLogin(ctx); err != nil { + return err } - if statusCode != 200 { - return false + return a.supported(ctx) +} + +func (a *ASRockRack) supported(ctx context.Context) error { + supported := []string{ + E3C256D4ID_NL, + E3C246D4ID_NL, + E3C246D4I_NL, } - return bytes.Contains(resp, []byte(`ASRockRack`)) -} + if a.deviceModel == "" { + device := common.NewDevice() + device.Metadata = map[string]string{} -// Open a connection to a BMC, implements the Opener interface -func (a *ASRockRack) Open(ctx context.Context) (err error) { - return a.httpsLogin(ctx) + err := a.fruAttributes(ctx, &device) + if err != nil { + return errors.Wrap(err, "failed to identify device model") + } + + if device.Model == "" { + return errors.Wrap(err, "failed to identify device model - empty model attribute") + } + + a.deviceModel = device.Model + } + + for _, s := range supported { + if strings.EqualFold(a.deviceModel, s) { + return nil + } + } + + return fmt.Errorf("device model not supported: %s", a.deviceModel) } // Close a connection to a BMC, implements the Closer interface diff --git a/providers/asrockrack/asrockrack_test.go b/providers/asrockrack/asrockrack_test.go index cc59d32e5..ce9a1b3fd 100644 --- a/providers/asrockrack/asrockrack_test.go +++ b/providers/asrockrack/asrockrack_test.go @@ -4,58 +4,55 @@ import ( "context" "os" "testing" + "time" "gopkg.in/go-playground/assert.v1" ) -func Test_Compatible(t *testing.T) { - b := aClient.Compatible(context.TODO()) - if !b { - t.Errorf("expected true, got false") - } -} - -func Test_httpLogin(t *testing.T) { +func TestHttpLogin(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } assert.Equal(t, "l5L29IP7", aClient.loginSession.CSRFToken) } -func Test_Close(t *testing.T) { +func TestClose(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login setup: %s", err.Error()) } err = aClient.httpsLogout(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("logout: %s", err.Error()) } } -func Test_FirwmwareUpdateBMC(t *testing.T) { +func TestFirwmwareUpdateBMC(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } upgradeFile := "/tmp/dummy-E3C246D4I-NL_L0.01.00.ima" _, err = os.Create(upgradeFile) if err != nil { - t.Errorf(err.Error()) + t.Errorf("create file: %s", err.Error()) } fh, err := os.Open(upgradeFile) if err != nil { - t.Errorf(err.Error()) + t.Errorf("file open: %s", err.Error()) } defer fh.Close() - err = aClient.firmwareInstallBMC(context.TODO(), fh, 0) + ctx, cancel := context.WithTimeout(context.TODO(), time.Minute*15) + defer cancel() + + err = aClient.firmwareUploadBMC(ctx, fh) if err != nil { - t.Errorf(err.Error()) + t.Errorf("upload: %s", err.Error()) } } diff --git a/providers/asrockrack/firmware.go b/providers/asrockrack/firmware.go index f1eb111b2..24bd29730 100644 --- a/providers/asrockrack/firmware.go +++ b/providers/asrockrack/firmware.go @@ -2,9 +2,10 @@ package asrockrack import ( "context" - "io" + "fmt" "os" "strings" + "time" "github.com/pkg/errors" @@ -21,117 +22,180 @@ const ( versionStrEmpty = 2 ) -// FirmwareInstall uploads and initiates firmware update for the component -func (a *ASRockRack) FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (jobID string, err error) { - var size int64 - if file, ok := reader.(*os.File); ok { - finfo, err := file.Stat() - if err != nil { - a.log.V(2).Error(err, "unable to determine file size") - } +// bmc client interface implementations methods +func (a *ASRockRack) FirmwareInstallSteps(ctx context.Context, component string) ([]constants.FirmwareInstallStep, error) { + if err := a.supported(ctx); err != nil { + return nil, bmclibErrs.NewErrUnsupportedHardware(err.Error()) + } - size = finfo.Size() + switch strings.ToUpper(component) { + case common.SlugBMC: + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepUpload, + constants.FirmwareInstallStepInstallUploaded, + constants.FirmwareInstallStepInstallStatus, + constants.FirmwareInstallStepResetBMCPostInstall, + constants.FirmwareInstallStepResetBMCOnInstallFailure, + }, nil } - switch component { + return nil, errors.Wrap(bmclibErrs.ErrFirmwareUpload, "component unsupported: "+component) +} + +func (a *ASRockRack) FirmwareUpload(ctx context.Context, component string, file *os.File) (taskID string, err error) { + switch strings.ToUpper(component) { case common.SlugBIOS: - err = a.firmwareInstallBIOS(ctx, reader, size) + return "", a.firmwareUploadBIOS(ctx, file) case common.SlugBMC: - err = a.firmwareInstallBMC(ctx, reader, size) - default: - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component) + return "", a.firmwareUploadBMC(ctx, file) } - if err != nil { - err = errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) - } + return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, "component unsupported: "+component) - return jobID, err } -// FirmwareInstallStatus returns the status of the firmware install process, a bool value indicating if the component requires a reset -func (a *ASRockRack) FirmwareInstallStatus(ctx context.Context, installVersion, component, taskID string) (status string, err error) { - switch component { - case common.SlugBIOS, common.SlugBMC: - return a.firmwareUpdateStatus(ctx, component, installVersion) - default: - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component) +func (a *ASRockRack) firmwareUploadBMC(ctx context.Context, file *os.File) error { + // // 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()) } -} -// firmwareInstallBMC uploads and installs firmware for the BMC component -func (a *ASRockRack) firmwareInstallBMC(ctx context.Context, reader io.Reader, fileSize int64) error { - var err error - - // 1. set the device to flash mode - prepares the flash + // Beware: this locks some capabilities, e.g. the access to fruAttributes a.log.V(2).WithValues("step", "1/4").Info("set device to flash mode, takes a minute...") - err = a.setFlashMode(ctx) + err := a.setFlashMode(ctx) if err != nil { - return errors.Wrap(err, "failed in step 1/4 - set device to flash mode") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 1/3 - set device to flash mode: "+err.Error(), + ) } - // 2. upload firmware image file - a.log.V(2).WithValues("step", "2/4").Info("upload BMC firmware image") - err = a.uploadFirmware(ctx, "api/maintenance/firmware", reader, fileSize) - if err != nil { - return errors.Wrap(err, "failed in step 2/4 - upload BMC firmware image") + var fwEndpoint string + switch a.deviceModel { + // E3C256D4ID-NL calls a different endpoint for firmware upload + case "E3C256D4ID-NL": + fwEndpoint = "api/maintenance/firmware/firmware" + default: + fwEndpoint = "api/maintenance/firmware" } - // 3. BMC to verify the uploaded file - err = a.verifyUploadedFirmware(ctx) - a.log.V(2).WithValues("step", "3/4").Info("verify uploaded BMC firmware") + a.log.V(2).WithValues("step", "2/4").Info("upload BMC firmware image to " + fwEndpoint) + err = a.uploadFirmware(ctx, fwEndpoint, file) if err != nil { - return errors.Wrap(err, "failed in step 3/4 - verify uploaded BMC firmware") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 2/3 - upload BMC firmware image: "+err.Error(), + ) } - // 4. Run the upgrade - preserving current config - a.log.V(2).WithValues("step", "4/4").Info("proceed with BMC firmware install, preserve current configuration") - err = a.upgradeBMC(ctx) + a.log.V(2).WithValues("step", "3/4").Info("verify uploaded BMC firmware") + err = a.verifyUploadedFirmware(ctx) if err != nil { - return errors.Wrap(err, "failed in step 4/4 - proceed with BMC firmware install") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 3/3 - verify uploaded BMC firmware: "+err.Error(), + ) } return nil } -// firmwareInstallBIOS uploads and installs firmware for the BIOS component -func (a *ASRockRack) firmwareInstallBIOS(ctx context.Context, reader io.Reader, fileSize int64) error { - var err error - - // 1. upload firmware image file +func (a *ASRockRack) firmwareUploadBIOS(ctx context.Context, file *os.File) error { a.log.V(2).WithValues("step", "1/3").Info("upload BIOS firmware image") - err = a.uploadFirmware(ctx, "api/asrr/maintenance/BIOS/firmware", reader, fileSize) + err := a.uploadFirmware(ctx, "api/asrr/maintenance/BIOS/firmware", file) if err != nil { - return errors.Wrap(err, "failed in step 1/3 - upload BIOS firmware image") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 1/3 - upload BIOS firmware image: "+err.Error(), + ) } - // 2. set update parameters to preserve configurations a.log.V(2).WithValues("step", "2/3").Info("set BIOS preserve flash configuration") err = a.biosUpgradeConfiguration(ctx) if err != nil { - return errors.Wrap(err, "failed in step 2/3 - set flash configuration") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 2/3 - set flash configuration: "+err.Error(), + ) } // 3. run upgrade a.log.V(2).WithValues("step", "3/3").Info("proceed with BIOS firmware install") err = a.upgradeBIOS(ctx) if err != nil { - return errors.Wrap(err, "failed in step 3/3 - proceed with BIOS firmware install") + return errors.Wrap( + bmclibErrs.ErrFirmwareUpload, + "failed in step 3/3 - proceed with BIOS firmware install: "+err.Error(), + ) } return nil } +func (a *ASRockRack) FirmwareInstallUploaded(ctx context.Context, component, uploadTaskID string) (installTaskID string, err error) { + switch strings.ToUpper(component) { + case common.SlugBIOS: + return "", a.firmwareInstallUploadedBIOS(ctx) + case common.SlugBMC: + return "", a.firmwareInstallUploadedBMC(ctx) + } + + return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component) +} + +// firmwareInstallUploadedBIOS uploads and installs firmware for the BMC component +func (a *ASRockRack) firmwareInstallUploadedBIOS(ctx context.Context) error { + // 4. Run the upgrade - preserving current config + a.log.V(2).WithValues("step", "install").Info("proceed with BIOS firmware install, preserve current configuration") + err := a.upgradeBIOS(ctx) + if err != nil { + return errors.Wrap( + bmclibErrs.ErrFirmwareInstallUploaded, + "failed in step 4/4 - proceed with BMC firmware install: "+err.Error(), + ) + } + + return nil +} + +// firmwareInstallUploadedBMC uploads and installs firmware for the BMC component +func (a *ASRockRack) firmwareInstallUploadedBMC(ctx context.Context) error { + // 4. Run the upgrade - preserving current config + a.log.V(2).WithValues("step", "install").Info("proceed with BMC firmware install, preserve current configuration") + err := a.upgradeBMC(ctx) + if err != nil { + return errors.Wrap( + bmclibErrs.ErrFirmwareInstallUploaded, + "failed in step 4/4 - proceed with BMC firmware install"+err.Error(), + ) + } + + return nil +} + +// FirmwareTaskStatus returns the status of a firmware related task queued on the BMC. +func (a *ASRockRack) FirmwareTaskStatus(ctx context.Context, kind constants.FirmwareInstallStep, component, taskID, installVersion string) (state constants.TaskState, status string, err error) { + component = strings.ToUpper(component) + switch component { + case common.SlugBIOS, common.SlugBMC: + return a.firmwareUpdateStatus(ctx, component, installVersion) + default: + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component) + } +} + // firmwareUpdateBIOSStatus returns the BIOS firmware install status -func (a *ASRockRack) firmwareUpdateStatus(ctx context.Context, component string, installVersion string) (status string, err error) { +func (a *ASRockRack) firmwareUpdateStatus(ctx context.Context, component string, installVersion string) (state constants.TaskState, status string, err error) { var endpoint string + component = strings.ToUpper(component) switch component { case common.SlugBIOS: endpoint = "api/asrr/maintenance/BIOS/flash-progress" case common.SlugBMC: endpoint = "api/maintenance/firmware/flash-progress" default: - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component) + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component) } // 1. query the flash progress endpoint @@ -143,11 +207,15 @@ func (a *ASRockRack) firmwareUpdateStatus(ctx context.Context, component string, } if progress != nil { + status = fmt.Sprintf("action: %s, progress: %s", progress.Action, progress.Progress) + switch progress.State { case 0: - return constants.FirmwareInstallRunning, nil + return constants.Running, status, nil + case 1: // "Flashing To be done" + return constants.Queued, status, nil case 2: - return constants.FirmwareInstallComplete, nil + return constants.Complete, status, nil default: a.log.V(3).WithValues("state", progress.State).Info("warn", "bmc returned unknown flash progress state") } @@ -160,19 +228,26 @@ func (a *ASRockRack) firmwareUpdateStatus(ctx context.Context, component string, installStatus, err = a.versionInstalled(ctx, component, installVersion) if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) + return "", "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error()) } switch installStatus { case versionStrMatch: - return constants.FirmwareInstallComplete, nil + if progress == nil { + // TODO: we should pass the force parameter to firmwareUpdateStatus, + // so that we can know if we expect a version change or not + a.log.V(3).Info("Nil progress + no version change -> unknown") + return constants.Unknown, status, nil + } + + return constants.Complete, status, nil case versionStrEmpty: - return constants.FirmwareInstallUnknown, nil + return constants.Unknown, status, nil case versionStrMismatch: - return constants.FirmwareInstallRunning, nil + return constants.Running, status, nil } - return constants.FirmwareInstallUnknown, nil + return constants.Unknown, status, nil } // versionInstalled returns int values on the status of the firmware version install @@ -181,6 +256,7 @@ func (a *ASRockRack) firmwareUpdateStatus(ctx context.Context, component string, // - 1 indicates the given version parameter does not match the version installed // - 2 the version parameter returned from the BMC is empty (which means the BMC needs a reset) func (a *ASRockRack) versionInstalled(ctx context.Context, component, version string) (status int, err error) { + component = strings.ToUpper(component) if !internal.StringInSlice(component, []string{common.SlugBIOS, common.SlugBMC}) { return versionStrError, errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component) } diff --git a/providers/asrockrack/helpers.go b/providers/asrockrack/helpers.go index 3ff46bc5b..c562e7460 100644 --- a/providers/asrockrack/helpers.go +++ b/providers/asrockrack/helpers.go @@ -12,7 +12,8 @@ import ( "os" "github.com/bmc-toolbox/bmclib/v2/constants" - "github.com/bmc-toolbox/bmclib/v2/errors" + brrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/bmc-toolbox/common" ) // API session setup response payload @@ -178,10 +179,29 @@ func (a *ASRockRack) createUpdateUser(ctx context.Context, account *UserAccount) } // 1 Set BMC to flash mode and prepare flash area -// at this point all logged in sessions are terminated -// and no logins are permitted +// +// with the BMC set in flash mode, no new logins are accepted +// and only a few endpoints can be queried with the existing session +// one of the few being the install progress/flash status endpoint. func (a *ASRockRack) setFlashMode(ctx context.Context) error { - _, statusCode, err := a.queryHTTPS(ctx, "api/maintenance/flash", "PUT", nil, nil, 0) + device := common.NewDevice() + device.Metadata = map[string]string{} + _ = a.fruAttributes(ctx, &device) + + pConfig := &preserveConfig{} + // preserve config is needed by e3c256d4i + switch device.Model { + case E3C256D4ID_NL: + pConfig = &preserveConfig{PreserveConfig: 1} + } + + payload, err := json.Marshal(pConfig) + if err != nil { + return err + } + + headers := map[string]string{"Content-Type": "application/json"} + _, statusCode, err := a.queryHTTPS(ctx, "api/maintenance/flash", "PUT", bytes.NewReader(payload), headers, 0) if err != nil { return err } @@ -204,9 +224,20 @@ func multipartSize(fieldname, filename string) int64 { } // 2 Upload the firmware file -func (a *ASRockRack) uploadFirmware(ctx context.Context, endpoint string, fwReader io.Reader, fileSize int64) error { +func (a *ASRockRack) uploadFirmware(ctx context.Context, endpoint string, file *os.File) error { + var size int64 + finfo, err := file.Stat() + if err != nil { + return fmt.Errorf("unable to determine file size: %w", err) + } + + size = finfo.Size() + fieldName, fileName := "fwimage", "image" - contentLength := multipartSize(fieldName, fileName) + fileSize + contentLength := multipartSize(fieldName, fileName) + size + + // Before reading the file, rewind to the beginning + _, _ = file.Seek(0, 0) // setup pipe pipeReader, pipeWriter := io.Pipe() @@ -227,7 +258,7 @@ func (a *ASRockRack) uploadFirmware(ctx context.Context, endpoint string, fwRead } // copy from source into form part writer - _, err = io.Copy(part, fwReader) + _, err = io.Copy(part, file) if err != nil { errCh <- err return @@ -354,7 +385,7 @@ func (a *ASRockRack) postCodeInfo(ctx context.Context) (*biosPOSTCode, error) { } // Query the inventory info endpoint -func (a *ASRockRack) inventoryInfo(ctx context.Context) ([]*component, error) { +func (a *ASRockRack) inventoryInfoE3C246D41D(ctx context.Context) ([]*component, error) { resp, statusCode, err := a.queryHTTPS(ctx, "api/asrr/inventory_info", "GET", nil, nil, 0) if err != nil { return nil, err @@ -526,17 +557,17 @@ func (a *ASRockRack) httpsLogin(ctx context.Context) error { resp, statusCode, err := a.queryHTTPS(ctx, urlEndpoint, "POST", bytes.NewReader(payload), headers, 0) if err != nil { - return fmt.Errorf("Error logging in: " + err.Error()) + return fmt.Errorf("logging in: %w", err) } if statusCode == 401 { - return errors.ErrLoginFailed + return brrs.ErrLoginFailed } // Unmarshal login session err = json.Unmarshal(resp, a.loginSession) if err != nil { - return fmt.Errorf("error unmarshalling response payload: " + err.Error()) + return fmt.Errorf("unmarshalling response payload: %w", err) } return nil @@ -546,7 +577,7 @@ func (a *ASRockRack) httpsLogin(ctx context.Context) error { func (a *ASRockRack) httpsLogout(ctx context.Context) error { _, statusCode, err := a.queryHTTPS(ctx, "api/session", "DELETE", nil, nil, 0) if err != nil { - return fmt.Errorf("Error logging out: " + err.Error()) + return fmt.Errorf("logging out: %w", err) } if statusCode != http.StatusOK { @@ -584,7 +615,7 @@ func (a *ASRockRack) queryHTTPS(ctx context.Context, endpoint, method string, pa } // debug dump request - if os.Getenv("BMCLIB_LOG_LEVEL") == "trace" { + if os.Getenv(constants.EnvEnableDebug) == "true" { reqDump, _ := httputil.DumpRequestOut(req, true) a.log.V(3).Info("trace", "url", URL, "requestDump", string(reqDump)) } @@ -595,7 +626,7 @@ func (a *ASRockRack) queryHTTPS(ctx context.Context, endpoint, method string, pa } // debug dump response - if os.Getenv("BMCLIB_LOG_LEVEL") == "trace" { + if os.Getenv(constants.EnvEnableDebug) == "true" { respDump, _ := httputil.DumpResponse(resp, true) a.log.V(3).Info("trace", "responseDump", string(respDump)) } diff --git a/providers/asrockrack/helpers_test.go b/providers/asrockrack/helpers_test.go index a1b18fc7e..88d41452f 100644 --- a/providers/asrockrack/helpers_test.go +++ b/providers/asrockrack/helpers_test.go @@ -21,24 +21,24 @@ func Test_FirmwareInfo(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } fwInfo, err := aClient.firmwareInfo(context.TODO()) if err != nil { - t.Error(err.Error()) + t.Errorf("firmwareInfo: %s", err.Error()) } assert.Equal(t, expected, fwInfo) } -func Test_inventoryInfo(t *testing.T) { +func TestInventoryInfo(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } - inventory, err := aClient.inventoryInfo(context.TODO()) + inventory, err := aClient.inventoryInfoE3C246D41D(context.TODO()) if err != nil { t.Fatal(err.Error()) } @@ -50,7 +50,7 @@ func Test_inventoryInfo(t *testing.T) { func Test_fruInfo(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } frus, err := aClient.fruInfo(context.TODO()) @@ -64,7 +64,7 @@ func Test_fruInfo(t *testing.T) { func Test_sensors(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } sensors, err := aClient.sensors(context.TODO()) @@ -83,7 +83,7 @@ func Test_biosPOSTCode(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } info, err := aClient.postCodeInfo(context.TODO()) @@ -102,7 +102,7 @@ func Test_chassisStatus(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } info, err := aClient.chassisStatusInfo(context.TODO()) diff --git a/providers/asrockrack/inventory.go b/providers/asrockrack/inventory.go index f1ad88efc..d571a241d 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" ) @@ -29,9 +28,12 @@ func (a *ASRockRack) Inventory(ctx context.Context) (device *common.Device, err } // populate device health based on sensor readings + // + // sensor data collection can fail for a myriad of reasons + // we log the error and keep going err = a.systemHealth(ctx, device) if err != nil { - return nil, err + a.log.V(2).Error(err, "sensor data collection error", "deviceModel", a.deviceModel) } return device, nil @@ -140,7 +142,17 @@ func (a *ASRockRack) systemAttributes(ctx context.Context, device *common.Device device.Metadata["node_id"] = fwInfo.NodeID - components, err := a.inventoryInfo(ctx) + switch device.Model { + case E3C246D4ID_NL, E3C246D4I_NL: + return a.componentAttributesE3C246(ctx, fwInfo, device) + default: + return nil + } +} + +func (a *ASRockRack) componentAttributesE3C246(ctx context.Context, fwInfo *firmwareInfo, device *common.Device) error { + // TODO: implement newer device inventory + components, err := a.inventoryInfoE3C246D41D(ctx) if err != nil { return err } @@ -181,7 +193,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/asrockrack/inventory_test.go b/providers/asrockrack/inventory_test.go index 5e7302cbc..8d7e1c3a7 100644 --- a/providers/asrockrack/inventory_test.go +++ b/providers/asrockrack/inventory_test.go @@ -7,15 +7,16 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_GetInventory(t *testing.T) { +func TestGetInventory(t *testing.T) { device, err := aClient.Inventory(context.TODO()) if err != nil { t.Fatal(err) } + aClient.deviceModel = E3C246D4I_NL assert.NotNil(t, device) assert.Equal(t, "ASRockRack", device.Vendor) - assert.Equal(t, "E3C246D4I-NL", device.Model) + assert.Equal(t, E3C246D4I_NL, device.Model) assert.Equal(t, "L2.07B", device.BIOS.Firmware.Installed) assert.Equal(t, "0.01.00", device.BMC.Firmware.Installed) diff --git a/providers/asrockrack/mock_test.go b/providers/asrockrack/mock_test.go index 3a603480b..b471f853a 100644 --- a/providers/asrockrack/mock_test.go +++ b/providers/asrockrack/mock_test.go @@ -82,6 +82,7 @@ func mockASRockBMC() *httptest.Server { // fw update endpoints - in order of invocation handler.HandleFunc("/api/maintenance/flash", bmcFirmwareUpgrade) handler.HandleFunc("/api/maintenance/firmware", bmcFirmwareUpgrade) + handler.HandleFunc("/api/maintenance/firmware/firmware", bmcFirmwareUpgrade) handler.HandleFunc("/api/maintenance/firmware/verification", bmcFirmwareUpgrade) handler.HandleFunc("/api/maintenance/firmware/upgrade", bmcFirmwareUpgrade) handler.HandleFunc("/api/maintenance/firmware/flash-progress", bmcFirmwareUpgrade) @@ -211,7 +212,7 @@ func bmcFirmwareUpgrade(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) // 2. upload firmware - case "/api/maintenance/firmware": + case "/api/maintenance/firmware", "/api/maintenance/firmware/firmware": // validate flash mode set if !fwUpgradeState.FlashModeSet { @@ -306,11 +307,6 @@ func session(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) } case "DELETE": - //1for h, values := range r.Header { - //1 for _, v := range values { - //1 fmt.Println(h, v) - //1 } - //1} if r.Header.Get("X-Csrftoken") != "l5L29IP7" { w.WriteHeader(http.StatusBadRequest) } diff --git a/providers/asrockrack/power.go b/providers/asrockrack/power.go index d8a8efa1b..b94f5a190 100644 --- a/providers/asrockrack/power.go +++ b/providers/asrockrack/power.go @@ -20,6 +20,30 @@ type power struct { func (a *ASRockRack) PowerStateGet(ctx context.Context) (state string, err error) { info, err := a.chassisStatusInfo(ctx) if err != nil { + if strings.Contains(err.Error(), "401") { + // during a BMC update, only the flash-progress endpoint can be queried + // and so we cannot determine server power status + // we don't return an error here because we don't want the bmclib client to retry another provider. + progress, err := a.flashProgress(ctx, "/api/maintenance/firmware/flash-progress") + if err == nil && progress.Action != "" { + a.log.V(2).WithValues( + "action", progress.Action, + "progress", progress.Progress, + "state", progress.State, + ).Info("bmc in flash mode, power status cannot be determined") + + return "", errors.Wrap( + bmclibErrs.ErrBMCUpdating, + fmt.Sprintf( + "action: %s, progress: %s, state: %d", + progress.Action, + progress.Progress, + progress.State, + ), + ) + } + } + return "", errors.Wrap(bmclibErrs.ErrPowerStatusRead, err.Error()) } @@ -105,7 +129,8 @@ func (a *ASRockRack) resetBMC(ctx context.Context) error { return err } - if statusCode != http.StatusOK { + // The E3C256D4ID BMC returns a 500 status error on the BMC reset request + if statusCode != http.StatusOK && statusCode != http.StatusInternalServerError { return fmt.Errorf("non 200 response: %d", statusCode) } diff --git a/providers/asrockrack/user_test.go b/providers/asrockrack/user_test.go index dc4dd8576..38d2a9b87 100644 --- a/providers/asrockrack/user_test.go +++ b/providers/asrockrack/user_test.go @@ -62,7 +62,7 @@ func Test_UserRead(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } users, err := aClient.UserRead(context.TODO()) @@ -159,7 +159,7 @@ func Test_UserUpdate(t *testing.T) { func Test_createUser(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } account := &UserAccount{ @@ -202,7 +202,7 @@ func Test_createUser(t *testing.T) { func Test_userAccounts(t *testing.T) { err := aClient.httpsLogin(context.TODO()) if err != nil { - t.Errorf(err.Error()) + t.Errorf("login: %s", err.Error()) } account0 := &UserAccount{ diff --git a/providers/dell/firmware.go b/providers/dell/firmware.go new file mode 100644 index 000000000..47ca86ded --- /dev/null +++ b/providers/dell/firmware.go @@ -0,0 +1,231 @@ +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" +) + +// 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, bmcliberrs.NewErrUnsupportedHardware(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 "", bmcliberrs.NewErrUnsupportedHardware(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 "", "", bmcliberrs.NewErrUnsupportedHardware(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 000000000..77f099e27 --- /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/fixtures/systems_embedded_no_manufacturer.1.json b/providers/dell/fixtures/systems_embedded_no_manufacturer.1.json new file mode 100644 index 000000000..9fe0cf73b --- /dev/null +++ b/providers/dell/fixtures/systems_embedded_no_manufacturer.1.json @@ -0,0 +1,525 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ComputerSystem.ComputerSystem", + "@odata.id": "/redfish/v1/Systems/System.Embedded.1", + "@odata.type": "#ComputerSystem.v1_12_0.ComputerSystem", + "Actions": { + "#ComputerSystem.Reset": { + "target": "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", + "ResetType@Redfish.AllowableValues": [ + "On", + "ForceOff", + "ForceRestart", + "GracefulRestart", + "GracefulShutdown", + "PushPowerButton", + "Nmi", + "PowerCycle" + ] + } + }, + "AssetTag": "", + "Bios": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Bios" + }, + "BiosVersion": "2.6.6", + "Boot": { + "BootOptions": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/BootOptions" + }, + "Certificates": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Boot/Certificates" + }, + "BootOrder": [ + "HardDisk.List.1-1", + "NIC.Slot.3-1-1" + ], + "BootOrder@odata.count": 2, + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideMode": "Legacy", + "BootSourceOverrideTarget": "None", + "UefiTargetBootSourceOverride": null, + "BootSourceOverrideTarget@Redfish.AllowableValues": [ + "None", + "Pxe", + "Floppy", + "Cd", + "Hdd", + "BiosSetup", + "Utilities", + "UefiTarget", + "SDCard", + "UefiHttp" + ] + }, + "Description": "Computer System which represents a machine (physical or virtual) and the local resources such as memory, cpu and other devices that can be accessed from that machine.", + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/EthernetInterfaces" + }, + "HostName": "foobaz", + "HostWatchdogTimer": { + "FunctionEnabled": false, + "Status": { + "State": "Disabled" + }, + "TimeoutAction": "None" + }, + "HostingRoles": [], + "HostingRoles@odata.count": 0, + "Id": "System.Embedded.1", + "IndicatorLED": "Lit", + "Links": { + "Chassis": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1" + } + ], + "Chassis@odata.count": 1, + "CooledBy": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/0" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/1" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/2" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/3" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/4" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/5" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/6" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/7" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/8" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/9" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/10" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/11" + } + ], + "CooledBy@odata.count": 12, + "ManagedBy": [ + { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1" + } + ], + "ManagedBy@odata.count": 1, + "Oem": { + "Dell": { + "@odata.type": "#DellOem.v1_2_0.DellOemLinks", + "BootOrder": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBootSources" + }, + "DellBootSources": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBootSources" + }, + "DellSoftwareInstallationService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSoftwareInstallationService" + }, + "DellVideoCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellVideo" + }, + "DellChassisCollection": { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Oem/Dell/DellChassis" + }, + "DellPresenceAndStatusSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellPresenceAndStatusSensors" + }, + "DellSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSensors" + }, + "DellRollupStatusCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRollupStatus" + }, + "DellPSNumericSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellPSNumericSensors" + }, + "DellVideoNetworkCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellVideoNetwork" + }, + "DellOSDeploymentService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellOSDeploymentService" + }, + "DellMetricService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellMetricService" + }, + "DellGPUSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellGPUSensors" + }, + "DellRaidService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" + }, + "DellNumericSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellNumericSensors" + }, + "DellBIOSService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBIOSService" + }, + "DellSlotCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSlots" + } + } + }, + "PoweredBy": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerSupplies/0" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerSupplies/1" + } + ], + "PoweredBy@odata.count": 2 + }, + "Manufacturer": "", + "Memory": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Memory" + }, + "MemorySummary": { + "MemoryMirroring": "System", + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + }, + "TotalSystemMemoryGiB": 256 + }, + "Model": "PowerEdge R6515", + "Name": "System", + "NetworkInterfaces": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/NetworkInterfaces" + }, + "Oem": { + "Dell": { + "@odata.type": "#DellOem.v1_2_0.DellOemResources", + "DellSystem": { + "BIOSReleaseDate": "01/13/2022", + "BaseBoardChassisSlot": "NA", + "BatteryRollupStatus": "OK", + "BladeGeometry": "NotApplicable", + "CMCIP": null, + "CPURollupStatus": "OK", + "ChassisModel": "", + "ChassisName": "Main System Chassis", + "ChassisServiceTag": "FOOBAR", + "ChassisSystemHeightUnit": 1, + "CurrentRollupStatus": "OK", + "EstimatedExhaustTemperatureCelsius": 255, + "EstimatedSystemAirflowCFM": 255, + "ExpressServiceCode": "1234567819", + "FanRollupStatus": "OK", + "Id": "System.Embedded.1", + "IDSDMRollupStatus": null, + "IntrusionRollupStatus": "OK", + "IsOEMBranded": "False", + "LastSystemInventoryTime": "2023-04-28T04:00:49+00:00", + "LastUpdateTime": "2022-10-11T21:35:12+00:00", + "LicensingRollupStatus": "OK", + "ManagedSystemSize": "1 U", + "MaxCPUSockets": 1, + "MaxDIMMSlots": 16, + "MaxPCIeSlots": 5, + "MemoryOperationMode": "OptimizerMode", + "Name": "DellSystem", + "NodeID": "FOOBAR", + "PSRollupStatus": "OK", + "PlatformGUID": "33435a4f-c0c6-4780-5210-00304c4c4544", + "PopulatedDIMMSlots": 8, + "PopulatedPCIeSlots": 2, + "PowerCapEnabledState": "Disabled", + "SDCardRollupStatus": null, + "SELRollupStatus": "OK", + "ServerAllocationWatts": null, + "StorageRollupStatus": "OK", + "SysMemErrorMethodology": "Multi-bitECC", + "SysMemFailOverState": "NotInUse", + "SysMemLocation": "SystemBoardOrMotherboard", + "SysMemPrimaryStatus": "OK", + "SystemGeneration": "15G Monolithic", + "SystemID": 2300, + "SystemRevision": "I", + "TempRollupStatus": "OK", + "TempStatisticsRollupStatus": "OK", + "UUID": "4c4c4544-0030-5210-8047-c6c04f5a4333", + "VoltRollupStatus": "OK", + "smbiosGUID": "44454c4c-3000-1052-8047-c6c04f5a4333", + "@odata.context": "/redfish/v1/$metadata#DellSystem.DellSystem", + "@odata.type": "#DellSystem.v1_3_0.DellSystem", + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSystem/System.Embedded.1" + } + } + }, + "PCIeDevices": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/70-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/69-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/194-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/72-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/195-0" + } + ], + "PCIeDevices@odata.count": 34, + "PCIeFunctions": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-3/PCIeFunctions/0-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-2/PCIeFunctions/0-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-4/PCIeFunctions/0-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-7/PCIeFunctions/0-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-1/PCIeFunctions/0-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20/PCIeFunctions/0-20-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-8/PCIeFunctions/0-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20/PCIeFunctions/0-20-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/70-0/PCIeFunctions/70-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0/PCIeFunctions/65-0-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0/PCIeFunctions/65-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/69-0/PCIeFunctions/69-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-2/PCIeFunctions/192-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-1/PCIeFunctions/192-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/194-0/PCIeFunctions/194-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/4-0/PCIeFunctions/4-0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-3/PCIeFunctions/192-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-4/PCIeFunctions/192-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-8/PCIeFunctions/192-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-0/PCIeFunctions/192-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-7/PCIeFunctions/192-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/1-0/PCIeFunctions/1-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-3/PCIeFunctions/128-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-2/PCIeFunctions/128-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/72-0/PCIeFunctions/72-0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-4/PCIeFunctions/128-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-7/PCIeFunctions/128-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-1/PCIeFunctions/128-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-8/PCIeFunctions/128-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-3/PCIeFunctions/64-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-2/PCIeFunctions/64-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-4/PCIeFunctions/64-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-7/PCIeFunctions/64-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-1/PCIeFunctions/64-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-8/PCIeFunctions/64-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/195-0/PCIeFunctions/195-0-0" + } + ], + "PCIeFunctions@odata.count": 36, + "PartNumber": "07PXPYA01", + "PowerState": "On", + "ProcessorSummary": { + "Count": 1, + "LogicalProcessorCount": 32, + "Model": "AMD EPYC 7502P 32-Core Processor", + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + } + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Processors" + }, + "SKU": "FOOBAR", + "SecureBoot": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/SecureBoot" + }, + "SerialNumber": "FOOBAR123", + "SimpleStorage": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/SimpleStorage" + }, + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + }, + "Storage": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Storage" + }, + "SystemType": "Physical", + "TrustedModules": [ + { + "FirmwareVersion": "1.3.2.8", + "InterfaceType": "TPM2_0", + "Status": { + "State": "Enabled" + } + } + ], + "TrustedModules@odata.count": 1, + "UUID": "4c4c4544-0030-5210-8047-c6c04f5a4333" +} \ No newline at end of file diff --git a/providers/dell/fixtures/systems_embedded_not_dell.1.json b/providers/dell/fixtures/systems_embedded_not_dell.1.json new file mode 100644 index 000000000..b282135cf --- /dev/null +++ b/providers/dell/fixtures/systems_embedded_not_dell.1.json @@ -0,0 +1,525 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ComputerSystem.ComputerSystem", + "@odata.id": "/redfish/v1/Systems/System.Embedded.1", + "@odata.type": "#ComputerSystem.v1_12_0.ComputerSystem", + "Actions": { + "#ComputerSystem.Reset": { + "target": "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", + "ResetType@Redfish.AllowableValues": [ + "On", + "ForceOff", + "ForceRestart", + "GracefulRestart", + "GracefulShutdown", + "PushPowerButton", + "Nmi", + "PowerCycle" + ] + } + }, + "AssetTag": "", + "Bios": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Bios" + }, + "BiosVersion": "2.6.6", + "Boot": { + "BootOptions": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/BootOptions" + }, + "Certificates": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Boot/Certificates" + }, + "BootOrder": [ + "HardDisk.List.1-1", + "NIC.Slot.3-1-1" + ], + "BootOrder@odata.count": 2, + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideMode": "Legacy", + "BootSourceOverrideTarget": "None", + "UefiTargetBootSourceOverride": null, + "BootSourceOverrideTarget@Redfish.AllowableValues": [ + "None", + "Pxe", + "Floppy", + "Cd", + "Hdd", + "BiosSetup", + "Utilities", + "UefiTarget", + "SDCard", + "UefiHttp" + ] + }, + "Description": "Computer System which represents a machine (physical or virtual) and the local resources such as memory, cpu and other devices that can be accessed from that machine.", + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/EthernetInterfaces" + }, + "HostName": "foobaz", + "HostWatchdogTimer": { + "FunctionEnabled": false, + "Status": { + "State": "Disabled" + }, + "TimeoutAction": "None" + }, + "HostingRoles": [], + "HostingRoles@odata.count": 0, + "Id": "System.Embedded.1", + "IndicatorLED": "Lit", + "Links": { + "Chassis": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1" + } + ], + "Chassis@odata.count": 1, + "CooledBy": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/0" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/1" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/2" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/3" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/4" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/5" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/6" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/7" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/8" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/9" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/10" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Thermal#/Fans/11" + } + ], + "CooledBy@odata.count": 12, + "ManagedBy": [ + { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1" + } + ], + "ManagedBy@odata.count": 1, + "Oem": { + "Dell": { + "@odata.type": "#DellOem.v1_2_0.DellOemLinks", + "BootOrder": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBootSources" + }, + "DellBootSources": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBootSources" + }, + "DellSoftwareInstallationService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSoftwareInstallationService" + }, + "DellVideoCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellVideo" + }, + "DellChassisCollection": { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Oem/Dell/DellChassis" + }, + "DellPresenceAndStatusSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellPresenceAndStatusSensors" + }, + "DellSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSensors" + }, + "DellRollupStatusCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRollupStatus" + }, + "DellPSNumericSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellPSNumericSensors" + }, + "DellVideoNetworkCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellVideoNetwork" + }, + "DellOSDeploymentService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellOSDeploymentService" + }, + "DellMetricService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellMetricService" + }, + "DellGPUSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellGPUSensors" + }, + "DellRaidService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" + }, + "DellNumericSensorCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellNumericSensors" + }, + "DellBIOSService": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellBIOSService" + }, + "DellSlotCollection": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSlots" + } + } + }, + "PoweredBy": [ + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerSupplies/0" + }, + { + "@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerSupplies/1" + } + ], + "PoweredBy@odata.count": 2 + }, + "Manufacturer": "bmclib", + "Memory": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Memory" + }, + "MemorySummary": { + "MemoryMirroring": "System", + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + }, + "TotalSystemMemoryGiB": 256 + }, + "Model": "PowerEdge R6515", + "Name": "System", + "NetworkInterfaces": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/NetworkInterfaces" + }, + "Oem": { + "Dell": { + "@odata.type": "#DellOem.v1_2_0.DellOemResources", + "DellSystem": { + "BIOSReleaseDate": "01/13/2022", + "BaseBoardChassisSlot": "NA", + "BatteryRollupStatus": "OK", + "BladeGeometry": "NotApplicable", + "CMCIP": null, + "CPURollupStatus": "OK", + "ChassisModel": "", + "ChassisName": "Main System Chassis", + "ChassisServiceTag": "FOOBAR", + "ChassisSystemHeightUnit": 1, + "CurrentRollupStatus": "OK", + "EstimatedExhaustTemperatureCelsius": 255, + "EstimatedSystemAirflowCFM": 255, + "ExpressServiceCode": "1234567819", + "FanRollupStatus": "OK", + "Id": "System.Embedded.1", + "IDSDMRollupStatus": null, + "IntrusionRollupStatus": "OK", + "IsOEMBranded": "False", + "LastSystemInventoryTime": "2023-04-28T04:00:49+00:00", + "LastUpdateTime": "2022-10-11T21:35:12+00:00", + "LicensingRollupStatus": "OK", + "ManagedSystemSize": "1 U", + "MaxCPUSockets": 1, + "MaxDIMMSlots": 16, + "MaxPCIeSlots": 5, + "MemoryOperationMode": "OptimizerMode", + "Name": "DellSystem", + "NodeID": "FOOBAR", + "PSRollupStatus": "OK", + "PlatformGUID": "33435a4f-c0c6-4780-5210-00304c4c4544", + "PopulatedDIMMSlots": 8, + "PopulatedPCIeSlots": 2, + "PowerCapEnabledState": "Disabled", + "SDCardRollupStatus": null, + "SELRollupStatus": "OK", + "ServerAllocationWatts": null, + "StorageRollupStatus": "OK", + "SysMemErrorMethodology": "Multi-bitECC", + "SysMemFailOverState": "NotInUse", + "SysMemLocation": "SystemBoardOrMotherboard", + "SysMemPrimaryStatus": "OK", + "SystemGeneration": "15G Monolithic", + "SystemID": 2300, + "SystemRevision": "I", + "TempRollupStatus": "OK", + "TempStatisticsRollupStatus": "OK", + "UUID": "4c4c4544-0030-5210-8047-c6c04f5a4333", + "VoltRollupStatus": "OK", + "smbiosGUID": "44454c4c-3000-1052-8047-c6c04f5a4333", + "@odata.context": "/redfish/v1/$metadata#DellSystem.DellSystem", + "@odata.type": "#DellSystem.v1_3_0.DellSystem", + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellSystem/System.Embedded.1" + } + } + }, + "PCIeDevices": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/70-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/69-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/194-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/72-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-2" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-4" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-7" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-8" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/195-0" + } + ], + "PCIeDevices@odata.count": 34, + "PCIeFunctions": [ + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-3/PCIeFunctions/0-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-2/PCIeFunctions/0-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-4/PCIeFunctions/0-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-7/PCIeFunctions/0-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-1/PCIeFunctions/0-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20/PCIeFunctions/0-20-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-8/PCIeFunctions/0-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/0-20/PCIeFunctions/0-20-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/70-0/PCIeFunctions/70-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0/PCIeFunctions/65-0-1" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/65-0/PCIeFunctions/65-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/69-0/PCIeFunctions/69-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-2/PCIeFunctions/192-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-1/PCIeFunctions/192-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/194-0/PCIeFunctions/194-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/4-0/PCIeFunctions/4-0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-3/PCIeFunctions/192-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-4/PCIeFunctions/192-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-8/PCIeFunctions/192-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-0/PCIeFunctions/192-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/192-7/PCIeFunctions/192-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/1-0/PCIeFunctions/1-0-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-3/PCIeFunctions/128-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-2/PCIeFunctions/128-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/72-0/PCIeFunctions/72-0-3" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-4/PCIeFunctions/128-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-7/PCIeFunctions/128-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-1/PCIeFunctions/128-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/128-8/PCIeFunctions/128-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-3/PCIeFunctions/64-3-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-2/PCIeFunctions/64-2-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-4/PCIeFunctions/64-4-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-7/PCIeFunctions/64-7-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-1/PCIeFunctions/64-1-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/64-8/PCIeFunctions/64-8-0" + }, + { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/PCIeDevices/195-0/PCIeFunctions/195-0-0" + } + ], + "PCIeFunctions@odata.count": 36, + "PartNumber": "07PXPYA01", + "PowerState": "On", + "ProcessorSummary": { + "Count": 1, + "LogicalProcessorCount": 32, + "Model": "AMD EPYC 7502P 32-Core Processor", + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + } + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Processors" + }, + "SKU": "FOOBAR", + "SecureBoot": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/SecureBoot" + }, + "SerialNumber": "FOOBAR123", + "SimpleStorage": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/SimpleStorage" + }, + "Status": { + "Health": "OK", + "HealthRollup": "OK", + "State": "Enabled" + }, + "Storage": { + "@odata.id": "/redfish/v1/Systems/System.Embedded.1/Storage" + }, + "SystemType": "Physical", + "TrustedModules": [ + { + "FirmwareVersion": "1.3.2.8", + "InterfaceType": "TPM2_0", + "Status": { + "State": "Enabled" + } + } + ], + "TrustedModules@odata.count": 1, + "UUID": "4c4c4544-0030-5210-8047-c6c04f5a4333" +} \ No newline at end of file diff --git a/providers/dell/idrac.go b/providers/dell/idrac.go index 8535026c4..7ea6e7245 100644 --- a/providers/dell/idrac.go +++ b/providers/dell/idrac.go @@ -36,16 +36,24 @@ var ( // Features implemented by dell redfish Features = registrar.Features{ providers.FeatureScreenshot, + providers.FeaturePowerState, + providers.FeaturePowerSet, + providers.FeatureFirmwareInstallSteps, + providers.FeatureFirmwareUploadInitiateInstall, + providers.FeatureFirmwareTaskStatus, + providers.FeatureInventoryRead, + providers.FeatureBmcReset, + providers.FeatureGetBiosConfiguration, + providers.FeatureSetBiosConfiguration, + providers.FeatureResetBiosConfiguration, } + + errManufacturerUnknown = errors.New("error identifying device manufacturer") ) type Config struct { - HttpClient *http.Client - Port string - // VersionsNotCompatible is the list of incompatible redfish versions. - // - // With this option set, The bmclib.Registry.FilterForCompatible(ctx) method will not proceed on - // devices with the given redfish version(s). + HttpClient *http.Client + Port string VersionsNotCompatible []string RootCAs *x509.CertPool UseBasicAuth bool @@ -126,13 +134,26 @@ func (c *Conn) Open(ctx context.Context) (err error) { // because this uses the redfish interface and the redfish interface // is available across various BMC vendors, we verify the device we're connected to is dell. + if err := c.deviceSupported(ctx); err != nil { + if er := c.redfishwrapper.Close(ctx); er != nil { + return fmt.Errorf("%v: %w", err, er) + } + + return err + } + + return nil +} + +func (c *Conn) deviceSupported(ctx context.Context) error { manufacturer, err := c.deviceManufacturer(ctx) if err != nil { return err } - if !strings.Contains(strings.ToLower(manufacturer), common.VendorDell) { - return bmclibErrs.ErrIncompatibleProvider + m := strings.ToLower(manufacturer) + if !strings.Contains(m, common.VendorDell) { + return errors.Wrap(bmclibErrs.ErrIncompatibleProvider, m) } return nil @@ -186,13 +207,45 @@ func (c *Conn) PowerStateGet(ctx context.Context) (state string, err error) { return c.redfishwrapper.SystemPowerStatus(ctx) } +// PowerSet sets the power state of a server +func (c *Conn) PowerSet(ctx context.Context, state string) (ok bool, err error) { + return c.redfishwrapper.PowerSet(ctx, state) +} + +// Inventory collects hardware inventory and install firmware information +func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) { + return c.redfishwrapper.Inventory(ctx, false) +} + +// BmcReset power cycles the BMC +func (c *Conn) BmcReset(ctx context.Context, resetType string) (ok bool, err error) { + return c.redfishwrapper.BMCReset(ctx, resetType) +} + +// GetBiosConfiguration returns the BIOS configuration settings via the BMC +func (c *Conn) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { + return c.redfishwrapper.GetBiosConfiguration(ctx) +} + +// SetBiosConfiguration sets the BIOS configuration settings via the BMC +func (c *Conn) SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) { + return c.redfishwrapper.SetBiosConfiguration(ctx, biosConfig) +} + +// ResetBiosConfiguration resets the BIOS configuration settings back to 'factory defaults' via the BMC +func (c *Conn) ResetBiosConfiguration(ctx context.Context) (err error) { + return c.redfishwrapper.ResetBiosConfiguration(ctx) +} + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Conn) SendNMI(ctx context.Context) error { + return c.redfishwrapper.SendNMI(ctx) +} + // deviceManufacturer returns the device manufacturer and model attributes func (c *Conn) deviceManufacturer(ctx context.Context) (vendor string, err error) { - errManufacturerUnknown := errors.New("error identifying device manufacturer") - systems, err := c.redfishwrapper.Systems() if err != nil { - fmt.Println(err.Error()) return "", errors.Wrap(errManufacturerUnknown, err.Error()) } diff --git a/providers/dell/idrac_test.go b/providers/dell/idrac_test.go index 575fa5743..69a91a682 100644 --- a/providers/dell/idrac_test.go +++ b/providers/dell/idrac_test.go @@ -3,6 +3,7 @@ package dell import ( "context" "encoding/base64" + "errors" "fmt" "io" "log" @@ -12,6 +13,7 @@ import ( "os" "testing" + berrors "github.com/bmc-toolbox/bmclib/v2/errors" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" ) @@ -20,6 +22,30 @@ const ( fixturesDir = "./fixtures" ) +var endpointFunc = func(file string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // expect either GET or Delete methods + if r.Method != http.MethodGet && r.Method != http.MethodDelete { + w.WriteHeader(http.StatusNotFound) + } + + fixture := fixturesDir + file + 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) + } + + _, _ = w.Write(b) + } +} + func Test_Screenshot(t *testing.T) { // byte slice instead of a real image img := []byte(`foobar`) @@ -37,72 +63,9 @@ func Test_Screenshot(t *testing.T) { []byte(`foobar`), handlerFuncMap{ // service root - "/redfish/v1/": func(w http.ResponseWriter, r *http.Request) { - // expect either GET or Delete methods - if r.Method != http.MethodGet && r.Method != http.MethodDelete { - w.WriteHeader(http.StatusNotFound) - } - - fixture := fixturesDir + "/serviceroot.json" - 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) - } - - _, _ = w.Write(b) - }, - - "/redfish/v1/Systems": func(w http.ResponseWriter, r *http.Request) { - // expect either GET or Delete methods - if r.Method != http.MethodGet && r.Method != http.MethodDelete { - w.WriteHeader(http.StatusNotFound) - } - - fixture := fixturesDir + "/systems.json" - 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) - } - - _, _ = w.Write(b) - }, - - "/redfish/v1/Systems/System.Embedded.1": func(w http.ResponseWriter, r *http.Request) { - // expect either GET or Delete methods - if r.Method != http.MethodGet && r.Method != http.MethodDelete { - w.WriteHeader(http.StatusNotFound) - } - - fixture := fixturesDir + "/systems_embedded.1.json" - 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) - } - - _, _ = w.Write(b) - }, - + "/redfish/v1/": endpointFunc("/serviceroot.json"), + "/redfish/v1/Systems": endpointFunc("/systems.json"), + "/redfish/v1/Systems/System.Embedded.1": endpointFunc("/systems_embedded.1.json"), // screenshot endpoint redfishV1Prefix + screenshotEndpoint: func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, r.Method, http.MethodPost) @@ -158,3 +121,54 @@ func Test_Screenshot(t *testing.T) { }) } } + +func TestOpenErrors(t *testing.T) { + tests := map[string]struct { + fns map[string]func(http.ResponseWriter, *http.Request) + err error + }{ + "not dell manufacturer": { + fns: map[string]func(http.ResponseWriter, *http.Request){ + // service root + "/redfish/v1/": endpointFunc("/serviceroot.json"), + "/redfish/v1/Systems": endpointFunc("/systems.json"), + "/redfish/v1/Systems/System.Embedded.1": endpointFunc("/systems_embedded_not_dell.1.json"), + }, + err: berrors.ErrIncompatibleProvider, + }, + "manufacturer failure": { + fns: map[string]func(http.ResponseWriter, *http.Request){ + // service root + "/redfish/v1/": endpointFunc("/serviceroot.json"), + "/redfish/v1/Systems": endpointFunc("/systems.json"), + "/redfish/v1/Systems/System.Embedded.1": endpointFunc("/systems_embedded_no_manufacturer.1.json"), + }, + err: errManufacturerUnknown, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + handleFunc := tc.fns + 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) + } + + client := New(parsedURL.Hostname(), "", "", logr.Discard(), WithPort(parsedURL.Port()), WithUseBasicAuth(true)) + + err = client.Open(context.TODO()) + if !errors.Is(err, tc.err) { + t.Fatalf("expected %v, got %v", tc.err, err) + } + client.Close(context.Background()) + }) + } +} diff --git a/providers/ipmitool/ipmitool.go b/providers/ipmitool/ipmitool.go index e104884e7..69f37ccc6 100644 --- a/providers/ipmitool/ipmitool.go +++ b/providers/ipmitool/ipmitool.go @@ -27,6 +27,10 @@ var ( providers.FeatureUserRead, providers.FeatureBmcReset, providers.FeatureBootDeviceSet, + providers.FeatureClearSystemEventLog, + providers.FeatureGetSystemEventLog, + providers.FeatureGetSystemEventLogRaw, + providers.FeatureDeactivateSOL, } ) @@ -146,6 +150,11 @@ func (c *Conn) BmcReset(ctx context.Context, resetType string) (ok bool, err err return c.ipmitool.PowerResetBmc(ctx, resetType) } +// DeactivateSOL will deactivate active SOL sessions +func (c *Conn) DeactivateSOL(ctx context.Context) (err error) { + return c.ipmitool.DeactivateSOL(ctx) +} + // UserRead list all users func (c *Conn) UserRead(ctx context.Context) (users []map[string]string, err error) { return c.ipmitool.ReadUsers(ctx) @@ -180,3 +189,20 @@ func (c *Conn) PowerSet(ctx context.Context, state string) (ok bool, err error) return ok, err } + +func (c *Conn) ClearSystemEventLog(ctx context.Context) (err error) { + return c.ipmitool.ClearSystemEventLog(ctx) +} + +func (c *Conn) GetSystemEventLog(ctx context.Context) (entries [][]string, err error) { + return c.ipmitool.GetSystemEventLog(ctx) +} + +func (c *Conn) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + return c.ipmitool.GetSystemEventLogRaw(ctx) +} + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Conn) SendNMI(ctx context.Context) error { + return c.ipmitool.SendPowerDiag(ctx) +} diff --git a/providers/ipmitool/ipmitool_test.go b/providers/ipmitool/ipmitool_test.go index ffe8b7bb7..de395bc18 100644 --- a/providers/ipmitool/ipmitool_test.go +++ b/providers/ipmitool/ipmitool_test.go @@ -106,3 +106,93 @@ func TestBMCReset(t *testing.T) { t.Log(state) t.Fatal() } + +func TestDeactivateSOL(t *testing.T) { + t.Skip("need real ipmi server") + host := "127.0.0.1" + port := "623" + user := "ADMIN" + pass := "ADMIN" + i, err := New(host, user, pass, WithPort(port), WithLogger(logging.DefaultLogger())) + if err != nil { + t.Fatal(err) + } + err = i.DeactivateSOL(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Log(err != nil) + t.Fatal() +} + +func TestSystemEventLogClear(t *testing.T) { + t.Skip("need real ipmi server") + host := "127.0.0.1" + port := "623" + user := "ADMIN" + pass := "ADMIN" + i, err := New(host, user, pass, WithPort(port), WithLogger(logging.DefaultLogger())) + if err != nil { + t.Fatal(err) + } + err = i.ClearSystemEventLog(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Log("System Event Log cleared") + t.Fatal() +} + +func TestSystemEventLogGet(t *testing.T) { + t.Skip("need real ipmi server") + host := "127.0.0.1" + port := "623" + user := "ADMIN" + pass := "ADMIN" + i, err := New(host, user, pass, WithPort(port), WithLogger(logging.DefaultLogger())) + if err != nil { + t.Fatal(err) + } + entries, err := i.GetSystemEventLog(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Log(entries) + t.Fatal() +} + +func TestSystemEventLogGetRaw(t *testing.T) { + t.Skip("need real ipmi server") + host := "127.0.0.1" + port := "623" + user := "ADMIN" + pass := "ADMIN" + i, err := New(host, user, pass, WithPort(port), WithLogger(logging.DefaultLogger())) + if err != nil { + t.Fatal(err) + } + eventlog, err := i.GetSystemEventLogRaw(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Log(eventlog) + t.Fatal() +} + +func TestSendNMI(t *testing.T) { + t.Skip("need real ipmi server") + host := "127.0.0.1" + port := "623" + user := "ADMIN" + pass := "ADMIN" + i, err := New(host, user, pass, WithPort(port), WithLogger(logging.DefaultLogger())) + if err != nil { + t.Fatal(err) + } + err = i.SendNMI(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Log("NMI sent") + t.Fatal() +} diff --git a/providers/openbmc/firmware.go b/providers/openbmc/firmware.go new file mode 100644 index 000000000..d5fd492a0 --- /dev/null +++ b/providers/openbmc/firmware.go @@ -0,0 +1,100 @@ +package openbmc + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/bmc-toolbox/bmclib/v2/constants" + "github.com/bmc-toolbox/common" + + bmcliberrs "github.com/bmc-toolbox/bmclib/v2/errors" + rfw "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" + "github.com/pkg/errors" + "github.com/stmcginnis/gofish/redfish" +) + +// 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, err + } + + switch strings.ToUpper(component) { + case common.SlugBIOS: + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepPowerOffHost, + constants.FirmwareInstallStepUploadInitiateInstall, + constants.FirmwareInstallStepInstallStatus, + }, nil + case common.SlugBMC: + return []constants.FirmwareInstallStep{ + constants.FirmwareInstallStepUploadInitiateInstall, + constants.FirmwareInstallStepInstallStatus, + }, nil + default: + return nil, errors.New("component firmware install not supported: " + component) + } +} + +func (c *Conn) FirmwareInstallUploadAndInitiate(ctx context.Context, component string, file *os.File) (taskID string, err error) { + if err := c.deviceSupported(ctx); err != nil { + return "", errNotOpenBMCDevice + } + + // // 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) +} + +// returns an error when a bmc firmware install is active +func (c *Conn) checkQueueability(component string, tasks []*redfish.Task) error { + errTaskActive := errors.New("A firmware job was found active for component: " + component) + + for _, t := range tasks { + // 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) { + return c.redfishwrapper.TaskStatus(ctx, taskID) +} diff --git a/providers/openbmc/openbmc.go b/providers/openbmc/openbmc.go new file mode 100644 index 000000000..8e9cde119 --- /dev/null +++ b/providers/openbmc/openbmc.go @@ -0,0 +1,191 @@ +package openbmc + +import ( + "bytes" + "context" + "crypto/x509" + "io" + "net/http" + "strings" + + "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/bmc-toolbox/common" + "github.com/go-logr/logr" + "github.com/jacobweinstock/registrar" + "github.com/pkg/errors" +) + +const ( + // ProviderName for the OpenBMC provider implementation + ProviderName = "openbmc" + // ProviderProtocol for the OpenBMC provider implementation + ProviderProtocol = "redfish" +) + +var ( + // Features implemented by dell redfish + Features = registrar.Features{ + providers.FeaturePowerState, + providers.FeaturePowerSet, + providers.FeatureBmcReset, + providers.FeatureFirmwareInstallSteps, + providers.FeatureFirmwareUploadInitiateInstall, + providers.FeatureFirmwareTaskStatus, + providers.FeatureInventoryRead, + } + + errNotOpenBMCDevice = errors.New("not an OpenBMC device") +) + +type Config struct { + HttpClient *http.Client + Port string + VersionsNotCompatible []string + RootCAs *x509.CertPool + UseBasicAuth bool +} + +// Option for setting optional Client values +type Option func(*Config) + +func WithHttpClient(httpClient *http.Client) Option { + return func(c *Config) { + c.HttpClient = httpClient + } +} + +func WithPort(port string) Option { + return func(c *Config) { + c.Port = port + } +} + +func WithRootCAs(rootCAs *x509.CertPool) Option { + return func(c *Config) { + c.RootCAs = rootCAs + } +} + +func WithUseBasicAuth(useBasicAuth bool) Option { + return func(c *Config) { + c.UseBasicAuth = useBasicAuth + } +} + +// Conn details for redfish client +type Conn struct { + host string + httpClient *http.Client + redfishwrapper *redfishwrapper.Client + Log logr.Logger +} + +// New returns connection with a redfish client initialized +func New(host, user, pass string, log logr.Logger, opts ...Option) *Conn { + defaultConfig := &Config{ + HttpClient: httpclient.Build(), + Port: "443", + VersionsNotCompatible: []string{}, + } + + for _, opt := range opts { + opt(defaultConfig) + } + + rfOpts := []redfishwrapper.Option{ + redfishwrapper.WithHTTPClient(defaultConfig.HttpClient), + redfishwrapper.WithBasicAuthEnabled(defaultConfig.UseBasicAuth), + redfishwrapper.WithEtagMatchDisabled(true), + } + + if defaultConfig.RootCAs != nil { + rfOpts = append(rfOpts, redfishwrapper.WithSecureTLS(defaultConfig.RootCAs)) + } + + return &Conn{ + host: host, + httpClient: defaultConfig.HttpClient, + Log: log, + redfishwrapper: redfishwrapper.NewClient(host, defaultConfig.Port, user, pass, rfOpts...), + } +} + +// Open a connection to a BMC via redfish +func (c *Conn) Open(ctx context.Context) (err error) { + if err := c.deviceSupported(ctx); err != nil { + return err + } + + if err := c.redfishwrapper.Open(ctx); err != nil { + return err + } + + return nil +} + +func (c *Conn) deviceSupported(ctx context.Context) error { + var host = c.host + if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") { + host = "https://" + host + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, host, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if !bytes.Contains(b, []byte(`OpenBMC`)) { + return errNotOpenBMCDevice + } + + return nil +} + +// Close a connection to a BMC via redfish +func (c *Conn) Close(ctx context.Context) error { + return c.redfishwrapper.Close(ctx) +} + +// Name returns the client provider name. +func (c *Conn) Name() string { + return ProviderName +} + +// PowerStateGet gets the power state of a BMC machine +func (c *Conn) PowerStateGet(ctx context.Context) (state string, err error) { + return c.redfishwrapper.SystemPowerStatus(ctx) +} + +// PowerSet sets the power state of a server +func (c *Conn) PowerSet(ctx context.Context, state string) (ok bool, err error) { + return c.redfishwrapper.PowerSet(ctx, state) +} + +// Inventory collects hardware inventory and install firmware information +func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) { + return c.redfishwrapper.Inventory(ctx, false) +} + +// BmcReset power cycles the BMC +func (c *Conn) BmcReset(ctx context.Context, resetType string) (ok bool, err error) { + return c.redfishwrapper.BMCReset(ctx, resetType) +} + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Conn) SendNMI(ctx context.Context) error { + return c.redfishwrapper.SendNMI(ctx) +} diff --git a/providers/providers.go b/providers/providers.go index 64c587a40..c87425808 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -23,7 +23,14 @@ const ( FeatureBootDeviceSet registrar.Feature = "bootdeviceset" // FeaturesVirtualMedia means an implementation can manage virtual media devices FeatureVirtualMedia registrar.Feature = "virtualmedia" + // FeatureMountFloppyImage means an implementation uploads a floppy image for mounting as virtual media. + // + // note: This is differs from FeatureVirtualMedia which is limited to accepting a URL to download the image from. + FeatureMountFloppyImage registrar.Feature = "mountFloppyImage" + // 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" @@ -33,4 +40,42 @@ const ( FeaturePostCodeRead registrar.Feature = "postcoderead" // FeatureScreenshot means an implementation that returns a screenshot of the video. FeatureScreenshot registrar.Feature = "screenshot" + // FeatureClearSystemEventLog means an implementation that clears the BMC System Event Log (SEL) + FeatureClearSystemEventLog registrar.Feature = "clearsystemeventlog" + // FeatureGetSystemEventLog means an implementation that returns the BMC System Event Log (SEL) + FeatureGetSystemEventLog registrar.Feature = "getsystemeventlog" + // FeatureGetSystemEventLogRaw means an implementation that returns the BMC System Event Log (SEL) in raw format + FeatureGetSystemEventLogRaw registrar.Feature = "getsystemeventlograw" + // FeatureFirmwareInstallSteps means an implementation returns the steps part of the firmware update process. + FeatureFirmwareInstallSteps registrar.Feature = "firmwareinstallsteps" + + // 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" + + // FeatureFirmwareUploadInitiateInstall identifies an implementation that uploads firmware _and_ initiates the install process. + FeatureFirmwareUploadInitiateInstall registrar.Feature = "uploadandinitiateinstall" + + // FeatureDeactivateSOL means an implementation that can deactivate active SOL sessions + FeatureDeactivateSOL registrar.Feature = "deactivatesol" + + // FeatureResetBiosConfiguration means an implementation that can reset bios configuration back to 'factory' defaults + FeatureResetBiosConfiguration registrar.Feature = "resetbiosconfig" + + // FeatureSetBiosConfiguration means an implementation that can set bios configuration from an input k/v map + FeatureSetBiosConfiguration registrar.Feature = "setbiosconfig" + + // FeatureSetBiosConfigurationFromFile means an implementation that can set bios configuration from a vendor specific text file + FeatureSetBiosConfigurationFromFile registrar.Feature = "setbiosconfigfile" + + // FeatureGetBiosConfiguration means an implementation that can get bios configuration in a simple k/v map + FeatureGetBiosConfiguration registrar.Feature = "getbiosconfig" + + // FeatureBootProgress indicates that the implementation supports reading the BootProgress from the BMC + FeatureBootProgress registrar.Feature = "bootprogress" ) diff --git a/providers/redfish/bios.go b/providers/redfish/bios.go deleted file mode 100644 index deb95ce5b..000000000 --- a/providers/redfish/bios.go +++ /dev/null @@ -1,36 +0,0 @@ -package redfish - -import ( - "context" - - bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" -) - -func (c *Conn) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { - systems, err := c.redfishwrapper.Systems() - if err != nil { - return nil, err - } - - biosConfig = make(map[string]string) - for _, sys := range systems { - if !compatibleOdataID(sys.ODataID, systemsOdataIDs) { - continue - } - - bios, err := sys.Bios() - if err != nil { - return nil, err - } - - if bios == nil { - return nil, bmclibErrs.ErrNoBiosAttributes - } - - for attr := range bios.Attributes { - biosConfig[attr] = bios.Attributes.String(attr) - } - } - - return biosConfig, nil -} diff --git a/providers/redfish/bios_test.go b/providers/redfish/bios_test.go deleted file mode 100644 index a4cd7a919..000000000 --- a/providers/redfish/bios_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package redfish - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_GetBiosConfiguration(t *testing.T) { - fixturePath := fixturesDir + "/v1/dell/bios.json" - fh, err := os.Open(fixturePath) - if err != nil { - log.Fatalf("%s, failed to open fixture: %s", err.Error(), fixturePath) - } - - defer fh.Close() - - b, err := io.ReadAll(fh) - if err != nil { - log.Fatalf("%s, failed to read fixture: %s", err.Error(), fixturePath) - } - - var bios map[string]any - err = json.Unmarshal([]byte(b), &bios) - if err != nil { - log.Fatalf("%s, failed to unmarshal fixture: %s", err.Error(), fixturePath) - } - - expectedBiosConfig := make(map[string]string) - for k, v := range bios["Attributes"].(map[string]any) { - expectedBiosConfig[k] = fmt.Sprintf("%v", v) - } - - tests := []struct { - testName string - expectedBiosConfig map[string]string - }{ - { - "GetBiosConfiguration", - expectedBiosConfig, - }, - } - - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - biosConfig, err := mockClient.GetBiosConfiguration(context.TODO()) - assert.Nil(t, err) - assert.Equal(t, tc.expectedBiosConfig, biosConfig) - }) - } -} diff --git a/providers/redfish/firmware.go b/providers/redfish/firmware.go deleted file mode 100644 index ca6231f4d..000000000 --- a/providers/redfish/firmware.go +++ /dev/null @@ -1,424 +0,0 @@ -package redfish - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/textproto" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/pkg/errors" - gofishrf "github.com/stmcginnis/gofish/redfish" - - "github.com/bmc-toolbox/bmclib/v2/constants" - bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/bmclib/v2/internal" -) - -var ( - errInsufficientCtxTimeout = errors.New("remaining context timeout insufficient to install firmware") - errMultiPartPayload = errors.New("error preparing multipart payload") -) - -// 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) { - // limit to *os.File until theres a need for other types of readers - updateFile, ok := reader.(*os.File) - if !ok { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "method expects an *os.File object") - } - - // validate firmware update mechanism is supported - err = c.firmwareUpdateCompatible(ctx) - if err != nil { - 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. - ctxDeadline, _ := ctx.Deadline() - if time.Until(ctxDeadline) < 10*time.Minute { - return "", errors.Wrap(errInsufficientCtxTimeout, " "+time.Until(ctxDeadline).String()) - } - - // list redfish firmware install task if theres one present - task, err := c.GetFirmwareInstallTaskQueued(ctx, component) - if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) - } - - if task != nil { - msg := fmt.Sprintf("task for %s firmware install present: %s", component, task.ID) - c.Log.V(2).Info("warn", msg) - - if forceInstall { - err = c.purgeQueuedFirmwareInstallTask(ctx, component) - if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error()) - } - } else { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, msg) - } - } - - updateParameters, err := json.Marshal(struct { - Targets []string `json:"Targets"` - RedfishOpApplyTime string `json:"@Redfish.OperationApplyTime"` - Oem struct{} `json:"Oem"` - }{ - []string{}, - applyAt, - struct{}{}, - }) - - if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, 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 - httpClientTimeout := c.redfishwrapper.HttpClientTimeout() - defer func() { - c.redfishwrapper.SetHttpClientTimeout(httpClientTimeout) - }() - - c.redfishwrapper.SetHttpClientTimeout(time.Until(ctxDeadline)) - - payload := &multipartPayload{ - updateParameters: updateParameters, - updateFile: updateFile, - } - - resp, err := c.runRequestWithMultipartPayload(http.MethodPost, "/redfish/v1/UpdateService/MultipartUpload", payload) - if err != nil { - return "", errors.Wrap(bmclibErrs.ErrFirmwareUpload, err.Error()) - } - - if resp.StatusCode != http.StatusAccepted { - return "", errors.Wrap( - bmclibErrs.ErrFirmwareUpload, - "non 202 status code returned: "+strconv.Itoa(resp.StatusCode), - ) - } - - // The response contains a location header pointing to the task URI - // Location: /redfish/v1/TaskService/Tasks/JID_467696020275 - if strings.Contains(resp.Header.Get("Location"), "JID_") { - taskID = strings.Split(resp.Header.Get("Location"), "JID_")[1] - } - - return taskID, nil -} - -type multipartPayload struct { - updateParameters []byte - updateFile *os.File -} - -// 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) - if err != nil { - return state, errors.Wrap(err, "unable to determine device vendor, model attributes") - } - - var task *gofishrf.Task - switch { - case strings.Contains(vendor, constants.Dell): - task, err = c.dellJobAsRedfishTask(taskID) - default: - err = errors.Wrap( - bmclibErrs.ErrNotImplemented, - "FirmwareInstallStatus() for vendor: "+vendor, - ) - } - - if err != nil { - return state, err - } - - if task == nil { - return state, errors.New("failed to lookup task status for task ID: " + taskID) - } - - state = strings.ToLower(string(task.TaskState)) - - // so much for standards... - switch state { - case "starting", "downloading", "downloaded": - return constants.FirmwareInstallInitializing, nil - case "running", "stopping", "cancelling", "scheduling": - return constants.FirmwareInstallRunning, nil - case "pending", "new": - return constants.FirmwareInstallQueued, nil - case "scheduled": - return constants.FirmwareInstallPowerCyleHost, nil - case "interrupted", "killed", "exception", "cancelled", "suspended", "failed": - return constants.FirmwareInstallFailed, nil - case "completed": - return constants.FirmwareInstallComplete, nil - default: - return constants.FirmwareInstallUnknown + ": " + state, nil - } - -} - -// firmwareUpdateCompatible retuns an error if the firmware update process for the BMC is not supported -func (c *Conn) firmwareUpdateCompatible(ctx context.Context) (err error) { - updateService, err := c.redfishwrapper.UpdateService() - if err != nil { - return err - } - - // TODO: check for redfish version - - // update service disabled - if !updateService.ServiceEnabled { - return errors.Wrap(bmclibErrs.ErrRedfishUpdateService, "service disabled") - } - - // for now we expect multipart HTTP push update support - if updateService.MultipartHTTPPushURI == "" { - return errors.Wrap(bmclibErrs.ErrRedfishUpdateService, "Multipart HTTP push updates not supported") - } - - return nil -} - -// 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 *Conn) runRequestWithMultipartPayload(method, 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.Log.Error(err, "multipart upload error occurred") - } - }() - - defer pipeWriter.Close() - - // Add UpdateParameters part - parametersPart, err := updateParametersFormField("UpdateParameters", form) - if err != nil { - c.Log.Error(errMultiPartPayload, err.Error()+": UpdateParameters part copy error") - - return - } - - if _, err = io.Copy(parametersPart, bytes.NewReader(payload.updateParameters)); err != nil { - c.Log.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.Log.Error(errMultiPartPayload, err.Error()+": UpdateFile part create error") - - return - } - - if _, err = io.Copy(updateFilePart, payload.updateFile); err != nil { - c.Log.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.redfishwrapper.RunRawRequestWithHeaders(method, url, reader, form.FormDataContentType(), headers) -} - -// 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.New("") - } - - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", `form-data; name="UpdateParameters"`) - h.Set("Content-Type", "application/json") - - return writer.CreatePart(h) -} - -// 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) - if err != nil { - return nil, errors.Wrap(err, "unable to determine device vendor, model attributes") - } - - var task *gofishrf.Task - - // check an update task for the component is currently scheduled - switch { - case strings.Contains(vendor, constants.Dell): - task, err = c.getDellFirmwareInstallTaskScheduled(component) - default: - err = errors.Wrap( - bmclibErrs.ErrNotImplemented, - "GetFirmwareInstallTask() for vendor: "+vendor, - ) - } - - if err != nil { - return nil, err - } - - return task, nil -} - -// 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) - if err != nil { - return errors.Wrap(err, "unable to determine device vendor, model attributes") - } - - // check an update task for the component is currently scheduled - switch { - case strings.Contains(vendor, constants.Dell): - err = c.dellPurgeScheduledFirmwareInstallJob(component) - default: - err = errors.Wrap( - bmclibErrs.ErrNotImplemented, - "purgeFirmwareInstallTask() for vendor: "+vendor, - ) - } - - return err -} diff --git a/providers/redfish/firmware_test.go b/providers/redfish/firmware_test.go deleted file mode 100644 index b0fbedeca..000000000 --- a/providers/redfish/firmware_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package redfish - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/bmc-toolbox/bmclib/v2/constants" - bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/common" -) - -// handler registered in mock_test.go -func multipartUpload(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 { - if !strings.Contains(string(body), want) { - fmt.Println(string(body)) - log.Fatal("expected value not in multipartUpload payload: " + string(want)) - } - } - - if r.Header.Get("Content-Length") != expectedContentLength { - log.Fatal("Header Content-Length does not match expected") - } - - w.Header().Add("Location", "/redfish/v1/TaskService/Tasks/JID_467696020275") - w.WriteHeader(http.StatusAccepted) -} - -func TestFirmwareInstall(t *testing.T) { - // curl -Lv -s -k -u root:calvin \ - // -F 'UpdateParameters={"Targets": [], "@Redfish.OperationApplyTime": "OnReset", "Oem": {}};type=application/json' \ - // -F'foo.bin=@/tmp/dummyfile;application/octet-stream' - // https://192.168.1.1/redfish/v1/UpdateService/MultipartUpload --trace-ascii /dev/stdout - - tmpdir := t.TempDir() - binPath := filepath.Join(tmpdir, "test.bin") - err := os.WriteFile(binPath, []byte(`HELLOWORLD`), 0600) - if err != nil { - t.Fatal(err) - } - - fh, err := os.Open(binPath) - if err != nil { - t.Fatalf("%s -> %s", err.Error(), binPath) - } - - defer os.Remove(binPath) - - tests := []struct { - component string - applyAt string - forceInstall bool - setRequiredTimeout bool - reader io.Reader - expectTaskID string - expectErr error - expectErrSubStr string - testName string - }{ - { - common.SlugBIOS, - constants.FirmwareApplyOnReset, - false, - false, - nil, - "", - bmclibErrs.ErrFirmwareInstall, - "method expects an *os.File object", - "expect *os.File object", - }, - { - common.SlugBIOS, - constants.FirmwareApplyOnReset, - false, - false, - &os.File{}, - "", - errInsufficientCtxTimeout, - "", - "remaining context deadline", - }, - { - common.SlugBIOS, - "invalidApplyAt", - false, - true, - &os.File{}, - "", - bmclibErrs.ErrFirmwareInstall, - "invalid applyAt parameter", - "applyAt parameter invalid", - }, - { - common.SlugBIOS, - constants.FirmwareApplyOnReset, - false, - true, - fh, - "467696020275", - bmclibErrs.ErrFirmwareInstall, - "task for BIOS firmware install present", - "task ID exists", - }, - { - common.SlugBIOS, - constants.FirmwareApplyOnReset, - true, - true, - fh, - "467696020275", - nil, - "task for BIOS firmware install present", - "task created (previous task purged with force)", - }, - } - - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.TODO(), 1*time.Second) - if tc.setRequiredTimeout { - ctx, cancel = context.WithTimeout(context.TODO(), 20*time.Minute) - } - - taskID, err := mockClient.FirmwareInstall(ctx, tc.component, 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)) - } - } else { - assert.Nil(t, err) - assert.Equal(t, tc.expectTaskID, taskID) - } - - defer cancel() - }) - } - -} - -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) - }) - } -} - -func TestFirmwareUpdateCompatible(t *testing.T) { - err := mockClient.firmwareUpdateCompatible(context.TODO()) - if err != nil { - t.Fatal(err) - } -} diff --git a/providers/redfish/fixtures/v1/dell/entries.json b/providers/redfish/fixtures/v1/dell/entries.json new file mode 100644 index 000000000..abcbbf3a9 --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/entries.json @@ -0,0 +1,50 @@ +{ + "@odata.context": "/redfish/v1/$metadata#LogEntryCollection.LogEntryCollection", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries", + "@odata.type": "#LogEntryCollection.LogEntryCollection", + "Description": "System Event Logs for this manager", + "Members": [ + { + "@odata.context": "/redfish/v1/$metadata#LogEntry.LogEntry", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/2", + "@odata.type": "#LogEntry.v1_6_1.LogEntry", + "Created": "2023-01-01T00:00:00-00:00", + "Description": "Log Entry 2", + "EntryCode": "Assert", + "EntryType": "SEL", + "GeneratorId": "0x0001", + "Id": "1", + "Links": {}, + "Message": "OEM software event.", + "MessageArgs": [], + "MessageArgs@odata.count": 0, + "MessageId": "d000000", + "Name": "Log Entry 2", + "SensorNumber": 999, + "SensorType": null, + "Severity": "OK" + }, + { + "@odata.context": "/redfish/v1/$metadata#LogEntry.LogEntry", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/1", + "@odata.type": "#LogEntry.v1_6_1.LogEntry", + "Created": "2023-01-01T00:00:00-00:00", + "Description": "Log Entry 1", + "EntryCode": "Deassert", + "EntryType": "SEL", + "GeneratorId": "0x0001", + "Id": "1", + "Links": {}, + "Message": "OEM software event.", + "MessageArgs": [], + "MessageArgs@odata.count": 0, + "MessageId": "d000000", + "Name": "Log Entry 1", + "SensorNumber": 999, + "SensorType": null, + "Severity": "OK" + } + ], + "Members@odata.count": 2, + "Name": "Log Entry Collection" +} diff --git a/providers/redfish/fixtures/v1/dell/logservices.json b/providers/redfish/fixtures/v1/dell/logservices.json new file mode 100644 index 000000000..a4518c5cb --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/logservices.json @@ -0,0 +1,13 @@ +{ + "@odata.context": "/redfish/v1/$metadata#LogServiceCollection.LogServiceCollection", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices", + "@odata.type": "#LogServiceCollection.LogServiceCollection", + "Description": "Collection of Log Services for this Manager", + "Members": [ + { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel" + } + ], + "Members@odata.count": 3, + "Name": "Log Service Collection" +} diff --git a/providers/redfish/fixtures/v1/dell/logservices.sel.json b/providers/redfish/fixtures/v1/dell/logservices.sel.json new file mode 100644 index 000000000..e974aa174 --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/logservices.sel.json @@ -0,0 +1,22 @@ +{ + "@odata.context": "/redfish/v1/$metadata#LogService.LogService", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel", + "@odata.type": "#LogService.v1_1_3.LogService", + "Actions": { + "#LogService.ClearLog": { + "target": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Actions/LogService.ClearLog" + } + }, + "DateTime": "2023-01-01T00:00:00-00:00", + "DateTimeLocalOffset": "00:00", + "Description": "SEL Log Service", + "Entries": { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries" + }, + "Id": "Sel", + "LogEntryType": "SEL", + "MaxNumberOfRecords": 1024, + "Name": "SEL Log Service", + "OverWritePolicy": "WrapsWhenFull", + "ServiceEnabled": true +} diff --git a/providers/redfish/fixtures/v1/dell/manager.idrac.embedded.1.json b/providers/redfish/fixtures/v1/dell/manager.idrac.embedded.1.json new file mode 100644 index 000000000..a78d532bc --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/manager.idrac.embedded.1.json @@ -0,0 +1,9 @@ +{ + "@odata.context": "/redfish/v1/$metadata#Manager.Manager", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1", + "@odata.type": "#Manager.v1_9_0.Manager", + "LogServices": { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices" + }, + "Comment": "This isn't part of the schema - this file contains only the bare minimum to make the schema validator happy" +} diff --git a/providers/redfish/fixtures/v1/dell/managers.json b/providers/redfish/fixtures/v1/dell/managers.json new file mode 100644 index 000000000..df2fea27c --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/managers.json @@ -0,0 +1,13 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ManagerCollection.ManagerCollection", + "@odata.id": "/redfish/v1/Managers", + "@odata.type": "#ManagerCollection.ManagerCollection", + "Description": "BMC", + "Members": [ + { + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1" + } + ], + "Members@odata.count": 1, + "Name": "Manager" +} diff --git a/providers/redfish/fixtures/v1/dell/selentries/1.json b/providers/redfish/fixtures/v1/dell/selentries/1.json new file mode 100644 index 000000000..b6adc669b --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/selentries/1.json @@ -0,0 +1,20 @@ +{ + "@odata.context": "/redfish/v1/$metadata#LogEntry.LogEntry", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/1", + "@odata.type": "#LogEntry.v1_6_1.LogEntry", + "Created": "2023-01-01T00:00:00-00:00", + "Description": "Log Entry 1", + "EntryCode": "Deassert", + "EntryType": "SEL", + "GeneratorId": "0x0001", + "Id": "1", + "Links": {}, + "Message": "OEM software event.", + "MessageArgs": [], + "MessageArgs@odata.count": 0, + "MessageId": "d000000", + "Name": "Log Entry 1", + "SensorNumber": 999, + "SensorType": null, + "Severity": "OK" +} diff --git a/providers/redfish/fixtures/v1/dell/selentries/2.json b/providers/redfish/fixtures/v1/dell/selentries/2.json new file mode 100644 index 000000000..3812f7a69 --- /dev/null +++ b/providers/redfish/fixtures/v1/dell/selentries/2.json @@ -0,0 +1,20 @@ +{ + "@odata.context": "/redfish/v1/$metadata#LogEntry.LogEntry", + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/2", + "@odata.type": "#LogEntry.v1_6_1.LogEntry", + "Created": "2023-01-01T00:00:00-00:00", + "Description": "Log Entry 2", + "EntryCode": "Assert", + "EntryType": "SEL", + "GeneratorId": "0x0001", + "Id": "1", + "Links": {}, + "Message": "OEM software event.", + "MessageArgs": [], + "MessageArgs@odata.count": 0, + "MessageId": "d000000", + "Name": "Log Entry 2", + "SensorNumber": 999, + "SensorType": null, + "Severity": "OK" +} diff --git a/providers/redfish/main_test.go b/providers/redfish/main_test.go index 924dfe8a9..2a71b8189 100644 --- a/providers/redfish/main_test.go +++ b/providers/redfish/main_test.go @@ -26,14 +26,17 @@ var ( // jsonResponse returns the fixture json response for a request URI func jsonResponse(endpoint string) []byte { jsonResponsesMap := map[string]string{ - "/redfish/v1/": fixturesDir + "/v1/serviceroot.json", - "/redfish/v1/UpdateService": fixturesDir + "/v1/updateservice.json", - "/redfish/v1/Systems": fixturesDir + "/v1/systems.json", - - "/redfish/v1/Systems/System.Embedded.1": fixturesDir + "/v1/dell/system.embedded.1.json", - "/redfish/v1/Systems/System.Embedded.1/Bios": fixturesDir + "/v1/dell/bios.json", - "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs?$expand=*($levels=1)": fixturesDir + "/v1/dell/jobs.json", - "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs/JID_467762674724": fixturesDir + "/v1/dell/job_delete_ok.json", + "/redfish/v1/Managers": fixturesDir + "/v1/dell/managers.json", + "/redfish/v1/Managers/iDRAC.Embedded.1": fixturesDir + "/v1/dell/manager.idrac.embedded.1.json", + "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices": fixturesDir + "/v1/dell/logservices.json", + "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel": fixturesDir + "/v1/dell/logservices.sel.json", + "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries": fixturesDir + "/v1/dell/entries.json", + "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/1": fixturesDir + "/v1/dell/selentries/1.json", + "/redfish/v1/Managers/iDRAC.Embedded.1/LogServices/Sel/Entries/2": fixturesDir + "/v1/dell/selentries/2.json", + + "/redfish/v1/": fixturesDir + "/v1/serviceroot.json", + "/redfish/v1/UpdateService": fixturesDir + "/v1/updateservice.json", + "/redfish/v1/Systems": fixturesDir + "/v1/systems.json", } fh, err := os.Open(jsonResponsesMap[endpoint]) @@ -57,9 +60,6 @@ func TestMain(m *testing.M) { handler := http.NewServeMux() handler.HandleFunc("/redfish/v1/", serviceRoot) handler.HandleFunc("/redfish/v1/SessionService/Sessions", sessionService) - handler.HandleFunc("/redfish/v1/UpdateService/MultipartUpload", multipartUpload) - handler.HandleFunc("/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs?$expand=*($levels=1)", dellJobs) - return httptest.NewTLSServer(handler) }() diff --git a/providers/redfish/redfish.go b/providers/redfish/redfish.go index 93bd6cece..07caa9207 100644 --- a/providers/redfish/redfish.go +++ b/providers/redfish/redfish.go @@ -4,15 +4,15 @@ import ( "context" "crypto/x509" "net/http" - "strings" "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/bmc-toolbox/common" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" - "github.com/pkg/errors" + "github.com/bmc-toolbox/bmclib/v2/bmc" bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" ) @@ -34,9 +34,11 @@ var ( providers.FeatureBootDeviceSet, providers.FeatureVirtualMedia, providers.FeatureInventoryRead, - providers.FeatureFirmwareInstall, - providers.FeatureFirmwareInstallStatus, providers.FeatureBmcReset, + providers.FeatureClearSystemEventLog, + providers.FeatureGetBiosConfiguration, + providers.FeatureSetBiosConfiguration, + providers.FeatureResetBiosConfiguration, } ) @@ -57,6 +59,9 @@ type Config struct { VersionsNotCompatible []string RootCAs *x509.CertPool UseBasicAuth bool + // DisableEtagMatch disables the If-Match Etag header from being included by the Gofish driver. + DisableEtagMatch bool + SystemName string } // Option for setting optional Client values @@ -92,6 +97,21 @@ func WithUseBasicAuth(useBasicAuth bool) Option { } } +func WithSystemName(name string) Option { + return func(c *Config) { + c.SystemName = name + } +} + +// WithEtagMatchDisabled disables the If-Match Etag header from being included by the Gofish driver. +// +// As of the current implementation this disables the header for POST/PATCH requests to the System entity endpoints. +func WithEtagMatchDisabled(d bool) Option { + return func(c *Config) { + c.DisableEtagMatch = d + } +} + // New returns connection with a redfish client initialized func New(host, user, pass string, log logr.Logger, opts ...Option) *Conn { defaultConfig := &Config{ @@ -106,10 +126,15 @@ func New(host, user, pass string, log logr.Logger, opts ...Option) *Conn { rfOpts := []redfishwrapper.Option{ redfishwrapper.WithHTTPClient(defaultConfig.HttpClient), redfishwrapper.WithVersionsNotCompatible(defaultConfig.VersionsNotCompatible), + redfishwrapper.WithEtagMatchDisabled(defaultConfig.DisableEtagMatch), + redfishwrapper.WithBasicAuthEnabled(defaultConfig.UseBasicAuth), + redfishwrapper.WithSystemName(defaultConfig.SystemName), } + if defaultConfig.RootCAs != nil { rfOpts = append(rfOpts, redfishwrapper.WithSecureTLS(defaultConfig.RootCAs)) } + return &Conn{ Log: log, failInventoryOnError: false, @@ -165,24 +190,6 @@ 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) { return c.redfishwrapper.BMCReset(ctx, resetType) @@ -195,20 +202,7 @@ func (c *Conn) PowerStateGet(ctx context.Context) (state string, err error) { // PowerSet sets the power state of a server func (c *Conn) PowerSet(ctx context.Context, state string) (ok bool, err error) { - switch strings.ToLower(state) { - case "on": - return c.redfishwrapper.SystemPowerOn(ctx) - case "off": - return c.redfishwrapper.SystemForceOff(ctx) - case "soft": - return c.redfishwrapper.SystemPowerOff(ctx) - case "reset": - return c.redfishwrapper.SystemReset(ctx) - case "cycle": - return c.redfishwrapper.SystemPowerCycle(ctx) - default: - return false, errors.New("unknown power action") - } + return c.redfishwrapper.PowerSet(ctx, state) } // BootDeviceSet sets the boot device @@ -216,7 +210,37 @@ func (c *Conn) BootDeviceSet(ctx context.Context, bootDevice string, setPersiste return c.redfishwrapper.SystemBootDeviceSet(ctx, bootDevice, setPersistent, efiBoot) } +// BootDeviceOverrideGet gets the boot override device information +func (c *Conn) BootDeviceOverrideGet(ctx context.Context) (bmc.BootDeviceOverride, error) { + return c.redfishwrapper.GetBootDeviceOverride(ctx) +} + // SetVirtualMedia sets the virtual media func (c *Conn) SetVirtualMedia(ctx context.Context, kind string, mediaURL string) (ok bool, err error) { return c.redfishwrapper.SetVirtualMedia(ctx, kind, mediaURL) } + +// Inventory collects hardware inventory and install firmware information +func (c *Conn) Inventory(ctx context.Context) (device *common.Device, err error) { + return c.redfishwrapper.Inventory(ctx, c.failInventoryOnError) +} + +// GetBiosConfiguration return bios configuration +func (c *Conn) GetBiosConfiguration(ctx context.Context) (biosConfig map[string]string, err error) { + return c.redfishwrapper.GetBiosConfiguration(ctx) +} + +// SetBiosConfiguration set bios configuration +func (c *Conn) SetBiosConfiguration(ctx context.Context, biosConfig map[string]string) (err error) { + return c.redfishwrapper.SetBiosConfiguration(ctx, biosConfig) +} + +// ResetBiosConfiguration set bios configuration +func (c *Conn) ResetBiosConfiguration(ctx context.Context) (err error) { + return c.redfishwrapper.ResetBiosConfiguration(ctx) +} + +// SendNMI tells the BMC to issue an NMI to the device +func (c *Conn) SendNMI(ctx context.Context) error { + return c.redfishwrapper.SendNMI(ctx) +} diff --git a/providers/redfish/sel.go b/providers/redfish/sel.go new file mode 100644 index 000000000..82adda44e --- /dev/null +++ b/providers/redfish/sel.go @@ -0,0 +1,15 @@ +package redfish + +import "context" + +func (c *Conn) ClearSystemEventLog(ctx context.Context) (err error) { + return c.redfishwrapper.ClearSystemEventLog(ctx) +} + +func (c *Conn) GetSystemEventLog(ctx context.Context) (entries [][]string, err error) { + return c.redfishwrapper.GetSystemEventLog(ctx) +} + +func (c *Conn) GetSystemEventLogRaw(ctx context.Context) (eventlog string, err error) { + return c.redfishwrapper.GetSystemEventLogRaw(ctx) +} diff --git a/providers/redfish/sel_test.go b/providers/redfish/sel_test.go new file mode 100644 index 000000000..1f41ceb71 --- /dev/null +++ b/providers/redfish/sel_test.go @@ -0,0 +1,29 @@ +package redfish + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Write tests for GetSystemEventLog +func Test_GetSystemEventLog(t *testing.T) { + entries, err := mockClient.GetSystemEventLog(context.TODO()) + if err != nil { + t.Fatal(err) + } + + assert.NotNil(t, entries) + assert.Equal(t, 2, len(entries)) +} + +// Write tests for GetSystemEventLogRaw +func Test_GetSystemEventLogRaw(t *testing.T) { + eventlog, err := mockClient.GetSystemEventLogRaw(context.Background()) + if err != nil { + t.Fatal(err) + } + + assert.NotNil(t, eventlog) +} diff --git a/providers/redfish/tasks.go b/providers/redfish/tasks.go deleted file mode 100644 index bc6aaeb1a..000000000 --- a/providers/redfish/tasks.go +++ /dev/null @@ -1,173 +0,0 @@ -package redfish - -import ( - "encoding/json" - "io" - "strconv" - "strings" - - bmcliberrs "github.com/bmc-toolbox/bmclib/v2/errors" - "github.com/bmc-toolbox/common" - "github.com/pkg/errors" - - gofishcommon "github.com/stmcginnis/gofish/common" - gofishrf "github.com/stmcginnis/gofish/redfish" -) - -// Dell specific redfish methods - -var ( - componentSlugDellJobName = 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", - } -) - -type dellJob struct { - PercentComplete int - OdataID string `json:"@odata.id"` - StartTime string - CompletionTime string - ID string - Message string - Name string - JobState string - JobType string -} - -type dellJobResponseData struct { - Members []*dellJob -} - -// dellJobID formats and returns taskID as a Dell Job ID -func dellJobID(id string) string { - if !strings.HasPrefix(id, "JID") { - return "JID_" + id - } - - return id -} - -func (c *Conn) getDellFirmwareInstallTaskScheduled(slug string) (*gofishrf.Task, error) { - // get tasks by state - tasks, err := c.dellJobs("scheduled") - if err != nil { - return nil, err - } - - // filter to match the task Name based on the component slug - for _, task := range tasks { - if task.Name == componentSlugDellJobName[strings.ToUpper(slug)] { - return task, nil - } - } - - return nil, nil -} - -func (c *Conn) dellPurgeScheduledFirmwareInstallJob(slug string) error { - // get tasks by state - tasks, err := c.dellJobs("scheduled") - if err != nil { - return err - } - - // filter to match the task Name based on the component slug - for _, task := range tasks { - if task.Name == componentSlugDellJobName[strings.ToUpper(slug)] { - err = c.dellPurgeJob(task.ID) - if err != nil { - return err - } - } - } - - return nil -} - -func (c *Conn) dellPurgeJob(id string) error { - id = dellJobID(id) - - endpoint := "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs/" + id - - resp, err := c.redfishwrapper.Delete(endpoint) - if err != nil { - return errors.Wrap(bmcliberrs.ErrTaskPurge, err.Error()) - } - - if resp.StatusCode != 200 { - return errors.Wrap(bmcliberrs.ErrTaskPurge, "response code: "+resp.Status) - } - - return nil -} - -// dellFirmwareUpdateTaskStatus looks up the Dell Job and returns it as a redfish task object -func (c *Conn) dellJobAsRedfishTask(jobID string) (*gofishrf.Task, error) { - jobID = dellJobID(jobID) - - tasks, err := c.dellJobs("") - if err != nil { - return nil, err - } - - for _, task := range tasks { - if task.ID == jobID { - return task, nil - } - } - - return nil, errors.Wrap(bmcliberrs.ErrTaskNotFound, "task with ID not found: "+jobID) -} - -// dellJobs returns all dell jobs as redfish task objects -// state: optional -func (c *Conn) dellJobs(state string) ([]*gofishrf.Task, error) { - endpoint := "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs?$expand=*($levels=1)" - - resp, err := c.redfishwrapper.Get(endpoint) - if err != nil { - return nil, err - } - - if resp.StatusCode != 200 { - return nil, errors.New("dell jobs endpoint returned unexpected status code: " + strconv.Itoa(resp.StatusCode)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - data := dellJobResponseData{} - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - tasks := []*gofishrf.Task{} - for _, job := range data.Members { - if state != "" && !strings.EqualFold(job.JobState, state) { - continue - } - - tasks = append(tasks, &gofishrf.Task{ - Entity: gofishcommon.Entity{ - ID: job.ID, - ODataID: job.OdataID, - Name: job.Name, - }, - Description: job.Name, - PercentComplete: job.PercentComplete, - StartTime: job.StartTime, - EndTime: job.CompletionTime, - TaskState: gofishrf.TaskState(job.JobState), - TaskStatus: gofishcommon.Health(job.Message), // abuse the TaskStatus to include any status message - }) - } - - return tasks, nil -} diff --git a/providers/redfish/tasks_test.go b/providers/redfish/tasks_test.go deleted file mode 100644 index bf1a376b6..000000000 --- a/providers/redfish/tasks_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package redfish - -import ( - "net/http" - "testing" - - "github.com/stretchr/testify/assert" -) - -// handler registered in redfish_test.go -func dellJobs(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - w.WriteHeader(http.StatusNotFound) - } - - _, _ = w.Write(jsonResponse(r.RequestURI)) -} - -func Test_dellFirmwareUpdateTask(t *testing.T) { - // see fixtures/v1/dell/jobs.json for the job IDs - // completed job - status, err := mockClient.dellJobAsRedfishTask("467767920358") - if err != nil { - t.Fatal(err) - } - - assert.NotNil(t, status) - assert.Equal(t, "2022-03-08T16:02:33", status.EndTime) - assert.Equal(t, "2022-03-08T15:59:52", status.StartTime) - assert.Equal(t, 100, status.PercentComplete) - assert.Equal(t, "Completed", string(status.TaskState)) - assert.Equal(t, "Job completed successfully.", string(status.TaskStatus)) -} - -func Test_dellPurgeScheduledFirmwareInstallJob(t *testing.T) { - err := mockClient.dellPurgeScheduledFirmwareInstallJob("bios") - if err != nil { - t.Fatal(err) - } -} diff --git a/providers/rpc/http.go b/providers/rpc/http.go index bde373ab4..f9c7b3e43 100644 --- a/providers/rpc/http.go +++ b/providers/rpc/http.go @@ -58,8 +58,7 @@ func (p *Provider) handleResponse(statusCode int, headers http.Header, body *byt if statusCode != http.StatusOK { return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", statusCode, res.Error) } - example, _ := json.Marshal(ResponsePayload{ID: 123, Host: p.Host, Error: &ResponseError{Code: 1, Message: "error message"}}) - return ResponsePayload{}, fmt.Errorf("failed to parse response: got: %q, error: %w, expected response json spec: %v", body.String(), err, string(example)) + return ResponsePayload{}, fmt.Errorf("failed to parse response: %w", err) } if statusCode != http.StatusOK { return ResponsePayload{}, fmt.Errorf("unexpected status code: %d, response error(optional): %v", statusCode, res.Error) diff --git a/providers/rpc/http_test.go b/providers/rpc/http_test.go index c211ff626..14dcf801c 100644 --- a/providers/rpc/http_test.go +++ b/providers/rpc/http_test.go @@ -48,7 +48,9 @@ func TestRequestKVS(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - kvs := requestKVS(tc.req) + buf := new(bytes.Buffer) + _, _ = io.Copy(buf, tc.req.Body) + kvs := requestKVS(tc.req.Method, tc.req.URL.String(), tc.req.Header, buf) if diff := cmp.Diff(kvs, tc.expected); diff != "" { t.Fatalf("requestKVS() mismatch (-want +got):\n%s", diff) } @@ -56,31 +58,6 @@ func TestRequestKVS(t *testing.T) { } } -func TestRequestKVSOneOffs(t *testing.T) { - t.Run("nil body", func(t *testing.T) { - req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", nil) - got := requestKVS(req) - if diff := cmp.Diff(got, []interface{}{"request", requestDetails{}}); diff != "" { - t.Logf("got: %+v", got) - t.Fatalf("requestKVS(req) mismatch (-want +got):\n%s", diff) - } - }) - t.Run("nil request", func(t *testing.T) { - if diff := cmp.Diff(requestKVS(nil), []interface{}{"request", requestDetails{}}); diff != "" { - t.Fatalf("requestKVS(nil) mismatch (-want +got):\n%s", diff) - } - }) - - t.Run("failed to unmarshal body", func(t *testing.T) { - req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", bytes.NewBufferString("invalid")) - got := requestKVS(req) - if diff := cmp.Diff(got, []interface{}{"request", requestDetails{URL: "http://example.com", Method: http.MethodPost, Headers: http.Header{}}}); diff != "" { - t.Logf("got: %+v", got) - t.Fatalf("requestKVS(req) mismatch (-want +got):\n%s", diff) - } - }) -} - func TestResponseKVS(t *testing.T) { tests := map[string]struct { resp *http.Response diff --git a/providers/rpc/logging.go b/providers/rpc/logging.go index 5e689c687..a4b187572 100644 --- a/providers/rpc/logging.go +++ b/providers/rpc/logging.go @@ -3,7 +3,6 @@ package rpc import ( "bytes" "encoding/json" - "io" "net/http" ) @@ -21,20 +20,17 @@ type responseDetails struct { } // requestKVS returns a slice of key, value sets. Used for logging. -func requestKVS(req *http.Request) []interface{} { +func requestKVS(method, url string, headers http.Header, body *bytes.Buffer) []interface{} { var r requestDetails - if req != nil && req.Body != nil { + if body.Len() > 0 { var p RequestPayload - reqBody, err := io.ReadAll(req.Body) - if err == nil { - req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) - _ = json.Unmarshal(reqBody, &p) - } + _ = json.Unmarshal(body.Bytes(), &p) + r = requestDetails{ Body: p, - Headers: req.Header, - URL: req.URL.String(), - Method: req.Method, + Headers: headers, + URL: url, + Method: method, } } diff --git a/providers/rpc/payload.go b/providers/rpc/payload.go index 560dc2fe7..44a046bd4 100644 --- a/providers/rpc/payload.go +++ b/providers/rpc/payload.go @@ -9,6 +9,7 @@ const ( PowerSetMethod Method = "setPowerState" PowerGetMethod Method = "getPowerState" VirtualMediaMethod Method = "setVirtualMedia" + PingMethod Method = "ping" ) // RequestPayload is the payload sent to the ConsumerURL. diff --git a/providers/rpc/rpc.go b/providers/rpc/rpc.go index 4651caaa5..0432c2fd5 100644 --- a/providers/rpc/rpc.go +++ b/providers/rpc/rpc.go @@ -3,7 +3,6 @@ package rpc import ( "bytes" "context" - "encoding/json" "errors" "fmt" "hash" @@ -14,6 +13,7 @@ import ( "strings" "time" + "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/providers" "github.com/go-logr/logr" "github.com/jacobweinstock/registrar" @@ -65,8 +65,8 @@ type Provider struct { ConsumerURL string // Host is the BMC ip address or hostname or identifier. Host string - // Client is the http client used for all HTTP calls. - Client *http.Client + // HTTPClient is the http client used for all HTTP calls. + HTTPClient *http.Client // Logger is the logger to use for logging. Logger logr.Logger // LogNotificationsDisabled determines whether responses from rpc consumer/listeners will be logged or not. @@ -137,7 +137,7 @@ func New(consumerURL string, host string, secrets Secrets) *Provider { c := &Provider{ Host: host, ConsumerURL: consumerURL, - Client: http.DefaultClient, + HTTPClient: httpclient.Build(), Logger: logr.Discard(), Opts: Opts{ Request: RequestOpts{ @@ -182,27 +182,16 @@ func (p *Provider) Open(ctx context.Context) error { return err } p.listenerURL = u - buf := new(bytes.Buffer) - _ = json.NewEncoder(buf).Encode(RequestPayload{}) - testReq, err := http.NewRequestWithContext(ctx, p.Opts.Request.HTTPMethod, p.listenerURL.String(), buf) - if err != nil { - return err - } - // test that we can communicate with the rpc consumer. - resp, err := p.Client.Do(testReq) - if err != nil { - return err - } - if resp.StatusCode >= http.StatusInternalServerError { - return fmt.Errorf("issue on the rpc consumer side, status code: %d", resp.StatusCode) - } - // test that the consumer responses with the expected contract (ResponsePayload{}). - if err := json.NewDecoder(resp.Body).Decode(&ResponsePayload{}); err != nil { - return fmt.Errorf("issue with the rpc consumer response: %v", err) + if _, err = p.process(ctx, RequestPayload{ + ID: time.Now().UnixNano(), + Host: p.Host, + Method: PingMethod, + }); err != nil { + return err } - return resp.Body.Close() + return nil } // Close a connection to the rpc consumer. @@ -274,7 +263,12 @@ func (p *Provider) PowerStateGet(ctx context.Context) (state string, err error) return "", fmt.Errorf("error from rpc consumer: %v", resp.Error) } - return resp.Result.(string), nil + s, ok := resp.Result.(string) + if !ok { + return "", fmt.Errorf("expected result equal to type string, got: %T", resp.Result) + } + + return s, nil } // process is the main function for the roundtrip of rpc calls to the ConsumerURL. @@ -292,9 +286,14 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl // create the signature payload reqBuf := new(bytes.Buffer) - if _, err := io.Copy(reqBuf, req.Body); err != nil { + reqBody, err := req.GetBody() + if err != nil { + return ResponsePayload{}, fmt.Errorf("failed to get request body: %w", err) + } + if _, err := io.Copy(reqBuf, reqBody); err != nil { return ResponsePayload{}, fmt.Errorf("failed to read request body: %w", err) } + headersForSig := http.Header{} for _, h := range p.Opts.Signature.IncludedPayloadHeaders { if val := req.Header.Get(h); val != "" { @@ -321,13 +320,13 @@ func (p *Provider) process(ctx context.Context, rp RequestPayload) (ResponsePayl } // request/response round trip. - kvs := requestKVS(req) + kvs := requestKVS(req.Method, req.URL.String(), req.Header, reqBuf) kvs = append(kvs, []interface{}{"host", p.Host, "method", rp.Method, "consumerURL", p.ConsumerURL}...) if rp.Params != nil { kvs = append(kvs, []interface{}{"params", rp.Params}...) } - resp, err := p.Client.Do(req) + resp, err := p.HTTPClient.Do(req) if err != nil { p.Logger.Error(err, "failed to send rpc notification", kvs...) return ResponsePayload{}, err diff --git a/providers/rpc/rpc_test.go b/providers/rpc/rpc_test.go index 5aecf0539..e15c5ee73 100644 --- a/providers/rpc/rpc_test.go +++ b/providers/rpc/rpc_test.go @@ -124,14 +124,14 @@ func TestPowerStateGet(t *testing.T) { shouldErr bool url string }{ - "success": {}, + "success": {powerState: "on"}, "unknown state": {shouldErr: true}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { rsp := testConsumer{ - rp: ResponsePayload{Result: tc.powerState}, + rp: ResponsePayload{ID: 123, Host: "127.0.1.1", Result: tc.powerState}, } if tc.shouldErr { rsp.rp.Error = &ResponseError{Code: 500, Message: "failed"} diff --git a/providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf b/providers/supermicro/docs/20230907_2-RedfishRefGuide.pdf new file mode 100644 index 0000000000000000000000000000000000000000..17a6cd8e38ac2ff2b4e0465866158f5b8130d681 GIT binary patch literal 2468523 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 R>Is^1fw>9ojG9+#+9%G(dgtnN92T$p)Xny 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

?v{?!zIo>>73a^pElVZ3w< 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