refactor: moved internal functions to pkg and updated refs

This commit is contained in:
David Allen 2025-02-20 17:09:21 -07:00 committed by David Allen
parent 03c54cc7c1
commit e19af0ce0c
Signed by: towk
GPG key ID: 0430CDBE22619155
8 changed files with 13 additions and 9 deletions

View file

@ -4,8 +4,8 @@ import (
"fmt"
"strings"
magellan "github.com/davidallendj/magellan/internal"
"github.com/davidallendj/magellan/internal/util"
"github.com/OpenCHAMI/magellan/internal/util"
magellan "github.com/OpenCHAMI/magellan/pkg"
"github.com/jmoiron/sqlx"
)

View file

@ -1,317 +0,0 @@
// Package magellan implements the core routines for the tools.
package magellan
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/davidallendj/magellan/pkg/client"
"github.com/davidallendj/magellan/pkg/crawler"
"github.com/rs/zerolog/log"
"github.com/Cray-HPE/hms-xname/xnames"
_ "github.com/mattn/go-sqlite3"
"github.com/stmcginnis/gofish"
"github.com/stmcginnis/gofish/redfish"
"golang.org/x/exp/slices"
)
// CollectParams is a collection of common parameters passed to the CLI
// for the 'collect' subcommand.
type CollectParams struct {
URI string // set by the 'host' flag
Username string // set the BMC username with the 'username' flag
Password string // set the BMC password with the 'password' flag
Concurrency int // set the of concurrent jobs with the 'concurrency' flag
Timeout int // set the timeout with the 'timeout' flag
CaCertPath string // set the cert path with the 'cacert' flag
Verbose bool // set whether to include verbose output with 'verbose' flag
OutputPath string // set the path to save output with 'output' flag
ForceUpdate bool // set whether to force updating SMD with 'force-update' flag
AccessToken string // set the access token to include in request with 'access-token' flag
}
// This is the main function used to collect information from the BMC nodes via Redfish.
// The results of the collect are stored in a cache specified with the `--cache` flag.
// The function expects a list of hosts found using the `ScanForAssets()` function.
//
// Requests can be made to several of the nodes using a goroutine by setting the q.Concurrency
// property value between 1 and 10000.
func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error {
// check for available remote assets found from scan
if assets == nil {
return fmt.Errorf("no assets found")
}
if len(*assets) <= 0 {
return fmt.Errorf("no assets found")
}
// collect bmc information asynchronously
var (
offset = 0
wg sync.WaitGroup
found = make([]string, 0, len(*assets))
done = make(chan struct{}, params.Concurrency+1)
chanAssets = make(chan RemoteAsset, params.Concurrency+1)
outputPath = path.Clean(params.OutputPath)
smdClient = &client.SmdClient{Client: &http.Client{}}
)
// set the client's params from CLI
// NOTE: temporary solution until client.NewClient() is fixed
smdClient.URI = params.URI
if params.CaCertPath != "" {
cacert, err := os.ReadFile(params.CaCertPath)
if err != nil {
return fmt.Errorf("failed to read CA cert path: %w", err)
}
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
smdClient.Client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
},
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 120 * time.Second,
KeepAlive: 120 * time.Second,
}).Dial,
TLSHandshakeTimeout: 120 * time.Second,
ResponseHeaderTimeout: 120 * time.Second,
}
}
wg.Add(params.Concurrency)
for i := 0; i < params.Concurrency; i++ {
go func() {
for {
sr, ok := <-chanAssets
if !ok {
wg.Done()
return
}
// generate custom xnames for bmcs
// TODO: add xname customization via CLI
node := xnames.Node{
Cabinet: 1000,
Chassis: 1,
ComputeModule: 7,
NodeBMC: offset,
}
offset += 1
// crawl BMC node to fetch inventory data via Redfish
var (
systems []crawler.InventoryDetail
managers []crawler.Manager
config = crawler.CrawlerConfig{
URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port),
Username: params.Username,
Password: params.Password,
Insecure: true,
}
)
systems, err := crawler.CrawlBMCForSystems(config)
if err != nil {
log.Error().Err(err).Msg("failed to crawl BMC for systems")
}
managers, err = crawler.CrawlBMCForManagers(config)
if err != nil {
log.Error().Err(err).Msg("failed to crawl BMC for managers")
}
// data to be sent to smd
data := map[string]any{
"ID": fmt.Sprintf("%v", node.String()[:len(node.String())-2]),
"Type": "",
"Name": "",
"FQDN": sr.Host,
"User": params.Username,
"MACRequired": true,
"RediscoverOnUpdate": false,
"Systems": systems,
"Managers": managers,
"SchemaVersion": 1,
}
// optionally, add the MACAddr property if we find a matching IP
// from the correct ethernet interface
mac, err := FindMACAddressWithIP(config, net.ParseIP(sr.Host))
if err != nil {
log.Warn().Err(err).Msgf("failed to find MAC address with IP '%s'", sr.Host)
}
if mac != "" {
data["MACAddr"] = mac
}
// create and set headers for request
headers := client.HTTPHeader{}
headers.Authorization(params.AccessToken)
headers.ContentType("application/json")
body, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Error().Err(err).Msgf("failed to marshal output to JSON")
}
if params.Verbose {
fmt.Printf("%v\n", string(body))
}
// write JSON data to file if output path is set using hive partitioning strategy
if outputPath != "" {
var (
finalPath = fmt.Sprintf("./%s/%s/%d.json", outputPath, data["ID"], time.Now().Unix())
finalDir = filepath.Dir(finalPath)
)
// if it doesn't, make the directory and write file
err = os.MkdirAll(finalDir, 0o777)
if err == nil { // no error
err = os.WriteFile(path.Clean(finalPath), body, os.ModePerm)
if err != nil {
log.Error().Err(err).Msgf("failed to write collect output to file")
}
} else { // error is set
log.Error().Err(err).Msg("failed to make directory for collect output")
}
}
// add all endpoints to SMD ONLY if a host is provided
if smdClient.URI != "" {
err = smdClient.Add(body, headers)
if err != nil {
// try updating instead
if params.ForceUpdate {
smdClient.Xname = data["ID"].(string)
err = smdClient.Update(body, headers)
if err != nil {
log.Error().Err(err).Msgf("failed to forcibly update Redfish endpoint")
}
} else {
log.Error().Err(err).Msgf("failed to add Redfish endpoint")
}
}
} else {
if params.Verbose {
log.Warn().Msg("no request made (host argument is empty)")
}
}
// got host information, so add to list of already probed hosts
found = append(found, sr.Host)
}
}()
}
// use the found results to query bmc information
for _, ps := range *assets {
// skip if found info from host
foundHost := slices.Index(found, ps.Host)
if !ps.State || foundHost >= 0 {
continue
}
chanAssets <- ps
}
// handle goroutine paths
go func() {
select {
case <-done:
wg.Done()
break
default:
time.Sleep(1000)
}
}()
close(chanAssets)
wg.Wait()
close(done)
return nil
}
// FindMACAddressWithIP() returns the MAC address of an ethernet interface with
// a matching IPv4Address. Returns an empty string and error if there are no matches
// found.
func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string, error) {
// get the managers to find the BMC MAC address compared with IP
//
// NOTE: Since we don't have a RedfishEndpoint type abstraction in
// magellan and the crawler crawls for systems information, it
// may just make more sense to get the managers directly via
// gofish (at least for now). If there's a need for grabbing more
// manager information in the future, we can move the logic into
// the crawler.
client, err := gofish.Connect(gofish.ClientConfig{
Endpoint: config.URI,
Username: config.Username,
Password: config.Password,
Insecure: config.Insecure,
BasicAuth: true,
})
if err != nil {
if strings.HasPrefix(err.Error(), "404:") {
err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", config.URI)
}
if strings.HasPrefix(err.Error(), "401:") {
err = fmt.Errorf("authentication failed. Check your username and password: %s", config.URI)
}
event := log.Error()
event.Err(err)
event.Msg("failed to connect to BMC")
return "", err
}
defer client.Logout()
var (
rf_service = client.GetService()
rf_managers []*redfish.Manager
)
rf_managers, err = rf_service.Managers()
if err != nil {
return "", fmt.Errorf("failed to get managers: %v", err)
}
// find the manager with the same IP address of the BMC to get
// it's MAC address from its EthernetInterface
for _, manager := range rf_managers {
eths, err := manager.EthernetInterfaces()
if err != nil {
log.Error().Err(err).Msgf("failed to get ethernet interfaces from manager '%s'", manager.Name)
continue
}
for _, eth := range eths {
// compare the ethernet interface IP with argument
for _, ip := range eth.IPv4Addresses {
if ip.Address == targetIP.String() {
// we found matching IP address so return the ethernet interface MAC
return eth.MACAddress, nil
}
}
// do the same thing as above, but with static IP addresses
for _, ip := range eth.IPv4StaticAddresses {
if ip.Address == targetIP.String() {
return eth.MACAddress, nil
}
}
// no matches found, so go to next ethernet interface
continue
}
}
// no matches found, so return an empty string
return "", fmt.Errorf("no ethernet interfaces found with IP address")
}

View file

@ -1,250 +0,0 @@
package magellan
import (
"fmt"
"math"
"net"
"net/http"
"net/url"
"strconv"
"sync"
"time"
urlx "github.com/davidallendj/magellan/internal/url"
"github.com/davidallendj/magellan/pkg/client"
"github.com/rs/zerolog/log"
)
type RemoteAsset struct {
Host string `db:"host" json:"host"`
Port int `db:"port" json:"port"`
Protocol string `db:"protocol" json:"protocol"`
State bool `db:"state" json:"state"`
Timestamp time.Time `db:"timestamp" json:"timestamp"`
}
// ScanParams is a collection of commom parameters passed to the CLI
type ScanParams struct {
TargetHosts [][]string
Scheme string
Protocol string
Concurrency int
Timeout int
DisableProbing bool
Verbose bool
Debug bool
}
// ScanForAssets() performs a net scan on a network to find available services
// running. The function expects a list of targets (as [][]string) to make requests.
// The 2D list is to permit one goroutine per BMC node when making each request.
//
// This function runs in a goroutine with the "concurrency" flag setting the
// number of concurrent requests. Only one request is made to each BMC node
// at a time, but setting a value greater than 1 with enable the requests
// to be made concurrently.
//
// If the "disableProbing" flag is set, then the function will skip the extra
// HTTP request made to check if the response was from a Redfish service.
// Otherwise, not receiving a 200 OK response code from the HTTP request will
// remove the service from being stored in the list of scanned results.
//
// Returns a list of scanned results to be stored in cache (but isn't doing here).
func ScanForAssets(params *ScanParams) []RemoteAsset {
var (
results = make([]RemoteAsset, 0, len(params.TargetHosts))
done = make(chan struct{}, params.Concurrency+1)
chanHosts = make(chan []string, params.Concurrency+1)
)
if params.Verbose {
log.Info().Any("args", params).Msg("starting scan...")
}
var wg sync.WaitGroup
wg.Add(params.Concurrency)
for i := 0; i < params.Concurrency; i++ {
go func() {
for {
hosts, ok := <-chanHosts
if !ok {
wg.Done()
return
}
for _, host := range hosts {
foundAssets, err := rawConnect(host, params.Protocol, params.Timeout, true)
// if we failed to connect, exit from the function
if err != nil {
if params.Verbose {
log.Debug().Err(err).Msgf("failed to connect to host")
}
wg.Done()
return
}
if !params.DisableProbing {
assetsToAdd := []RemoteAsset{}
for _, foundAsset := range foundAssets {
url := fmt.Sprintf("%s:%d/redfish/v1/", foundAsset.Host, foundAsset.Port)
res, _, err := client.MakeRequest(nil, url, http.MethodGet, nil, nil)
if err != nil || res == nil {
if params.Verbose {
log.Printf("failed to make request: %v\n", err)
}
continue
} else if res.StatusCode != http.StatusOK {
if params.Verbose {
log.Printf("request returned code: %v\n", res.StatusCode)
}
continue
} else {
assetsToAdd = append(assetsToAdd, foundAsset)
}
}
results = append(results, assetsToAdd...)
} else {
results = append(results, foundAssets...)
}
}
}
}()
}
for _, hosts := range params.TargetHosts {
chanHosts <- hosts
}
go func() {
select {
case <-done:
wg.Done()
break
default:
time.Sleep(1000)
}
}()
close(chanHosts)
wg.Wait()
close(done)
if params.Verbose {
log.Info().Msg("scan complete")
}
return results
}
// GenerateHostsWithSubnet() builds a list of hosts to scan using the "subnet"
// and "subnetMask" arguments passed. The function is capable of
// distinguishing between IP formats: a subnet with just an IP address (172.16.0.0)
// and a subnet with IP address and CIDR (172.16.0.0/24).
//
// NOTE: If a IP address is provided with CIDR, then the "subnetMask"
// parameter will be ignored. If neither is provided, then the default
// subnet mask will be used instead.
func GenerateHostsWithSubnet(subnet string, subnetMask *net.IPMask, additionalPorts []int, defaultScheme string) [][]string {
if subnet == "" || subnetMask == nil {
return nil
}
// convert subnets from string to net.IP to test if CIDR is included
subnetIp := net.ParseIP(subnet)
if subnetIp == nil {
// not a valid IP so try again with CIDR
ip, network, err := net.ParseCIDR(subnet)
if err != nil {
return nil
}
subnetIp = ip
if network == nil {
// use the default subnet mask if a valid one is not provided
network = &net.IPNet{
IP: subnetIp,
Mask: net.IPv4Mask(255, 255, 255, 0),
}
}
subnetMask = &network.Mask
}
// generate new IPs from subnet and format to full URL
subnetIps := generateIPsWithSubnet(&subnetIp, subnetMask)
return urlx.FormatIPs(subnetIps, additionalPorts, defaultScheme, false)
}
// GetDefaultPorts() returns a list of default ports. The only reason to have
// this function is to add/remove ports without affecting usage.
func GetDefaultPorts() []int {
return []int{443}
}
// rawConnect() tries to connect to the host using DialTimeout() and waits
// until a response is receive or if the timeout (in seconds) expires. This
// function expects a full URL such as https://my.bmc.host:443/ to make the
// connection.
func rawConnect(address string, protocol string, timeoutSeconds int, keepOpenOnly bool) ([]RemoteAsset, error) {
uri, err := url.ParseRequestURI(address)
if err != nil {
return nil, fmt.Errorf("failed to split host/port: %w", err)
}
// convert port to its "proper" type
port, err := strconv.Atoi(uri.Port())
if err != nil {
return nil, fmt.Errorf("failed to convert port to integer type: %w", err)
}
var (
timeoutDuration = time.Second * time.Duration(timeoutSeconds)
assets []RemoteAsset
asset = RemoteAsset{
Host: fmt.Sprintf("%s://%s", uri.Scheme, uri.Hostname()),
Port: port,
Protocol: protocol,
State: false,
Timestamp: time.Now(),
}
)
// try to conntect to host (expects host in format [10.0.0.0]:443)
target := fmt.Sprintf("%s:%s", uri.Hostname(), uri.Port())
conn, err := net.DialTimeout(protocol, target, timeoutDuration)
if err != nil {
asset.State = false
return nil, fmt.Errorf("failed to dial host: %w", err)
}
if conn != nil {
asset.State = true
defer conn.Close()
}
if keepOpenOnly {
if asset.State {
assets = append(assets, asset)
}
} else {
assets = append(assets, asset)
}
return assets, nil
}
// generateIPsWithSubnet() returns a collection of host IP strings with a
// provided subnet mask.
//
// TODO: add a way for filtering/exclude specific IPs and IP ranges.
func generateIPsWithSubnet(ip *net.IP, mask *net.IPMask) []string {
// check if subnet IP and mask are valid
if ip == nil || mask == nil {
log.Error().Msg("invalid subnet IP or mask (ip == nil or mask == nil)")
return nil
}
// get all IP addresses in network
ones, bits := mask.Size()
hosts := []string{}
end := int(math.Pow(2, float64((bits-ones)))) - 1
for i := 0; i < end; i++ {
ip = client.GetNextIP(ip, 1)
if ip == nil {
continue
}
hosts = append(hosts, ip.String())
}
return hosts
}

View file

@ -1,92 +0,0 @@
package magellan
import (
"fmt"
"net/url"
"github.com/stmcginnis/gofish"
"github.com/stmcginnis/gofish/redfish"
)
type UpdateParams struct {
CollectParams
FirmwarePath string
FirmwareVersion string
Component string
TransferProtocol string
Insecure bool
}
// UpdateFirmwareRemote() uses 'gofish' to update the firmware of a BMC node.
// The function expects the firmware URL, firmware version, and component flags to be
// set from the CLI to perform a firmware update.
// Example:
// ./magellan update https://192.168.23.40 --username root --password 0penBmc
// --firmware-url http://192.168.23.19:1337/obmc-phosphor-image.static.mtd.tar
// --scheme TFTP
//
// being:
// q.URI https://192.168.23.40
// q.TransferProtocol TFTP
// q.FirmwarePath http://192.168.23.19:1337/obmc-phosphor-image.static.mtd.tar
func UpdateFirmwareRemote(q *UpdateParams) error {
// parse URI to set up full address
uri, err := url.ParseRequestURI(q.URI)
if err != nil {
return fmt.Errorf("failed to parse URI: %w", err)
}
// Connect to the Redfish service using gofish
client, err := gofish.Connect(gofish.ClientConfig{Endpoint: uri.String(), Username: q.Username, Password: q.Password, Insecure: q.Insecure})
if err != nil {
return fmt.Errorf("failed to connect to Redfish service: %w", err)
}
defer client.Logout()
// Retrieve the UpdateService from the Redfish client
updateService, err := client.Service.UpdateService()
if err != nil {
return fmt.Errorf("failed to get update service: %w", err)
}
// Build the update request payload
req := redfish.SimpleUpdateParameters{
ImageURI: q.FirmwarePath,
TransferProtocol: redfish.TransferProtocolType(q.TransferProtocol),
}
// Execute the SimpleUpdate action
err = updateService.SimpleUpdate(&req)
if err != nil {
return fmt.Errorf("firmware update failed: %w", err)
}
fmt.Println("Firmware update initiated successfully.")
return nil
}
func GetUpdateStatus(q *UpdateParams) error {
// parse URI to set up full address
uri, err := url.ParseRequestURI(q.URI)
if err != nil {
return fmt.Errorf("failed to parse URI: %w", err)
}
// Connect to the Redfish service using gofish
client, err := gofish.Connect(gofish.ClientConfig{Endpoint: uri.String(), Username: q.Username, Password: q.Password, Insecure: q.Insecure})
if err != nil {
return fmt.Errorf("failed to connect to Redfish service: %w", err)
}
defer client.Logout()
// Retrieve the UpdateService from the Redfish client
updateService, err := client.Service.UpdateService()
if err != nil {
return fmt.Errorf("failed to get update service: %w", err)
}
// Get the update status
status := updateService.Status
fmt.Printf("Update Status: %v\n", status)
return nil
}