Merge pull request #10 from davidallendj/update-firmware

Added `update` command to update firmware
This commit is contained in:
David Allen 2023-10-16 10:35:37 -06:00 committed by GitHub
commit 075cbf0733
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 403 additions and 75 deletions

View file

@ -1,7 +1,8 @@
# Magellan
Magellan is a small tool designed to scan a network and collect BMC information
to load the data into an [`hms-smd`](https://github.com/alexlovelltroy/hms-smd/tree/master) instance.
Magellan is a board management controller discovery tool designed to scan a network
and collect information about a BMC node and load that data into an
[`hms-smd`](https://github.com/bikeshack/smd/tree/master) instance.
## How It Works
@ -13,9 +14,13 @@ Magellan is designed to do three things:
Magellan first tries to probe for specified hosts using the [`dora`](https://github.com/bmc-toolbox/dora)
API. If that fails, it then tries to use its own built-in, simpler scanner as a fallback.
Next, it tries to query information about the BMC node using `bmclib` functions, but requires
access to a redfish interface on the node to work. Once the BMC information is received,
it is then stored into `hms-smd` using its API.
This is done by sending a raw TCP request to a number of potential hosts over a
network, and noting which requests are successful. At this point, `magellan` sees
no difference between a services.
Next, it tries to query information about the BMC node using `bmclib` functions,
but requires access to a redfish interface on the node to work. Once the BMC
information is received, it is then stored into `hms-smd` using its API.
In summary, `magellan` needs at minimum the following configured to work on each node:

View file

@ -11,6 +11,10 @@ function list(){
./magellan list
}
function update() {
./magellan update --user admin --pass password --host 172.16.0.109 --component BMC --protocol HTTP --firmware-path ""
}
function collect() {
./magellan collect --user admin --pass password
}

View file

@ -33,6 +33,7 @@ var collectCmd = &cobra.Command{
q := &magellan.QueryParams{
User: user,
Pass: pass,
Protocol: protocol,
Drivers: drivers,
Preferred: preferredDriver,
Timeout: timeout,
@ -58,6 +59,7 @@ func init() {
collectCmd.PersistentFlags().IntVarP(&smd.Port, "port", "p", smd.Port, "set the port to the smd API")
collectCmd.PersistentFlags().StringVar(&user, "user", "", "set the BMC user")
collectCmd.PersistentFlags().StringVar(&pass, "pass", "", "set the BMC password")
collectCmd.PersistentFlags().StringVar(&protocol, "protocol", "https", "set the Redfish protocol")
collectCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "/tmp/magellan/data/", "set the path to store collection data")
collectCmd.PersistentFlags().BoolVar(&forceUpdate, "force-update", true, "set flag to force update data sent to SMD ")
collectCmd.PersistentFlags().StringVar(&preferredDriver, "preferred-driver", "ipmi", "set the preferred driver to use")

View file

@ -12,6 +12,7 @@ var (
threads int
ports []int
hosts []string
protocol string
withSecureTLS bool
certPoolFile string
user string

82
cmd/update.go Normal file
View file

@ -0,0 +1,82 @@
package cmd
import (
magellan "github.com/bikeshack/magellan/internal"
"github.com/bikeshack/magellan/internal/log"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var (
host string
port int
firmwarePath string
firmwareVersion string
component string
transferProtocol string
status bool
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update BMC node firmware",
Run: func(cmd *cobra.Command, args []string) {
l := log.NewLogger(logrus.New(), logrus.DebugLevel)
q := &magellan.UpdateParams {
FirmwarePath: firmwarePath,
FirmwareVersion: firmwareVersion,
Component: component,
TransferProtocol: transferProtocol,
QueryParams: magellan.QueryParams{
Drivers: []string{"redfish"},
Preferred: "redfish",
Protocol: protocol,
Host: host,
User: user,
Pass: pass,
Timeout: timeout,
Port: port,
WithSecureTLS: withSecureTLS,
},
}
// check if required params are set
if host == "" || user == "" || pass == "" {
l.Log.Fatal("requires host, user, and pass to be set")
}
// get status if flag is set and exit
if status {
err := magellan.GetUpdateStatus(q)
if err != nil {
l.Log.Errorf("could not get update status: %v", err)
}
return
}
// client, err := magellan.NewClient(l, &q.QueryParams)
// if err != nil {
// l.Log.Errorf("could not make client: %v", err)
// }
// err = magellan.UpdateFirmware(client, l, q)
err := magellan.UpdateFirmwareRemote(q)
if err != nil {
l.Log.Errorf("could not update firmware: %v", err)
}
},
}
func init() {
updateCmd.Flags().StringVar(&host, "host", "", "set the BMC host")
updateCmd.Flags().IntVar(&port, "port", 443, "set the BMC port")
updateCmd.Flags().StringVar(&user, "user", "", "set the BMC user")
updateCmd.Flags().StringVar(&pass, "pass", "", "set the BMC password")
updateCmd.Flags().StringVar(&transferProtocol, "transfer-protocol", "HTTP", "set the transfer protocol")
updateCmd.Flags().StringVar(&protocol, "protocol", "https", "set the Redfish protocol")
updateCmd.Flags().StringVar(&firmwarePath, "firmware-path", "", "set the path to the firmware")
updateCmd.Flags().StringVar(&firmwareVersion, "firmware-version", "", "set the version of firmware to be installed")
updateCmd.Flags().StringVar(&component, "component", "", "set the component to upgrade")
updateCmd.Flags().BoolVar(&withSecureTLS, "secure-tls", false, "enable secure TLS")
updateCmd.Flags().BoolVar(&status, "status", false, "get the status of the update")
rootCmd.AddCommand(updateCmd)
}

View file

@ -51,9 +51,7 @@ func AddRedfishEndpoint(data []byte, headers map[string]string) error {
// Add redfish endpoint via POST `/hsm/v2/Inventory/RedfishEndpoints` endpoint
url := makeEndpointUrl("/Inventory/RedfishEndpoints")
res, body, err := util.MakeRequest(url, "POST", data, headers)
fmt.Printf("smd url: %v\n", url)
fmt.Printf("res: %v\n", res.Status)
fmt.Printf("body: %v\n", string(body))
fmt.Printf("%v (%v)\n%s\n", url, res.Status, string(body))
if res != nil {
if res.StatusCode != http.StatusOK {
return fmt.Errorf("could not add redfish endpoint")
@ -69,9 +67,7 @@ func UpdateRedfishEndpoint(xname string, data []byte, headers map[string]string)
// Update redfish endpoint via PUT `/hsm/v2/Inventory/RedfishEndpoints` endpoint
url := makeEndpointUrl("/Inventory/RedfishEndpoints/" + xname)
res, body, err := util.MakeRequest(url, "PUT", data, headers)
fmt.Printf("smd url: %v\n", url)
fmt.Printf("res: %v\n", res.Status)
fmt.Printf("body: %v\n", string(body))
fmt.Printf("%v (%v)\n%s\n", url, res.Status, string(body))
if res != nil {
if res.StatusCode != http.StatusOK {
return fmt.Errorf("could not update redfish endpoint")

View file

@ -38,6 +38,7 @@ const (
type QueryParams struct {
Host string
Port int
Protocol string
User string
Pass string
Drivers []string
@ -53,9 +54,6 @@ type QueryParams struct {
}
func NewClient(l *log.Logger, q *QueryParams) (*bmclib.Client, error) {
// NOTE: bmclib.NewClient(host, port, user, pass)
// ...seems like the `port` params doesn't work like expected depending on interface
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
@ -77,22 +75,20 @@ func NewClient(l *log.Logger, q *QueryParams) (*bmclib.Client, error) {
}
// only work if valid cert is provided
if q.WithSecureTLS {
var pool *x509.CertPool
if q.CertPoolFile != "" {
pool = x509.NewCertPool()
if q.WithSecureTLS && q.CertPoolFile != "" {
pool := x509.NewCertPool()
data, err := os.ReadFile(q.CertPoolFile)
if err != nil {
return nil, fmt.Errorf("could not read cert pool file: %v", err)
}
pool.AppendCertsFromPEM(data)
}
// a nil pool uses the system certs
clientOpts = append(clientOpts, bmclib.WithSecureTLS(pool))
}
url := ""
fmt.Println(url)
if q.User != "" && q.Pass != "" {
url += fmt.Sprintf("https://%s:%s@%s", q.User, q.Pass, q.Host)
url += fmt.Sprintf("%s://%s:%s@%s", q.Protocol, q.User, q.Pass, q.Host)
} else {
url += q.Host
}
@ -172,7 +168,7 @@ func CollectInfo(probeStates *[]ScannedResult, l *log.Logger, q *QueryParams) er
var rm map[string]json.RawMessage
// inventories
inventory, err := QueryInventory(client, l, q)
inventory, err := QueryInventory(client, q)
if err != nil {
l.Log.Errorf("could not query inventory (%v:%v): %v", q.Host, q.Port, err)
}
@ -189,7 +185,7 @@ func CollectInfo(probeStates *[]ScannedResult, l *log.Logger, q *QueryParams) er
data["Chassis"] = rm["Chassis"]
// ethernet interfaces
interfaces, err := QueryEthernetInterfaces(client, l, q)
interfaces, err := QueryEthernetInterfaces(client, q)
if err != nil {
l.Log.Errorf("could not query ethernet interfaces: %v", err)
continue
@ -312,7 +308,7 @@ func CollectInfo(probeStates *[]ScannedResult, l *log.Logger, q *QueryParams) er
return nil
}
func QueryMetadata(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryMetadata(client *bmclib.Client, q *QueryParams) ([]byte, error) {
// client, err := NewClient(l, q)
// open BMC session and update driver registry
@ -346,7 +342,7 @@ func QueryMetadata(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte
return b, nil
}
func QueryInventory(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryInventory(client *bmclib.Client, q *QueryParams) ([]byte, error) {
// open BMC session and update driver registry
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*time.Duration(q.Timeout))
client.Registry.FilterForCompatible(ctx)
@ -379,7 +375,7 @@ func QueryInventory(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byt
return b, nil
}
func QueryPowerState(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryPowerState(client *bmclib.Client, q *QueryParams) ([]byte, error) {
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*time.Duration(q.Timeout))
client.Registry.FilterForCompatible(ctx)
err := client.PreferProvider(q.Preferred).Open(ctx)
@ -411,7 +407,7 @@ func QueryPowerState(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]by
}
func QueryUsers(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryUsers(client *bmclib.Client, q *QueryParams) ([]byte, error) {
// open BMC session and update driver registry
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*time.Duration(q.Timeout))
client.Registry.FilterForCompatible(ctx)
@ -445,7 +441,7 @@ func QueryUsers(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, e
return b, nil
}
func QueryBios(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryBios(client *bmclib.Client, q *QueryParams) ([]byte, error) {
// client, err := NewClient(l, q)
// if err != nil {
// return nil, fmt.Errorf("could not make query: %v", err)
@ -457,14 +453,27 @@ func QueryBios(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, er
return b, err
}
func QueryEthernetInterfaces(client *bmclib.Client, l *log.Logger, q *QueryParams) ([]byte, error) {
func QueryEthernetInterfaces(client *bmclib.Client, q *QueryParams) ([]byte, error) {
c, err := connectGofish(q)
if err != nil {
return nil, fmt.Errorf("could not connect to bmc: %v", err)
}
interfaces, err := redfish.ListReferencedEthernetInterfaces(c, "/redfish/v1/Systems/")
systems, err := c.Service.Systems()
if err != nil {
return nil, fmt.Errorf("could not query storage systems (%v:%v): %v", q.Host, q.Port, err)
}
var interfaces []*redfish.EthernetInterface
for _, system := range systems {
i, err := redfish.ListReferencedEthernetInterfaces(c, "/redfish/v1/Systems/" + system.ID + "/EthernetInterfaces/")
if err != nil {
continue
}
interfaces = append(interfaces, i...)
}
if len(interfaces) <= 0 {
return nil, fmt.Errorf("could not get ethernet interfaces: %v", err)
}
@ -582,12 +591,7 @@ func QueryRegisteries(q *QueryParams) ([]byte, error) {
}
func QueryProcessors(q *QueryParams) ([]byte, error) {
baseUrl := "https://"
if q.User != "" && q.Pass != "" {
baseUrl += fmt.Sprintf("%s:%s@", q.User, q.Pass)
}
baseUrl += fmt.Sprintf("%s:%d", q.Host, q.Port)
url := baseUrl + "/redfish/v1/Systems"
url := baseRedfishUrl(q) + "/Systems"
res, body, err := util.MakeRequest(url, "GET", nil, nil)
if err != nil {
return nil, fmt.Errorf("something went wrong: %v", err)
@ -606,7 +610,7 @@ func QueryProcessors(q *QueryParams) ([]byte, error) {
// request data about each processor member on node
for _, member := range members {
var oid = member["@odata.id"].(string)
var infoUrl = baseUrl + oid
var infoUrl = url + oid
res, _, err := util.MakeRequest(infoUrl, "GET", nil, nil)
if err != nil {
return nil, fmt.Errorf("something went wrong: %v", err)
@ -633,6 +637,7 @@ func connectGofish(q *QueryParams) (*gofish.APIClient, error) {
config := makeGofishConfig(q)
c, err := gofish.Connect(config)
if err != nil {
return nil, fmt.Errorf("could not connect to redfish endpoint: %v", err)
}
if c != nil {
@ -647,12 +652,7 @@ func connectGofish(q *QueryParams) (*gofish.APIClient, error) {
}
func makeGofishConfig(q *QueryParams) gofish.ClientConfig {
url := "https://"
if q.User != "" && q.Pass != "" {
url += fmt.Sprintf("%s:%s@", q.User, q.Pass)
}
url += fmt.Sprintf("%s:%d", q.Host, q.Port)
url := baseRedfishUrl(q)
return gofish.ClientConfig{
Endpoint: url,
Username: q.User,
@ -690,3 +690,11 @@ func makeJson(object any) ([]byte, error) {
}
return []byte(b), nil
}
func baseRedfishUrl(q *QueryParams) string {
url := fmt.Sprintf("%s://", q.Protocol)
if q.User != "" && q.Pass != "" {
url += fmt.Sprintf("%s:%s@", q.User, q.Pass)
}
return fmt.Sprintf("%s%s:%d", url, q.Host, q.Port)
}

View file

@ -8,12 +8,7 @@ import (
"github.com/jmoiron/sqlx"
)
func InsertProbeResults(path string, states *[]magellan.ScannedResult) error {
if states == nil {
return fmt.Errorf("states == nil")
}
// create database if it doesn't already exist
func CreateProbeResultsIfNotExists(path string) (*sqlx.DB, error) {
schema := `
CREATE TABLE IF NOT EXISTS magellan_scanned_ports (
host TEXT NOT NULL,
@ -25,9 +20,22 @@ func InsertProbeResults(path string, states *[]magellan.ScannedResult) error {
`
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return fmt.Errorf("could not open database: %v", err)
return nil, fmt.Errorf("could not open database: %v", err)
}
db.MustExec(schema)
return db, nil
}
func InsertProbeResults(path string, states *[]magellan.BMCProbeResult) error {
if states == nil {
return fmt.Errorf("states == nil")
}
// create database if it doesn't already exist
db, err := CreateProbeResultsIfNotExists(path)
if err != nil {
return err
}
// insert all probe states into db
tx := db.MustBegin()
@ -46,6 +54,30 @@ func InsertProbeResults(path string, states *[]magellan.ScannedResult) error {
return nil
}
func DeleteProbeResults(path string, results *[]magellan.BMCProbeResult) error {
if results == nil {
return fmt.Errorf("no probe results found")
}
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return fmt.Errorf("could not open database: %v", err)
}
tx := db.MustBegin()
for _, state := range *results {
sql := `DELETE FROM magellan_scanned_ports WHERE host = :host, port = :port;`
_, err := tx.NamedExec(sql, &state)
if err != nil {
fmt.Printf("could not execute transaction: %v\n", err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("could not commit transaction: %v", err)
}
return nil
}
func GetProbeResults(path string) ([]magellan.ScannedResult, error) {
db, err := sqlx.Open("sqlite3", path)
if err != nil {

195
internal/update.go Normal file
View file

@ -0,0 +1,195 @@
package magellan
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/bikeshack/magellan/internal/log"
"github.com/bikeshack/magellan/internal/util"
bmclib "github.com/bmc-toolbox/bmclib/v2"
"github.com/bmc-toolbox/bmclib/v2/constants"
bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors"
"github.com/sirupsen/logrus"
)
type UpdateParams struct {
QueryParams
FirmwarePath string
FirmwareVersion string
Component string
TransferProtocol string
}
// NOTE: Does not work since OpenBMC, whic bmclib uses underneath, does not
// support multipart updates. See issue: https://github.com/bmc-toolbox/bmclib/issues/341
func UpdateFirmware(client *bmclib.Client, l *log.Logger, q *UpdateParams) error {
if q.Component == "" {
return fmt.Errorf("component is required")
}
// open BMC session and update driver registry
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*time.Duration(q.Timeout))
client.Registry.FilterForCompatible(ctx)
err := client.Open(ctx)
if err != nil {
ctxCancel()
return fmt.Errorf("could not connect to bmc: %v", err)
}
defer client.Close(ctx)
file, err := os.Open(q.FirmwarePath)
if err != nil {
ctxCancel()
return fmt.Errorf("could not open firmware path: %v", err)
}
defer file.Close()
taskId, err := client.FirmwareInstall(ctx, q.Component, constants.FirmwareApplyOnReset, true, file)
if err != nil {
ctxCancel()
return fmt.Errorf("could not install firmware: %v", err)
}
for {
if ctx.Err() != nil {
ctxCancel()
return fmt.Errorf("context error: %v", ctx.Err())
}
state, err := client.FirmwareInstallStatus(ctx, q.FirmwareVersion, q.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.Log.Info("BMC refused connection, BMC most likely resetting...")
time.Sleep(2 * time.Second)
continue
}
if errors.Is(err, bmclibErrs.ErrSessionExpired) || strings.Contains(err.Error(), "session expired") {
err := client.Open(ctx)
if err != nil {
l.Log.Fatal(err, "bmc re-login failed")
}
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("BMC session expired, logging in...")
continue
}
l.Log.Fatal(err)
}
switch state {
case constants.FirmwareInstallRunning, constants.FirmwareInstallInitializing:
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("firmware install running")
case constants.FirmwareInstallFailed:
ctxCancel()
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("firmware install failed")
return fmt.Errorf("failed to install firmware")
case constants.FirmwareInstallComplete:
ctxCancel()
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("firmware install completed")
return nil
case constants.FirmwareInstallPowerCyleHost:
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("host powercycle required")
if _, err := client.SetPowerState(ctx, "cycle"); err != nil {
ctxCancel()
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("error power cycling host for install")
return fmt.Errorf("failed to install firmware")
}
ctxCancel()
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("host power cycled, all done!")
return nil
default:
l.Log.WithFields(logrus.Fields{"state": state, "component": q.Component}).Info("unknown state returned")
}
time.Sleep(2 * time.Second)
}
return nil
}
func UpdateFirmwareRemote(q *UpdateParams) error {
url := baseRedfishUrl(&q.QueryParams) + "/redfish/v1/UpdateService/Actions/SimpleUpdate"
headers := map[string]string {
"Content-Type": "application/json",
"cache-control": "no-cache",
}
b := map[string]any{
"UpdateComponent": q.Component, // BMC, BIOS
"TransferProtocol": q.TransferProtocol,
"ImageURI": q.FirmwarePath,
}
data, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("could not marshal data: %v", err)
}
res, body, err := util.MakeRequest(url, "POST", data, headers)
if err != nil {
return fmt.Errorf("something went wrong: %v", err)
} else if res == nil {
return fmt.Errorf("no response returned (url: %s)", url)
}
if len(body) > 0 {
fmt.Printf("%d: %v\n", res.StatusCode, string(body))
}
return nil
}
func GetUpdateStatus(q *UpdateParams) error {
url := baseRedfishUrl(&q.QueryParams) + "/redfish/v1/UpdateService"
res, body, err := util.MakeRequest(url, "GET", nil, nil)
if err != nil {
return fmt.Errorf("something went wrong: %v", err)
} else if res == nil {
return fmt.Errorf("no response returned (url: %s)", url)
} else if res.StatusCode != http.StatusOK {
return fmt.Errorf("returned status code %d", res.StatusCode)
}
if len(body) > 0 {
fmt.Printf("%d: %v\n", res.StatusCode, string(body))
}
return nil
}
// func UpdateFirmwareLocal(q *UpdateParams) error {
// fwUrl := baseUrl(&q.QueryParams) + ""
// url := baseUrl(&q.QueryParams) + "UpdateService/Actions/"
// headers := map[string]string {
// }
// // get etag from FW inventory
// response, err := util.MakeRequest()
// // load file from disk
// file, err := os.ReadFile(q.FirmwarePath)
// if err != nil {
// return fmt.Errorf("could not read file: %v", err)
// }
// switch q.TransferProtocol {
// case "HTTP":
// default:
// return fmt.Errorf("transfer protocol not supported")
// }
// return nil
// }

View file

@ -19,7 +19,10 @@ func PathExists(path string) (bool, error) {
func MakeRequest(url string, httpMethod string, body []byte, headers map[string]string) (*http.Response, []byte, error) {
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
req, _ := http.NewRequest(httpMethod, url, bytes.NewBuffer(body))
req, err := http.NewRequest(httpMethod, url, bytes.NewBuffer(body))
if err != nil {
return nil, nil, fmt.Errorf("could not create new HTTP request: %v", err)
}
req.Header.Add("User-Agent", "magellan")
for k, v := range headers {
req.Header.Add(k, v)