magellan/cmd/send.go
David Allen 04e1fb26c9
Split the collect Command For Customization (#93)
* feat: initial implementation of command split

* feat: update collect and new send cmd

* chore: cleanup unused code

* chore: refactored getting username

* chore: more refactoring and cleanup

* feat: update send cmd implementation

* chore: changed/updated example config

* chore: made cmd more consistent and added formatting

* refactor: removed --host flag from scan

* chore: cleaned up and fixed issue with client

* chore: cleaned up CLI flags in collect cmd

* feat: updated crawl to include managers and output YAML optionally

* refactor: updated and improved send implementation

* refactor: minor improvements

* refactor: added util func to check for empty slices

* fix: issue with reading from stdin

* refactor: added scheme trimming function for URIs

* refactor: changed host arg back to positional

* refactor: removed unused vars and added --output-dir flag

* fix: make -f for secrets persistent

* refactor: removed --host flag and request in collect

* refactor: changed --output flag to --output-file

* fix: updated flags for collect

* fix: typo in crawler error

* fix: dir being created when outputDir not set

* fix: reading stdin and data args

* fix: made output using -v and -o consistent

* readme: added info about command split

* updated changelog adding missing version entries

* chore: updated example to use host as positional arg

* fix: issue with reading --data arg

* fix: remove unused import from collect pkg

Signed-off-by: Devon Bautista <devonb@lanl.gov>

---------

Signed-off-by: David Allen <16520934+davidallendj@users.noreply.github.com>
Signed-off-by: Devon Bautista <devonb@lanl.gov>
Co-authored-by: Devon Bautista <devonb@lanl.gov>
2025-05-29 15:15:46 -04:00

283 lines
7.7 KiB
Go

package cmd
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
urlx "github.com/OpenCHAMI/magellan/internal/url"
"github.com/OpenCHAMI/magellan/pkg/auth"
"github.com/OpenCHAMI/magellan/pkg/client"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
var (
sendInputFormat string
sendDataArgs []string
)
var sendCmd = &cobra.Command{
Use: "send [data]",
Example: ` // minimal working example
magellan send -d @inventory.json https://smd.openchami.cluster
// send data from multiple files (must specify -f/--format if not JSON)
magellan send -d @cluster-1.json -d @cluster-2.json https://smd.openchami.cluster
magellan send -d '{...}' -d @cluster-1.json https://proxy.example.com
// send data to remote host by piping output of collect directly
magellan collect -v -F yaml | magellan send -d @inventory.yaml -F yaml https://smd.openchami.cluster`,
Short: "Send collected node information to specified host.",
Args: func(cmd *cobra.Command, args []string) error {
return nil
},
Run: func(cmd *cobra.Command, args []string) {
// try to load access token either from env var, file, or config if var not set
if accessToken == "" {
var err error
accessToken, err = auth.LoadAccessToken(tokenPath)
if err != nil && verbose {
log.Warn().Err(err).Msgf("could not load access token")
} else if debug && accessToken != "" {
log.Debug().Str("access_token", accessToken).Msg("using access token")
}
}
// try and load cert if argument is passed for client
var smdClient = client.NewSmdClient()
if cacertPath != "" {
log.Debug().Str("path", cacertPath).Msg("using provided certificate path")
err := client.LoadCertificateFromPath(smdClient, cacertPath)
if err != nil {
log.Warn().Err(err).Msg("could not load certificate")
}
}
// make one request be host positional argument (restricted to 1 for now)
var inputData []map[string]any
temp := append(handleArgs(args), processDataArgs(sendDataArgs)...)
for _, data := range temp {
if data != nil {
inputData = append(inputData, data)
}
}
if len(inputData) == 0 {
log.Error().Msg("must include data with standard input or -d/--data flag")
os.Exit(1)
}
// show the data that was just loaded as input
if verbose {
output, err := json.Marshal(inputData)
if err != nil {
log.Error().Err(err).Msg("failed to marshal input data")
}
fmt.Println(string(output))
}
for _, host := range args {
var (
body []byte
err error
)
smdClient.URI = host
for _, dataObject := range inputData {
// skip on to the next thing if it's does not exist
if dataObject == nil {
continue
}
// create and set headers for request
headers := client.HTTPHeader{}
headers.Authorization(accessToken)
headers.ContentType("application/json")
host, err = urlx.Sanitize(host)
if err != nil {
log.Warn().Err(err).Str("host", host).Msg("could not sanitize host")
}
// convert to JSON to send data
body, err = json.MarshalIndent(dataObject, "", " ")
if err != nil {
log.Error().Err(err).Msg("failed to marshal request data")
continue
}
err = smdClient.Add(body, headers)
if err != nil {
// try updating instead
if forceUpdate {
smdClient.Xname = dataObject["ID"].(string)
err = smdClient.Update(body, headers)
if err != nil {
log.Error().Err(err).Msgf("failed to forcibly update Redfish endpoint with ID %s", smdClient.Xname)
}
} else {
log.Error().Err(err).Msgf("failed to add Redfish endpoint with ID %s", smdClient.Xname)
}
}
}
}
},
}
func init() {
sendCmd.Flags().StringArrayVarP(&sendDataArgs, "data", "d", []string{}, "Set the data to send to specified host (prepend @ for files)")
sendCmd.Flags().StringVarP(&sendInputFormat, "format", "F", FORMAT_JSON, "Set the data input format (json|yaml)")
sendCmd.Flags().BoolVarP(&forceUpdate, "force-update", "f", false, "Set flag to force update data sent to SMD")
sendCmd.Flags().StringVar(&cacertPath, "cacert", "", "Set the path to CA cert file (defaults to system CAs when blank)")
rootCmd.AddCommand(sendCmd)
}
// processDataArgs takes a slice of strings that check for the @ symbol and loads
// the contents from the file specified in place (which replaces the path).
//
// NOTE: The purpose is to make the input arguments uniform for our request. This
// function is meant to handle data passed with the `-d/--data` flag and positional
// args from the CLI.
func processDataArgs(args []string) []map[string]any {
// JSON representation
type (
JSONObject = map[string]any
JSONArray = []JSONObject
)
// load data either from file or directly from args
var collection = make(JSONArray, len(args))
for i, arg := range args {
// if arg is empty string, then skip and continue
if len(arg) > 0 {
// determine if we're reading from file to load contents
if strings.HasPrefix(arg, "@") {
var (
path string = strings.TrimLeft(arg, "@")
contents []byte
data JSONArray
err error
)
contents, err = os.ReadFile(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to read file")
continue
}
// skip empty files
if len(contents) == 0 {
log.Warn().Str("path", path).Msg("file is empty")
continue
}
// convert/validate JSON input format
data, err = parseInput(contents)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to validate input from file")
}
// add loaded data to collection of all data
collection = append(collection, data...)
} else {
// input should be a valid JSON
var (
data JSONArray
input = []byte(arg)
err error
)
if !json.Valid(input) {
log.Error().Msgf("argument %d not a valid JSON", i)
continue
}
err = json.Unmarshal(input, &data)
if err != nil {
log.Error().Err(err).Msgf("failed to unmarshal input for argument %d", i)
}
return data
}
}
}
return collection
}
func handleArgs(args []string) []map[string]any {
// JSON representation
type (
JSONObject = map[string]any
JSONArray = []JSONObject
)
// no file to load, so we just use the joined args (since each one is a new line)
// and then stop
var (
collection JSONArray
data []byte
err error
)
if len(sendDataArgs) > 0 {
return nil
}
data, err = ReadStdin()
if err != nil {
log.Error().Err(err).Msg("failed to read from standard input")
return nil
}
if len(data) == 0 {
log.Warn().Msg("no data found from standard input")
return nil
}
fmt.Println(string(data))
collection, err = parseInput([]byte(data))
if err != nil {
log.Error().Err(err).Msg("failed to validate input from arg")
}
return collection
}
func parseInput(contents []byte) ([]map[string]any, error) {
var (
data []map[string]any
err error
)
// convert/validate JSON input format
switch sendInputFormat {
case FORMAT_JSON:
err = json.Unmarshal(contents, &data)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal input in JSON: %v", err)
}
case FORMAT_YAML:
err = yaml.Unmarshal(contents, &data)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal input in YAML: %v", err)
}
default:
return nil, fmt.Errorf("unrecognized format")
}
return data, nil
}
// ReadStdin reads all of standard input and returns the bytes. If an error
// occurs during scanning, it is returned.
func ReadStdin() ([]byte, error) {
var b []byte
input := bufio.NewScanner(os.Stdin)
for input.Scan() {
b = append(b, input.Bytes()...)
b = append(b, byte('\n'))
if len(b) == 0 {
break
}
}
if err := input.Err(); err != nil {
return b, fmt.Errorf("failed to read stdin: %w", err)
}
return b, nil
}