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"` +}