magellan/cmd/send.go

283 lines
7.7 KiB
Go

package cmd
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
urlx "github.com/davidallendj/magellan/internal/urlx"
"github.com/davidallendj/magellan/pkg/auth"
"github.com/davidallendj/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(accessTokenPath)
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
}