mirror of
https://github.com/davidallendj/magellan.git
synced 2025-12-20 11:37:01 -07:00
Merge pull request #103 from OpenCHAMI/100-pdu-inventorydiscovery-in-openchami
Add PDU collect command to gather PDU inventory
This commit is contained in:
commit
c9d5825114
5 changed files with 238 additions and 0 deletions
12
README.md
12
README.md
|
|
@ -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
116
cmd/pdu-collect.go
Normal 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
96
pkg/jaws/pdu-crawler.go
Normal 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
1
pkg/pdu/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
./magellan pdu collect x3000m0 --username admn --password admn
|
||||||
13
pkg/pdu/pdu-inventory.go
Normal file
13
pkg/pdu/pdu-inventory.go
Normal 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"`
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue