Merge pull request #103 from OpenCHAMI/100-pdu-inventorydiscovery-in-openchami

Add PDU collect command to gather PDU inventory
This commit is contained in:
David Allen 2025-06-17 20:45:01 +02:00 committed by GitHub
commit c9d5825114
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 238 additions and 0 deletions

View file

@ -250,6 +250,18 @@ This maintains the original behavior of passing the `--host` flag to `collect` w
> └── 1747550498.yaml > └── 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 ### 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. 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.

116
cmd/pdu-collect.go Normal file
View file

@ -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 <smd-endpoint>`,
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)
}

96
pkg/jaws/pdu-crawler.go Normal file
View file

@ -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
}

1
pkg/pdu/README.md Normal file
View file

@ -0,0 +1 @@
./magellan pdu collect x3000m0 --username admn --password admn

13
pkg/pdu/pdu-inventory.go Normal file
View file

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