From ac36201f075b12574617aff167340f636fc86f25 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 29 Aug 2025 19:27:06 -0600 Subject: [PATCH] feat: initial upload cmd implementation --- cmd/download.go | 1 + cmd/upload.go | 214 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/cmd/download.go b/cmd/download.go index e36ee86..10c860f 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -30,6 +30,7 @@ var downloadCmd = cobra.Command{ curl $MAKESHIFT_HOST/download/test?plugins=smd,jinja2&profile=test # download directory and extract it's contents automatically + # then, remove the downloaded archive makeshift download -xr `, Short: "Download and modify files with plugins", diff --git a/cmd/upload.go b/cmd/upload.go index 39aa49f..12b58b1 100644 --- a/cmd/upload.go +++ b/cmd/upload.go @@ -1,24 +1,234 @@ package cmd -import "github.com/spf13/cobra" +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "git.towk2.me/towk/makeshift/internal/format" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var ( + inputFormat format.DataFormat = format.JSON + dataArgs []string +) var uploadCmd = &cobra.Command{ Use: "upload", - Run: func(cmd *cobra.Command, args []string) { + Example: ` + # upload a single file + makeshift upload -d @compute-base.yaml -t file + # upload a single file with contents without specify type + makeshift upload -d '{"name": "John Smith", "email": "john.smith@example.com"}' + + # upload a directory + makeshift upload -d @setup/ -t directory + + # upload an archive (extracted and saved on server) + makeshift upload -d @setup.tar.gz -t archive +`, + Run: func(cmd *cobra.Command, args []string) { + // make one request be host positional argument (restricted to 1 for now) + var inputData []map[string]any + temp := append(handleArgs(args), processDataArgs(dataArgs)...) + for _, data := range temp { + if data != nil { + inputData = append(inputData, data) + } + } }, } var uploadProfileCmd = &cobra.Command{ Use: "profile", + Example: ` + # upload a new profile + makeshift upload profile -d @compute.json + + # upload a new profile with a specific name (used for lookups) + makeshift upload profile -d @kubernetes.json -n k8s +`, + Args: cobra.ExactArgs(1), + Short: "Upload a new profile", + Run: func(cmd *cobra.Command, args []string) { + + // make one request be host positional argument (restricted to 1 for now) + var inputData []map[string]any + temp := append(handleArgs(args), processDataArgs(dataArgs)...) + for _, data := range temp { + if data != nil { + inputData = append(inputData, data) + } + } + + }, } var uploadPluginCmd = &cobra.Command{ Use: "plugin", + Example: ` + # upload a new plugin + makeshift upload plugin -d @slurm.so + + # upload a new plugin with a specific name (used for lookups) + makeshift upload plugin -d @cobbler.so -n merge +`, + Args: cobra.ExactArgs(1), + Short: "Upload a new plugin", + Run: func(cmd *cobra.Command, args []string) { + // make one request be host positional argument (restricted to 1 for now) + var inputData []map[string]any + temp := append(handleArgs(args), processDataArgs(dataArgs)...) + for _, data := range temp { + if data != nil { + inputData = append(inputData, data) + } + } + }, } func init() { + uploadProfileCmd.Flags().VarP(&inputFormat, "format", "F", "Set the input format for profile") uploadCmd.AddCommand(uploadProfileCmd, uploadPluginCmd) rootCmd.AddCommand(uploadCmd) } + +// 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 input data + data, err = parseInput(contents, format.DataFormatFromFileExt(path, inputFormat)) + 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(dataArgs) > 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), inputFormat) + if err != nil { + log.Error().Err(err).Msg("failed to validate input from arg") + } + return collection +} + +func parseInput(contents []byte, dataFormat format.DataFormat) ([]map[string]any, error) { + var ( + data []map[string]any + err error + ) + + // convert/validate JSON input format + err = format.Unmarshal(contents, &data, dataFormat) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data: %v", err) + } + 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 +}