diff --git a/README.md b/README.md index d9eee43..2c10e28 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,18 @@ This maintains the original behavior of passing the `--host` flag to `collect` w > └── 1747550498.yaml > ``` +### PDU Inventory Collection + +In addition to collecting Redfish inventory from BMCs, `magellan` can also collect inventory from Power Distribution Units (PDUs) that expose a JAWS-style API. The `collect` command has a `pdu` subcommand for this purpose. + +The command connects to the specified PDU host(s), gathers all outlet information, and transforms it into the nested JSON format required by SMD in OpenCHAMI. + +The most common workflow is to collect from the PDU and pipe the JSON output directly to the `magellan send` command, which then POSTs the data to a running SMD instance. + +```bash +# Collect from a PDU and pipe the output directly to a local SMD instance +./magellan collect pdu pdu.example.com --username admin --password "pdu-password" | ./magellan send http://localhost:27779 + ### Managing Secrets When connecting to an array of BMC nodes, some nodes may have different secret credentials than the rest. These secrets can be stored and used automatically by `magellan` when performing a `collect` or a `crawl`. All secrets are encrypted and are only accessible using the same `MASTER_KEY` as when stored originally. diff --git a/cmd/pdu-collect.go b/cmd/pdu-collect.go new file mode 100644 index 0000000..ba281ed --- /dev/null +++ b/cmd/pdu-collect.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "unicode" + + "github.com/OpenCHAMI/magellan/pkg/jaws" + "github.com/OpenCHAMI/magellan/pkg/pdu" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func transformToSMDFormat(inventory *pdu.PDUInventory) []map[string]any { + smdOutlets := make([]map[string]any, 0) + for _, outlet := range inventory.Outlets { + var letterPart, numberPart string + splitIndex := strings.IndexFunc(outlet.ID, unicode.IsDigit) + + if splitIndex == -1 { + log.Warn().Msgf("could not parse outlet ID format for '%s', skipping outlet", outlet.ID) + continue + } + letterPart = outlet.ID[:splitIndex] + numberPart = outlet.ID[splitIndex:] + + var pValue int + if len(letterPart) > 0 { + pValue = int(unicode.ToUpper(rune(letterPart[0])) - 'A') + } + + idSuffix := fmt.Sprintf("p%dv%s", pValue, numberPart) + + rawOutlet := map[string]any{ + "original_id": outlet.ID, + "id_suffix": idSuffix, + "name": outlet.Name, + "state": outlet.PowerState, + "socket_type": outlet.SocketType, + } + smdOutlets = append(smdOutlets, rawOutlet) + } + + pduRecord := map[string]any{ + "ID": inventory.Hostname, + "Type": "Node", + "FQDN": inventory.Hostname, + "Hostname": inventory.Hostname, + "Enabled": true, + "RediscoverOnUpdate": false, + "PDUInventory": map[string]any{ + "Outlets": smdOutlets, + }, + } + + return []map[string]any{pduRecord} +} + +var pduCmd = &cobra.Command{ + Use: "pdu [hosts...]", + Short: "Collect inventory from JAWS-based PDUs", + Long: `Connects to one or more PDUs with a JAWS interface to collect hardware inventory.`, + Example: ` + // Collect inventory from a single PDU using credentials + magellan collect pdu x3000m0 --username admin --password inital0 + + // Collect from multiple PDUs and send to SMD + magellan collect pdu x3000m0 x3000m1 -u admin -p initial0 | ./magellan send `, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + log.Error().Msg("no PDU hosts provided") + return + } + + if username == "" || password == "" { + log.Error().Msg("--username and --password are required for PDU collection") + return + } + + allSmdRecords := make([]map[string]any, 0) + + for _, host := range args { + log.Info().Msgf("Collecting from PDU: %s", host) + config := jaws.CrawlerConfig{ + URI: host, + Username: username, + Password: password, + Insecure: true, + } + + inventory, err := jaws.CrawlPDU(config) + if err != nil { + log.Error().Err(err).Msgf("failed to crawl PDU %s", host) + continue + } + + smdRecords := transformToSMDFormat(inventory) + + allSmdRecords = append(allSmdRecords, smdRecords...) + } + + output, err := json.MarshalIndent(allSmdRecords, "", " ") + if err != nil { + log.Error().Err(err).Msgf("failed to marshal SMD records to JSON") + } + fmt.Println(string(output)) + }, +} + +func init() { + pduCmd.Flags().StringVarP(&username, "username", "u", "", "Set the PDU username") + pduCmd.Flags().StringVarP(&password, "password", "p", "", "Set the PDU password") + + CollectCmd.AddCommand(pduCmd) +} diff --git a/pkg/jaws/pdu-crawler.go b/pkg/jaws/pdu-crawler.go new file mode 100644 index 0000000..3cafa61 --- /dev/null +++ b/pkg/jaws/pdu-crawler.go @@ -0,0 +1,96 @@ +package jaws + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/OpenCHAMI/magellan/pkg/pdu" + "github.com/rs/zerolog/log" +) + +type CrawlerConfig struct { + URI string + Username string + Password string + Insecure bool + Timeout time.Duration +} + +// JawsOutlet represents the structure of a single outlet object +type JawsOutlet struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + SocketType string `json:"socket_type"` + Current float32 `json:"current"` + Voltage float32 `json:"voltage"` + ActivePower int `json:"active_power"` +} + +// CrawlPDU connects to a single JAWS PDU and collects its full inventory. +func CrawlPDU(config CrawlerConfig) (*pdu.PDUInventory, error) { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: config.Insecure}, + } + client := &http.Client{ + Timeout: config.Timeout, + Transport: transport, + } + + inventory := &pdu.PDUInventory{ + Hostname: config.URI, + } + + targetURL := fmt.Sprintf("https://%s/jaws/monitor/outlets", config.URI) + + req, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + log.Error().Err(err).Msg("failed to create new HTTP request for outlets") + return nil, err + } + req.SetBasicAuth(config.Username, config.Password) + + log.Debug().Msgf("querying JAWS endpoint: %s", targetURL) + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msgf("failed to execute request to JAWS outlets endpoint %s", targetURL) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("received non-200 status code from outlets endpoint: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + log.Error().Err(err).Str("url", targetURL).Msg("bad response from PDU") + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("failed to read outlets response body") + return nil, err + } + log.Debug().RawJSON("response_body", body).Msg("received response from JAWS outlets") + + var rawOutlets []JawsOutlet + if err := json.Unmarshal(body, &rawOutlets); err != nil { + log.Error().Err(err).Msg("failed to unmarshal JAWS outlet data") + return nil, err + } + + for _, rawOutlet := range rawOutlets { + outlet := pdu.PDUOutlet{ + ID: rawOutlet.ID, + Name: rawOutlet.Name, + PowerState: rawOutlet.State, + SocketType: rawOutlet.SocketType, + } + inventory.Outlets = append(inventory.Outlets, outlet) + } + + log.Info().Msgf("successfully collected inventory for %d outlets from %s", len(inventory.Outlets), config.URI) + return inventory, nil +} diff --git a/pkg/pdu/README.md b/pkg/pdu/README.md new file mode 100644 index 0000000..b077941 --- /dev/null +++ b/pkg/pdu/README.md @@ -0,0 +1 @@ +./magellan pdu collect x3000m0 --username admn --password admn diff --git a/pkg/pdu/pdu-inventory.go b/pkg/pdu/pdu-inventory.go new file mode 100644 index 0000000..71e956a --- /dev/null +++ b/pkg/pdu/pdu-inventory.go @@ -0,0 +1,13 @@ +package pdu + +type PDUOutlet struct { + ID string `json:"id"` // e.g., "35" or "BA35" + Name string `json:"name"` // e.g., "Link1_Outlet_35" + PowerState string `json:"power_state"` // e.g., "ON" or "OFF" + SocketType string `json:"socket_type"` +} + +type PDUInventory struct { + Hostname string `json:"hostname"` + Outlets []PDUOutlet `json:"outlets"` +}