diff --git a/errors/errors.go b/errors/errors.go
index 6d59a6d6..3986acf5 100644
--- a/errors/errors.go
+++ b/errors/errors.go
@@ -95,6 +95,16 @@ var (
// ErrIncompatibleProvider is returned by Open() when the device is not compatible with the provider
ErrIncompatibleProvider = errors.New("provider not compatible with device")
+
+ // ErrBMCColdResetRequired is returned when a BMC cold reset is required.
+ ErrBMCColdResetRequired = errors.New("BMC cold reset required")
+
+ // ErrHostPowercycleRequired is returned when a host powercycle is required.
+ ErrHostPowercycleRequired = errors.New("Host power cycle required")
+
+ // 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")
)
type ErrUnsupportedHardware struct {
diff --git a/examples/install-firmware/doc.go b/examples/install-firmware/doc.go
index b96767b9..16753bfe 100644
--- a/examples/install-firmware/doc.go
+++ b/examples/install-firmware/doc.go
@@ -1,24 +1,58 @@
/*
-install-firmware is an example commmand that utilizes the 'v1' bmclib interface
+install-firmware is an example command that utilizes the 'v1' bmclib interface
methods to flash a firmware image to a BMC.
- $ go run ./examples/v1/install-firmware/main.go -h
- Usage of /tmp/go-build2950657412/b001/exe/main:
- -cert-pool string
- Path to an file containing x509 CAs. An empty string uses the system CAs. Only takes effect when --secure-tls=true
- -firmware string
- The local path of the firmware to install
- -host string
- BMC hostname to connect to
- -password string
- Username to login with
- -port int
- BMC port to connect to (default 443)
- -secure-tls
- Enable secure TLS
- -user string
- Username to login with
- -version string
- The firmware version being installed
+Note: The example installs the firmware and polls until the status until the install is complete,
+and if required by the install process - power cycles the host.
+
+ $ go run ./examples/v1/install-firmware/main.go -h
+ Usage of /tmp/go-build2950657412/b001/exe/main:
+ -cert-pool string
+ Path to an file containing x509 CAs. An empty string uses the system CAs. Only takes effect when -secure-tls=true
+ -firmware string
+ The local path of the firmware to install
+ -host string
+ BMC hostname to connect to
+ -password string
+ Username to login with
+ -port int
+ BMC port to connect to (default 443)
+ -secure-tls
+ Enable secure TLS
+ -user string
+ Username to login with
+ -version string
+ The firmware version being installed
+
+ # install bios firmware on a supermicro X11
+ #
+ $ go run . -host 192.168.1.1 -user ADMIN -password hunter2 -component bios -firmware BIOS_X11DPH-0981_20220208_3.6_STD.bin
+ INFO[0007] set firmware install mode component=BIOS ip="https://192.168.1.1" model=X11DPH-T
+ INFO[0011] uploading firmware component=BIOS ip="https://192.168.1.1" model=X11DPH-T
+ INFO[0091] verifying uploaded firmware component=BIOS ip="https://192.168.1.1" model=X11DPH-T
+ INFO[0105] initiating firmware install component=BIOS ip="https://192.168.1.1" model=X11DPH-T
+ INFO[0115] firmware install running component=bios state=running
+ INFO[0132] firmware install running component=bios state=running
+ ...
+ ...
+ INFO[0628] firmware install running component=bios state=running
+ INFO[0635] host powercycle required component=bios state=powercycle-host
+ INFO[0637] host power cycled, all done! component=bios state=powercycle-host
+
+
+
+ # install bmc firmware on a supermicro X11
+ #
+ $ go run . -host 192.168.1.1 -user ADMIN -password hunter2 -component bmc -firmware BMC_X11AST2500-4101MS_20220225_01.74.02_STD.bin
+ INFO[0007] setting device to firmware install mode component=BMC ip="https://192.168.1.1"
+ INFO[0009] uploading firmware ip="https://192.168.1.1"
+ INFO[0045] verifying uploaded firmware ip="https://192.168.1.1"
+ INFO[0047] initiating firmware install ip="https://192.168.1.1"
+ INFO[0079] firmware install running component=bmc state=running
+ INFO[0085] firmware install running component=bmc state=running
+ ...
+ ...
+ INFO[0233] firmware install running component=bmc state=running
+ INFO[0238] firmware install completed component=bmc state=complete
*/
package main
diff --git a/examples/install-firmware/main.go b/examples/install-firmware/main.go
index 0a10d07d..d1820e0a 100644
--- a/examples/install-firmware/main.go
+++ b/examples/install-firmware/main.go
@@ -3,15 +3,17 @@ package main
import (
"context"
"crypto/x509"
+ "errors"
"flag"
"io/ioutil"
"log"
"os"
+ "strings"
"time"
bmclib "github.com/bmc-toolbox/bmclib/v2"
"github.com/bmc-toolbox/bmclib/v2/constants"
- "github.com/bmc-toolbox/common"
+ bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors"
"github.com/bombsimon/logrusr/v2"
"github.com/sirupsen/logrus"
)
@@ -20,6 +22,7 @@ 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")
+ component := flag.String("component", "", "Component to be updated (bmc, bios.. etc)")
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")
@@ -27,17 +30,25 @@ func main() {
flag.Parse()
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
l := logrus.New()
- l.Level = logrus.DebugLevel
+ l.Level = logrus.TraceLevel
logger := logrusr.New(l)
if *host == "" || *user == "" || *pass == "" {
l.Fatal("required host/user/pass parameters not defined")
}
- clientOpts := []bmclib.Option{bmclib.WithLogger(logger)}
+
+ if *component == "" {
+ l.Fatal("component parameter required (must be a component slug - bmc, bios etc)")
+ }
+
+ clientOpts := []bmclib.Option{
+ bmclib.WithLogger(logger),
+ bmclib.WithPerProviderTimeout(time.Minute * 30),
+ }
if *withSecureTLS {
var pool *x509.CertPool
@@ -61,14 +72,6 @@ func main() {
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 {
@@ -76,17 +79,66 @@ func main() {
}
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)
+ taskID, err := cl.FirmwareInstall(ctx, *component, constants.FirmwareApplyOnReset, true, fh)
if err != nil {
- l.Error(err)
+ l.Fatal(err)
}
- state, err := cl.FirmwareInstallStatus(ctx, taskID, common.SlugBMC, *firmwareVersion)
- if err != nil {
- log.Fatal(err)
- }
+ for {
+ if ctx.Err() != nil {
+ l.Fatal(ctx.Err())
+ }
+
+ state, err := cl.FirmwareInstallStatus(ctx, *firmwareVersion, *component, taskID)
+ if err != nil {
+ // when its under update a connection refused is returned
+ if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "operation timed out") {
+ l.Info("BMC refused connection, BMC most likely resetting...")
+ time.Sleep(2 * time.Second)
+
+ continue
+ }
- l.WithField("state", state).Info("BMC firmware install state")
+ if errors.Is(err, bmclibErrs.ErrSessionExpired) || strings.Contains(err.Error(), "session expired") {
+ err := cl.Open(ctx)
+ if err != nil {
+ l.Fatal(err, "bmc re-login failed")
+ }
+
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("BMC session expired, logging in...")
+
+ continue
+ }
+
+ log.Fatal(err)
+ }
+
+ switch state {
+ case constants.FirmwareInstallRunning:
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("firmware install running")
+
+ case constants.FirmwareInstallFailed:
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("firmware install failed")
+ os.Exit(1)
+
+ case constants.FirmwareInstallComplete:
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("firmware install completed")
+ os.Exit(0)
+
+ case constants.FirmwareInstallPowerCyleHost:
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("host powercycle required")
+
+ if _, err := cl.SetPowerState(ctx, "cycle"); err != nil {
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("error power cycling host for install")
+ os.Exit(1)
+ }
+
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("host power cycled, all done!")
+ os.Exit(0)
+ default:
+ l.WithFields(logrus.Fields{"state": state, "component": *component}).Info("unknown state returned")
+ }
+
+ time.Sleep(2 * time.Second)
+ }
}
diff --git a/internal/ipmi/ipmi.go b/internal/ipmi/ipmi.go
index b8c9b884..8b036794 100644
--- a/internal/ipmi/ipmi.go
+++ b/internal/ipmi/ipmi.go
@@ -90,7 +90,7 @@ func (i *Ipmi) run(ctx context.Context, command []string) (output string, err er
}
for _, cipherString := range ipmiCiphers {
ipmiCmd := append(ipmiArgs, "-C", cipherString)
- i.log.V(1).Info("ipmitool options", "opts", formatOptions(ipmiCmd))
+ i.log.V(3).Info("ipmitool options", "opts", formatOptions(ipmiCmd))
ipmiCmd = append(ipmiCmd, command...)
cmd := exec.CommandContext(ctx, i.ipmitool, ipmiCmd...)
cmd.Env = []string{fmt.Sprintf("IPMITOOL_PASSWORD=%s", i.Password)}
diff --git a/providers/supermicro/errors.go b/providers/supermicro/errors.go
new file mode 100644
index 00000000..14096bb9
--- /dev/null
+++ b/providers/supermicro/errors.go
@@ -0,0 +1,35 @@
+package supermicro
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/pkg/errors"
+)
+
+var (
+ ErrQueryFRUInfo = errors.New("FRU information query returned error")
+)
+
+type UnexpectedResponseError struct {
+ payload string
+ response string
+ statusCode string
+}
+
+func (e *UnexpectedResponseError) Error() string {
+ return fmt.Sprintf(
+ "unexpected response - statusCode: %s, payload: %s, response: %s",
+ e.statusCode,
+ e.payload,
+ e.response,
+ )
+}
+
+func unexpectedResponseErr(payload, response []byte, statusCode int) error {
+ return &UnexpectedResponseError{
+ string(payload),
+ string(response),
+ strconv.Itoa(statusCode),
+ }
+}
diff --git a/providers/supermicro/firmware.go b/providers/supermicro/firmware.go
new file mode 100644
index 00000000..0c6a7fa7
--- /dev/null
+++ b/providers/supermicro/firmware.go
@@ -0,0 +1,101 @@
+package supermicro
+
+import (
+ "context"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors"
+ "github.com/bmc-toolbox/common"
+ "github.com/pkg/errors"
+)
+
+// FirmwareInstall uploads and initiates firmware update for the component
+func (c *Client) FirmwareInstall(ctx context.Context, component, applyAt string, forceInstall bool, reader io.Reader) (jobID string, err error) {
+ if err := c.deviceSupported(ctx); err != nil {
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error())
+ }
+
+ var size int64
+ if file, ok := reader.(*os.File); ok {
+ finfo, err := file.Stat()
+ if err != nil {
+ c.log.V(2).Error(err, "unable to determine file size")
+ }
+
+ size = finfo.Size()
+ }
+
+ // expect atleast 30 minutes left in the deadline to proceed with the update
+ d, _ := ctx.Deadline()
+ if time.Until(d) < 30*time.Minute {
+ return "", errors.New("remaining context deadline insufficient to perform update: " + time.Until(d).String())
+ }
+
+ component = strings.ToUpper(component)
+
+ switch component {
+ case common.SlugBIOS:
+ err = c.firmwareInstallBIOS(ctx, reader, size)
+ case common.SlugBMC:
+ err = c.firmwareInstallBMC(ctx, reader, size)
+ default:
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstall, "component unsupported: "+component)
+ }
+
+ if err != nil {
+ err = errors.Wrap(bmclibErrs.ErrFirmwareInstall, err.Error())
+ }
+
+ return jobID, err
+}
+
+// FirmwareInstallStatus returns the status of the firmware install process
+func (c *Client) FirmwareInstallStatus(ctx context.Context, installVersion, component, taskID string) (string, error) {
+ component = strings.ToUpper(component)
+
+ switch component {
+ case common.SlugBMC:
+ return c.statusBMCFirmwareInstall(ctx)
+ case common.SlugBIOS:
+ return c.statusBIOSFirmwareInstall(ctx)
+ default:
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "component unsupported: "+component)
+ }
+}
+
+func (c *Client) deviceSupported(ctx context.Context) error {
+ errBoardPartNumUnknown := errors.New("baseboard part number unknown")
+ errBoardUnsupported := errors.New("feature not supported/implemented for device")
+
+ // Its likely this works on all X11's
+ // for now, we list only the ones its been tested on.
+ //
+ // board part numbers
+ //
+ supported := []string{
+ "X11SCM-F",
+ "X11DPH-T",
+ }
+
+ data, err := c.fruInfo(ctx)
+ if err != nil {
+ return err
+ }
+
+ if data.Board == nil || strings.TrimSpace(data.Board.PartNum) == "" {
+ return errors.Wrap(errBoardPartNumUnknown, "baseboard part number empty")
+ }
+
+ c.model = strings.TrimSpace(data.Board.PartNum)
+
+ for _, b := range supported {
+ if strings.EqualFold(b, strings.TrimSpace(data.Board.PartNum)) {
+ return nil
+ }
+ }
+
+ return errors.Wrap(errBoardUnsupported, data.Board.PartNum)
+}
diff --git a/providers/supermicro/firmware_bios.go b/providers/supermicro/firmware_bios.go
new file mode 100644
index 00000000..c8646f66
--- /dev/null
+++ b/providers/supermicro/firmware_bios.go
@@ -0,0 +1,435 @@
+package supermicro
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bmc-toolbox/bmclib/v2/constants"
+ bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors"
+ "github.com/pkg/errors"
+)
+
+func (c *Client) firmwareInstallBIOS(ctx context.Context, reader io.Reader, fileSize int64) error {
+ var err error
+
+ c.log.V(2).Info("set firmware install mode", "ip", c.host, "component", "BIOS", "model", c.model)
+
+ // 0. pre flash mode requisite
+ if err := c.checkComponentUpdateMisc(ctx, "preUpdate"); err != nil {
+ return err
+ }
+
+ // 1. set the device to flash mode - prepares the flash
+ err = c.setBIOSFirmwareInstallMode(ctx)
+ if err != nil {
+ return errors.Wrap(err, ErrFirmwareInstallMode.Error())
+ }
+
+ err = c.setBiosUpdateStart(ctx)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("uploading firmware", "ip", c.host, "component", "BIOS", "model", c.model)
+
+ // 2. upload firmware image file
+ err = c.uploadBIOSFirmware(ctx, reader)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("verifying uploaded firmware", "ip", c.host, "component", "BIOS", "model", c.model)
+
+ // 3. BMC verifies the uploaded firmware version
+ err = c.verifyBIOSFirmwareVersion(ctx)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("initiating firmware install", "ip", c.host, "component", "BIOS", "model", c.model)
+
+ // pre install requisite
+ err = c.setBIOSOp(ctx)
+ if err != nil {
+ return err
+ }
+
+ // 4. Run the firmware install process
+ err = c.initiateBIOSFirmwareInstall(ctx)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// checks component update status
+func (c *Client) checkComponentUpdateMisc(ctx context.Context, stage string) error {
+ var payload, expectResponse []byte
+
+ switch stage {
+ case "preUpdate":
+ payload = []byte(`op=COMPONENT_UPDATE_MISC.XML&r=(0,0)&_=`)
+ // RES=-1 indicates the BMC is not in BIOS update mode
+ expectResponse = []byte(``)
+
+ case "postUpdate":
+ payload = []byte(`op=COMPONENT_UPDATE_MISC.XML&r=(1,0)&_=`)
+ // RES=0 indicates the BMC is in BIOS update mode
+ expectResponse = []byte(``)
+
+ // When SYSOFF=1 the system requires a power cycle
+ default:
+ return errors.New("unknown stage: " + stage)
+
+ }
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ if status != http.StatusOK || !bytes.Contains(body, expectResponse) {
+ // this indicates the BMC is in firmware update mode and now requires a reset
+ // calling BIOS_UNLOCK.xml doesn't help here
+ if stage == "preUpdate" && bytes.Contains(body, []byte(``)) {
+ return bmclibErrs.ErrBMCColdResetRequired
+ }
+
+ if bytes.Contains(body, []byte(``)) {
+ return bmclibErrs.ErrHostPowercycleRequired
+ }
+
+ if stage == "postUpdate" && bytes.Contains(body, []byte(``)) {
+ return bmclibErrs.ErrSessionExpired
+ }
+
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ return nil
+}
+
+func (c *Client) setBIOSFirmwareInstallMode(ctx context.Context) error {
+
+ payload := []byte(`op=BIOS_UPLOAD.XML&r=(0,0)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ if status != http.StatusOK {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ switch {
+ case bytes.Contains(body, []byte(`LOCK_FW_UPLOAD RES="0"`)):
+ // This response indicates another web session that initiated the firmware upload has the lock,
+ // the BMC cannot be reset through a web session, nor can any other user obtain the firmware upload lock.
+ // Since the firmware upload lock is associated with the cookie that initiated the request only the initiating session can cancel it.
+ //
+ // The only way to get out of this situation is through an IPMI (or redfish?) based BMC cold reset.
+ ///
+ // The caller must check if a firmware update is in progress before proceeding with the reset.
+ //
+ // If after multiple calls to check the install progress - the progress seems stalled at 1%
+ // it indicates no update was active, and the BMC can be reset.
+ //
+ // 1
+ return errors.Wrap(
+ bmclibErrs.ErrBMCColdResetRequired,
+ "firmware upload mode active, another session may have initiated an install",
+ )
+
+ case bytes.Contains(body, []byte(`LOCK_FW_UPLOAD RES="1"`)):
+ return nil
+ default:
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+}
+
+func (c *Client) setBiosUpdateStart(ctx context.Context) error {
+ payload := []byte(`op=BIOS_UPDATE_START.XML&r=(1,0)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ // yep, the endpoint returns 500 even when successful
+ if status != http.StatusOK && status != 500 {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ return nil
+}
+
+// ------WebKitFormBoundaryXIAavwG4xzohdB6k
+// Content-Disposition: form-data; name="bios_rom"; filename="BIOS_X11SCM-1B0F_20220916_1.9_STDsp.bin"
+// Content-Type: application/macbinary
+//
+// ------WebKitFormBoundaryXIAavwG4xzohdB6k
+// Content-Disposition: form-data; name="CSRF-TOKEN"
+//
+// OO8+cjamaZZOMf6ZiGDY3Lw+7O20r5lR8aI8ByuTo3E
+// ------WebKitFormBoundaryXIAavwG4xzohdB6k--
+func (c *Client) uploadBIOSFirmware(ctx context.Context, fwReader io.Reader) error {
+ var payloadBuffer bytes.Buffer
+ var err error
+
+ formParts := []struct {
+ name string
+ data io.Reader
+ }{
+ {
+ name: "bios_rom",
+ data: fwReader,
+ },
+ {
+ name: "csrf-token",
+ data: bytes.NewBufferString(c.csrfToken),
+ },
+ }
+
+ payloadWriter := multipart.NewWriter(&payloadBuffer)
+
+ for _, part := range formParts {
+ var partWriter io.Writer
+
+ switch part.name {
+ case "bios_rom":
+ file, ok := part.data.(*os.File)
+ if !ok {
+ return errors.Wrap(ErrMultipartForm, "expected io.Reader on firmware image file")
+ }
+
+ if partWriter, err = payloadWriter.CreateFormFile(part.name, filepath.Base(file.Name())); err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+
+ case "csrf-token":
+ // Add csrf token field
+ h := make(textproto.MIMEHeader)
+ h.Set("Content-Disposition", `form-data; name="CSRF-TOKEN"`)
+
+ if partWriter, err = payloadWriter.CreatePart(h); err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+ default:
+ return errors.Wrap(ErrMultipartForm, "unexpected form part: "+part.name)
+ }
+
+ if _, err = io.Copy(partWriter, part.data); err != nil {
+ return err
+ }
+ }
+ payloadWriter.Close()
+
+ resp, statusCode, err := c.query(
+ ctx,
+ "cgi/bios_upload.cgi",
+ http.MethodPost,
+ bytes.NewReader(payloadBuffer.Bytes()),
+ map[string]string{"Content-Type": payloadWriter.FormDataContentType()},
+ 0,
+ )
+
+ if err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+
+ if statusCode != http.StatusOK {
+ return fmt.Errorf("non 200 response: %d %s", statusCode, resp)
+ }
+
+ return nil
+}
+
+func (c *Client) verifyBIOSFirmwareVersion(ctx context.Context) error {
+ payload := []byte(`op=BIOS_UPDATE_CHECK.XML&r=(0,0)&_=`)
+ expectResponse := []byte(``)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ if status != http.StatusOK || !bytes.Contains(body, expectResponse) {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ payload = []byte(`op=BIOS_REV.XML&_=`)
+ expectResponse = []byte(``)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ if status != http.StatusOK || !bytes.Contains(body, expectResponse) {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ return nil
+}
+
+func (c *Client) initiateBIOSFirmwareInstall(ctx context.Context) error {
+ // save all current SMBIOS, NVRAM, ME configuration
+ payload := []byte(`op=main_biosupdate&_=`)
+ expectResponse := []byte(`ok`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ // don't spend much time on this call since it doesn't return and holds the connection.
+ sctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ body, status, err := c.query(sctx, "cgi/op.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ // this endpoint generally times out - its expected
+ if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "operation timed out") {
+ return nil
+ }
+
+ return err
+ }
+
+ if status != http.StatusOK || !bytes.Contains(body, expectResponse) {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ return nil
+}
+
+func (c *Client) setBIOSUpdateDone(ctx context.Context) error {
+ payload := []byte(`op=BIOS_UPDATE_DONE.XML&r=(1,0)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ // yep, the endpoint returns 500 even when successful
+ if status != http.StatusOK && status != 500 {
+ return unexpectedResponseErr(payload, body, status)
+ }
+
+ return nil
+}
+
+// statusBIOSFirmwareInstall returns the status of the firmware install process
+func (c *Client) statusBIOSFirmwareInstall(ctx context.Context) (string, error) {
+ payload := []byte(`fwtype=1&_`)
+
+ headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
+ resp, status, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0)
+ if err != nil {
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error())
+ }
+
+ if status != http.StatusOK {
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected status code: "+strconv.Itoa(status))
+ }
+
+ switch {
+ // 1% indicates the file has been uploaded and the firmware install is not yet initiated
+ case bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("1")):
+ return constants.FirmwareInstallFailed, bmclibErrs.ErrBMCColdResetRequired
+
+ // 0% along with the check on the component endpoint indicates theres no update in progress
+ case (bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("0"))):
+ if err := c.checkComponentUpdateMisc(ctx, "postUpdate"); err != nil {
+ if errors.Is(err, bmclibErrs.ErrHostPowercycleRequired) {
+ return constants.FirmwareInstallPowerCyleHost, nil
+ }
+ }
+
+ return constants.FirmwareInstallComplete, nil
+
+ // status 0 and 100% indicates the update is complete and requires a few post update calls
+ case bytes.Contains(resp, []byte("0")) && bytes.Contains(resp, []byte("100")):
+ // notifies the BMC the BIOS update is done
+ if err := c.setBIOSUpdateDone(ctx); err != nil {
+ return "", err
+ }
+
+ // tells the BMC it can get out of the BIOS update mode
+ if err := c.checkComponentUpdateMisc(ctx, "postUpdate"); err != nil {
+ if errors.Is(err, bmclibErrs.ErrHostPowercycleRequired) {
+ return constants.FirmwareInstallPowerCyleHost, nil
+ }
+
+ return constants.FirmwareInstallPowerCyleHost, err
+ }
+
+ return constants.FirmwareInstallPowerCyleHost, nil
+
+ // status 8 and percent 0 indicates its initializing the update
+ case bytes.Contains(resp, []byte("8")) && bytes.Contains(resp, []byte("0")):
+ return constants.FirmwareInstallRunning, nil
+
+ // status 8 and any other percent value indicates its running
+ case bytes.Contains(resp, []byte("8")) && bytes.Contains(resp, []byte("")):
+ return constants.FirmwareInstallRunning, nil
+
+ case bytes.Contains(resp, []byte(``)):
+ return constants.FirmwareInstallUnknown, bmclibErrs.ErrSessionExpired
+
+ default:
+ return constants.FirmwareInstallUnknown, nil
+ }
+}
diff --git a/providers/supermicro/firmware_bios_test.go b/providers/supermicro/firmware_bios_test.go
new file mode 100644
index 00000000..f85914a8
--- /dev/null
+++ b/providers/supermicro/firmware_bios_test.go
@@ -0,0 +1,180 @@
+package supermicro
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_setComponentUpdateMisc(t *testing.T) {
+ testcases := []struct {
+ name string
+ stage string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "preUpdate",
+ "preUpdate",
+ "",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=COMPONENT_UPDATE_MISC.XML&r=(0,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ {
+ "postUpdate",
+ "postUpdate",
+ "",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=COMPONENT_UPDATE_MISC.XML&r=(1,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ if err := client.checkComponentUpdateMisc(context.Background(), tc.stage); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
+
+func Test_setBIOSFirmwareInstallMode(t *testing.T) {
+ testcases := []struct {
+ name string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "BIOS fw install lock acquired",
+ "",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ {
+ "lock not acquired",
+ "BMC cold reset required",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ {
+ "error returned",
+ "400",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(400)
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ if err := client.setBMCFirmwareInstallMode(context.Background()); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
diff --git a/providers/supermicro/firmware_bmc.go b/providers/supermicro/firmware_bmc.go
new file mode 100644
index 00000000..b6a2b8bf
--- /dev/null
+++ b/providers/supermicro/firmware_bmc.go
@@ -0,0 +1,290 @@
+package supermicro
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/pkg/errors"
+
+ "github.com/bmc-toolbox/bmclib/v2/constants"
+ bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors"
+)
+
+var (
+ ErrFirmwareInstallMode = errors.New("firmware install mode error")
+ ErrMultipartForm = errors.New("multipart form error")
+)
+
+// firmwareInstallBMC uploads and installs firmware for the BMC component
+func (c *Client) firmwareInstallBMC(ctx context.Context, reader io.Reader, fileSize int64) error {
+ var err error
+
+ c.log.V(2).Info("setting device to firmware install mode", "ip", c.host, "component", "BMC", "model", c.model)
+
+ // 1. set the device to flash mode - prepares the flash
+ err = c.setBMCFirmwareInstallMode(ctx)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("uploading firmware", "ip", c.host, "component", "BMC", "model", c.model)
+
+ // 2. upload firmware image file
+ err = c.uploadBMCFirmware(ctx, reader)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("verifying uploaded firmware", "ip", c.host, "component", "BMC", "model", c.model)
+
+ // 3. BMC verifies the uploaded firmware version
+ err = c.verifyBMCFirmwareVersion(ctx)
+ if err != nil {
+ return err
+ }
+
+ c.log.V(2).Info("initiating firmware install", "ip", c.host, "component", "BMC", "model", c.model)
+
+ // 4. Run the firmware install process
+ err = c.initiateBMCFirmwareInstall(ctx)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) setBMCFirmwareInstallMode(ctx context.Context) error {
+ payload := []byte(`op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return errors.Wrap(ErrFirmwareInstallMode, err.Error())
+ }
+
+ if status != http.StatusOK {
+ return errors.Wrap(ErrFirmwareInstallMode, "Unexpected status code: "+strconv.Itoa(status))
+ }
+
+ switch {
+ case bytes.Contains(body, []byte(`LOCK_FW_UPLOAD RES="0"`)):
+ // This response indicates another web session that initiated the firmware upload has the lock,
+ // the BMC cannot be reset through a web session, nor can any other user obtain the firmware upload lock.
+ // Since the firmware upload lock is associated with the cookie that initiated the request only the initiating session can cancel it.
+ //
+ // The only way to get out of this situation is through an IPMI (or redfish?) based BMC cold reset.
+ ///
+ // The caller must check if a firmware update is in progress before proceeding with the reset.
+ //
+ // If after multiple calls to check the install progress - the progress seems stalled at 1%
+ // it indicates no update was active, and the BMC can be reset.
+ //
+ // 1
+ return errors.Wrap(
+ bmclibErrs.ErrBMCColdResetRequired,
+ "unable to acquire lock for firmware upload, check if an update is in progress",
+ )
+
+ case bytes.Contains(body, []byte(`LOCK_FW_UPLOAD RES="1"`)):
+ return nil
+ default:
+ return errors.Wrap(ErrFirmwareInstallMode, "set firmware install mode returned unexpected response body")
+ }
+
+}
+
+// -----------------------------212212001131894333502018521064
+// Content-Disposition: form-data; name="fw_image"; filename="BMC_X11AST2500-4101MS_20221020_01.74.09_STDsp.bin"
+// Content-Type: application/macbinary
+//
+// ... contents...
+//
+// -----------------------------348113760313214626342869148824
+// Content-Disposition: form-data; name="CSRF-TOKEN"
+//
+// JhVe1BUiWzOVQdvXUKn7ClsQ5xffq8StMOxG7ZNlpKs
+// -----------------------------348113760313214626342869148824--
+func (c *Client) uploadBMCFirmware(ctx context.Context, fwReader io.Reader) error {
+ var payloadBuffer bytes.Buffer
+ var err error
+
+ formParts := []struct {
+ name string
+ data io.Reader
+ }{
+ {
+ name: "fw_image",
+ data: fwReader,
+ },
+ {
+ name: "csrf-token",
+ data: bytes.NewBufferString(c.csrfToken),
+ },
+ }
+
+ payloadWriter := multipart.NewWriter(&payloadBuffer)
+
+ for _, part := range formParts {
+ var partWriter io.Writer
+
+ switch part.name {
+ case "fw_image":
+ file, ok := part.data.(*os.File)
+ if !ok {
+ return errors.Wrap(ErrMultipartForm, "expected io.Reader on firmware image file")
+ }
+
+ if partWriter, err = payloadWriter.CreateFormFile(part.name, filepath.Base(file.Name())); err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+
+ case "csrf-token":
+ // Add csrf token field
+ h := make(textproto.MIMEHeader)
+ h.Set("Content-Disposition", `form-data; name="CSRF-TOKEN"`)
+
+ if partWriter, err = payloadWriter.CreatePart(h); err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+ default:
+ return errors.Wrap(ErrMultipartForm, "unexpected form part: "+part.name)
+ }
+
+ if _, err = io.Copy(partWriter, part.data); err != nil {
+ return err
+ }
+ }
+ payloadWriter.Close()
+
+ resp, statusCode, err := c.query(
+ ctx,
+ "cgi/oem_firmware_upload.cgi",
+ http.MethodPost,
+ bytes.NewReader(payloadBuffer.Bytes()),
+ map[string]string{"Content-Type": payloadWriter.FormDataContentType()},
+ 0,
+ )
+
+ if err != nil {
+ return errors.Wrap(ErrMultipartForm, err.Error())
+ }
+
+ if statusCode != http.StatusOK {
+ return fmt.Errorf("non 200 response: %d %s", statusCode, resp)
+ }
+
+ return nil
+}
+
+func (c *Client) verifyBMCFirmwareVersion(ctx context.Context) error {
+ errUnexpectedResponse := errors.New("unexpected response")
+
+ payload := []byte(`op=UPLOAD_FW_VERSION.XML&r=(0,0)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return err
+ }
+
+ if status != 200 {
+ return errors.Wrap(ErrFirmwareInstallMode, "Unexpected status code: "+strconv.Itoa(status))
+ }
+
+ if !bytes.Contains(body, []byte(`FW_VERSION NEW`)) {
+ return errors.Wrap(errUnexpectedResponse, string(body))
+ }
+
+ return nil
+}
+
+// initiate BMC firmware install process
+func (c *Client) initiateBMCFirmwareInstall(ctx context.Context) error {
+ // preserve all configuration, sensor data and SSL certs(?) during upgrade
+ payload := "op=main_fwupdate&preserve_config=1&preserve_sdr=1&preserve_ssl=1"
+
+ headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
+
+ // don't spend much time on this call since it doesn't return and holds the connection.
+ sctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ _, status, err := c.query(sctx, "cgi/op.cgi", http.MethodPost, bytes.NewBufferString(payload), headers, 0)
+ if err != nil {
+ // this operation causes the BMC to go AWOL and not send any response
+ // so we ignore the error here, the caller can invoke FirmwareInstallStatus in the same session,
+ // to check the install status to determine install progress.
+
+ // whats returned is a *url.Error{} and errors.Is(err, context.DeadlineExceeded) doesn't seem to match
+ // so a string contains it is.
+ if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "operation timed out") {
+ return nil
+ }
+
+ return err
+ }
+
+ if status != 200 {
+ return errors.Wrap(ErrFirmwareInstallMode, "Unexpected status code: "+strconv.Itoa(status))
+ }
+
+ return nil
+}
+
+// statusBMCFirmwareInstall returns the status of the firmware install process
+func (c *Client) statusBMCFirmwareInstall(ctx context.Context) (string, error) {
+ payload := []byte(`fwtype=0&_`)
+
+ headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
+ resp, status, err := c.query(ctx, "cgi/upgrade_process.cgi", http.MethodPost, bytes.NewReader(payload), headers, 0)
+ if err != nil {
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, err.Error())
+ }
+
+ if status != http.StatusOK {
+ return "", errors.Wrap(bmclibErrs.ErrFirmwareInstallStatus, "Unexpected status code: "+strconv.Itoa(status))
+ }
+
+ // as long as the response is xml, the firmware install is running
+ // at the end of the install the BMC resets itself and the response is in HTML
+ //
+ switch {
+
+ // TODO:
+ // - look up model on device and limit the parent methods to tested models.
+ // - fix up percent value checks, html indicates session has been terminated
+ // X11DPH-T - returns percent 0 all the time
+ //
+ // 0% indicates its either not running or complete
+ case bytes.Contains(resp, []byte("0")) || bytes.Contains(resp, []byte("100")):
+ return constants.FirmwareInstallComplete, nil
+ // until 2% its initializing
+ case bytes.Contains(resp, []byte(`1`)) || bytes.Contains(resp, []byte(`2`)):
+ return constants.FirmwareInstallInitializing, nil
+ // any other percent value indicates its active
+ case bytes.Contains(resp, []byte(``)):
+ return constants.FirmwareInstallRunning, nil
+ case bytes.Contains(resp, []byte(``)):
+ // reopen session here, check firmware install status
+ return constants.FirmwareInstallUnknown, bmclibErrs.ErrSessionExpired
+ default:
+ return constants.FirmwareInstallUnknown, nil
+ }
+}
diff --git a/providers/supermicro/firmware_bmc_test.go b/providers/supermicro/firmware_bmc_test.go
new file mode 100644
index 00000000..9c7093b5
--- /dev/null
+++ b/providers/supermicro/firmware_bmc_test.go
@@ -0,0 +1,511 @@
+package supermicro
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/bmc-toolbox/bmclib/v2/constants"
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_setBMCFirmwareInstallMode(t *testing.T) {
+ testcases := []struct {
+ name string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "BMC fw install lock acquired",
+ "",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ {
+ "lock not acquired",
+ "BMC cold reset required",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded; charset=UTF-8", r.Header.Get("Content-Type"))
+
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, `op=LOCK_UPLOAD_FW.XML&r=(0,0)&_=`, string(b))
+
+ _, _ = w.Write([]byte(`
+
+
+ `))
+ },
+ },
+ {
+ "error returned",
+ "400",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(400)
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ if err := client.setBMCFirmwareInstallMode(context.Background()); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
+
+func Test_uploadBMCFirmware(t *testing.T) {
+ testcases := []struct {
+ name string
+ errorContains string
+ endpoint string
+ fwFilename string
+ fwFileContents string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "upload works",
+ "",
+ "/cgi/oem_firmware_upload.cgi",
+ "blob.bin",
+ "dummy fw image",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // validate content type
+ mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
+ assert.Nil(t, err)
+
+ assert.Equal(t, "multipart/form-data", mediaType)
+
+ // read form parts from boundary
+ reader := multipart.NewReader(bytes.NewReader(b), params["boundary"])
+
+ // validate firmware image part
+ part, err := reader.NextPart()
+ assert.Nil(t, err)
+
+ assert.Equal(t, `form-data; name="fw_image"; filename="blob.bin"`, part.Header.Get("Content-Disposition"))
+
+ // validate csrf-token part
+ part, err = reader.NextPart()
+ assert.Nil(t, err)
+
+ assert.Equal(t, `form-data; name="CSRF-TOKEN"`, part.Header.Get("Content-Disposition"))
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create tmp firmware file
+ var fwReader *os.File
+ if tc.fwFilename != "" {
+ tmpdir := t.TempDir()
+ binPath := filepath.Join(tmpdir, tc.fwFilename)
+ err := os.WriteFile(binPath, []byte(tc.fwFileContents), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fwReader, err = os.Open(binPath)
+ if err != nil {
+ t.Fatalf("%s -> %s", err.Error(), binPath)
+ }
+
+ defer os.Remove(binPath)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ client.csrfToken = "foobar"
+ if err := client.uploadBMCFirmware(context.Background(), fwReader); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
+
+func Test_verifyBMCFirmwareVersion(t *testing.T) {
+ testcases := []struct {
+ name string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "verify successful",
+ "",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`op=UPLOAD_FW_VERSION.XML&r=(0,0)&_=`), b)
+
+ resp := []byte(` `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "unexpected response",
+ "unexpected response",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ resp := []byte(`bad bmc does not comply`)
+ _, err := w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "unexpected status code",
+ "Unexpected status code: 403",
+ "/cgi/ipmi.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ w.WriteHeader(http.StatusForbidden)
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ client.csrfToken = "foobar"
+ if err := client.verifyBMCFirmwareVersion(context.Background()); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
+
+func Test_initiateBMCFirmwareInstall(t *testing.T) {
+ testcases := []struct {
+ name string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "install intiated successfully",
+ "",
+ "/cgi/op.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`op=main_fwupdate&preserve_config=1&preserve_sdr=1&preserve_ssl=1`), b)
+
+ resp := []byte(`Upgrade progress.. 1%`)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "unexpected response",
+ "unexpected response",
+ "/cgi/op.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ resp := []byte(`bad bmc does not comply`)
+ _, err := w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "unexpected status code",
+ "Unexpected status code: 403",
+ "/cgi/op.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ w.WriteHeader(http.StatusForbidden)
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ client.csrfToken = "foobar"
+ if err := client.initiateBMCFirmwareInstall(context.Background()); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ }
+ })
+ }
+}
+
+func Test_statusBMCFirmwareInstall(t *testing.T) {
+ testcases := []struct {
+ name string
+ expectStatus string
+ errorContains string
+ endpoint string
+ handler func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ "state complete 0",
+ constants.FirmwareInstallComplete,
+ "",
+ "/cgi/upgrade_process.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`fwtype=0&_`), b)
+
+ resp := []byte(`
+
+ 0
+ `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "state complete 100",
+ constants.FirmwareInstallComplete,
+ "",
+ "/cgi/upgrade_process.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`fwtype=0&_`), b)
+
+ resp := []byte(`
+
+ 100
+ `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "state initializing",
+ constants.FirmwareInstallInitializing,
+ "",
+ "/cgi/upgrade_process.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`fwtype=0&_`), b)
+
+ resp := []byte(`
+
+ 1
+ `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "status running",
+ constants.FirmwareInstallRunning,
+ "",
+ "/cgi/upgrade_process.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`fwtype=0&_`), b)
+
+ resp := []byte(`
+
+ 95
+ `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ "status unknown",
+ constants.FirmwareInstallUnknown,
+ "session expired",
+ "/cgi/upgrade_process.cgi",
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, []byte(`fwtype=0&_`), b)
+
+ resp := []byte(` uh what `)
+ _, err = w.Write(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc(tc.endpoint, tc.handler)
+
+ server := httptest.NewTLSServer(mux)
+ defer server.Close()
+
+ parsedURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client := NewClient(parsedURL.Hostname(), "foo", "bar", logr.Discard(), WithPort(parsedURL.Port()))
+ client.csrfToken = "foobar"
+ if gotStatus, err := client.statusBMCFirmwareInstall(context.Background()); err != nil {
+ if tc.errorContains != "" {
+ assert.ErrorContains(t, err, tc.errorContains)
+
+ return
+ }
+
+ assert.Nil(t, err)
+ assert.Equal(t, tc.expectStatus, gotStatus)
+ }
+ })
+ }
+}
diff --git a/providers/supermicro/supermicro.go b/providers/supermicro/supermicro.go
index b9818c55..f9ce04d0 100644
--- a/providers/supermicro/supermicro.go
+++ b/providers/supermicro/supermicro.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/x509"
"encoding/base64"
+ "encoding/xml"
"fmt"
"io"
"net/http"
@@ -37,13 +38,23 @@ var (
// Features implemented
Features = registrar.Features{
providers.FeatureScreenshot,
+ providers.FeatureFirmwareInstall,
+ providers.FeatureFirmwareInstallStatus,
}
)
// supports
-// SYS-5019C-MR
-// SYS-510T-MR
-// 6029P-E1CR12L
+//
+// product: SYS-5019C-MR, baseboard part number: X11SCM-F
+// - screen capture
+// - bios firmware install
+// - bmc firmware install
+// product: SYS-510T-MR, baseboard part number: X12STH-SYS
+// - screen capture
+// product: 6029P-E1CR12L, baseboard part number: X11DPH-T
+// . - screen capture
+// - bios firmware install
+// - bmc firmware install
type Config struct {
HttpClient *http.Client
@@ -81,6 +92,7 @@ type Client struct {
pass string
port string
csrfToken string
+ model string
log logr.Logger
}
@@ -258,6 +270,64 @@ func (c *Client) initScreenPreview(ctx context.Context) error {
return nil
}
+// PowerSet sets the power state of a server
+func (c *Client) PowerSet(ctx context.Context, state string) (ok bool, err error) {
+ switch strings.ToLower(state) {
+ case "cycle":
+ return c.powerCycle(ctx)
+ default:
+ return false, errors.New("action not implemented for provider")
+ }
+}
+
+func (c *Client) fruInfo(ctx context.Context) (*FruInfo, error) {
+ headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
+
+ payload := "op=FRU_INFO.XML&r=(0,0)&_="
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBufferString(payload), headers, 0)
+ if err != nil {
+ return nil, errors.Wrap(ErrQueryFRUInfo, err.Error())
+ }
+
+ if status != 200 {
+ return nil, unexpectedResponseErr([]byte(payload), body, status)
+ }
+
+ if !bytes.Contains(body, []byte(``)) {
+ return nil, unexpectedResponseErr([]byte(payload), body, status)
+ }
+
+ data := &IPMI{}
+ if err := xml.Unmarshal(body, data); err != nil {
+ return nil, errors.Wrap(ErrQueryFRUInfo, err.Error())
+ }
+
+ return data.FruInfo, nil
+}
+
+// powerCycle using SMC XML API
+//
+// This method is only here for the case when firmware updates are being applied using this provider.
+func (c *Client) powerCycle(ctx context.Context) (bool, error) {
+ payload := []byte(`op=SET_POWER_INFO.XML&r=(1,3)&_=`)
+
+ headers := map[string]string{
+ "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+ }
+
+ body, status, err := c.query(ctx, "cgi/ipmi.cgi", http.MethodPost, bytes.NewBuffer(payload), headers, 0)
+ if err != nil {
+ return false, err
+ }
+
+ if status != http.StatusOK {
+ return false, unexpectedResponseErr(payload, body, status)
+ }
+
+ return true, nil
+}
+
func (c *Client) query(ctx context.Context, endpoint, method string, payload io.Reader, headers map[string]string, contentLength int64) ([]byte, int, error) {
var body []byte
var err error
diff --git a/providers/supermicro/types.go b/providers/supermicro/types.go
new file mode 100644
index 00000000..3946b9a4
--- /dev/null
+++ b/providers/supermicro/types.go
@@ -0,0 +1,18 @@
+package supermicro
+
+type IPMI struct {
+ FruInfo *FruInfo `xml:"FRU_INFO,omitempty"`
+}
+
+// FruInfo contains the FRU information
+type FruInfo struct {
+ Board *Board `xml:"BOARD,omitempty"`
+}
+
+// Board contains the product baseboard information
+type Board struct {
+ MfcName string `xml:"MFC_NAME,attr"`
+ PartNum string `xml:"PART_NUM,attr"`
+ ProdName string `xml:"PROD_NAME,attr"`
+ SerialNum string `xml:"SERIAL_NUM,attr"`
+}