feature: allow file upload for getuploadparams* apis (#177)

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
diff --git a/cmd/api.go b/cmd/api.go
index 872a328..f7b9149 100644
--- a/cmd/api.go
+++ b/cmd/api.go
@@ -21,6 +21,8 @@
 	"errors"
 	"fmt"
 	"strings"
+
+	"github.com/apache/cloudstack-cloudmonkey/config"
 )
 
 var apiCommand *Command
@@ -46,11 +48,23 @@
 				apiArgs = r.Args[2:]
 			}
 
-			for _, arg := range r.Args {
+			var uploadFiles []string
+
+			for _, arg := range apiArgs {
 				if arg == "-h" {
 					r.Args[0] = apiName
 					return helpCommand.Handle(r)
 				}
+				if strings.HasPrefix(arg, config.FilePathArg) && config.IsFileUploadAPI(apiName) {
+					var err error
+					uploadFiles, err = ValidateAndGetFileList(arg[len(config.FilePathArg):])
+					if err != nil {
+						return err
+					}
+					if len(uploadFiles) == 0 {
+						return errors.New("no valid files to upload")
+					}
+				}
 			}
 
 			api := r.Config.GetCache()[apiName]
@@ -111,8 +125,12 @@
 
 			if len(response) > 0 {
 				printResult(r.Config.Core.Output, response, filterKeys, excludeKeys)
+				if len(uploadFiles) > 0 {
+					UploadFiles(r, api.Name, response, uploadFiles)
+				} else {
+					PromptAndUploadFilesIfNeeded(r, api.Name, response)
+				}
 			}
-
 			return nil
 		},
 	}
diff --git a/cmd/fileupload.go b/cmd/fileupload.go
new file mode 100644
index 0000000..30bf986
--- /dev/null
+++ b/cmd/fileupload.go
@@ -0,0 +1,255 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package cmd
+
+import (
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"time"
+
+	"github.com/apache/cloudstack-cloudmonkey/config"
+	"github.com/briandowns/spinner"
+)
+
+const (
+	uploadingMessage  = "Uploading files, please wait..."
+	progressCharCount = 24
+)
+
+// ValidateAndGetFileList parses a comma-separated string of file paths, trims them,
+// checks for existence, and returns a slice of valid file paths or an error if any are missing.
+func ValidateAndGetFileList(filePaths string) ([]string, error) {
+	filePathsList := strings.FieldsFunc(filePaths, func(r rune) bool { return r == ',' })
+
+	var missingFiles []string
+	var validFiles []string
+	for _, filePath := range filePathsList {
+		filePath = strings.TrimSpace(filePath)
+		if filePath == "" {
+			continue
+		}
+		if _, err := os.Stat(filePath); os.IsNotExist(err) {
+			missingFiles = append(missingFiles, filePath)
+		} else {
+			validFiles = append(validFiles, filePath)
+		}
+	}
+	if len(missingFiles) > 0 {
+		return nil, fmt.Errorf("file(s) do not exist or are not accessible: %s", strings.Join(missingFiles, ", "))
+	}
+	return validFiles, nil
+}
+
+// PromptAndUploadFilesIfNeeded prompts the user to provide file paths for upload and the API is getUploadParamsFor*
+func PromptAndUploadFilesIfNeeded(r *Request, api string, response map[string]interface{}) {
+	if !r.Config.HasShell {
+		return
+	}
+	apiName := strings.ToLower(api)
+	if !config.IsFileUploadAPI(apiName) {
+		return
+	}
+	fmt.Print("Enter path of the file(s) to upload (comma-separated), leave empty to skip: ")
+	var filePaths string
+	fmt.Scanln(&filePaths)
+	if filePaths == "" {
+		return
+	}
+	validFiles, err := ValidateAndGetFileList(filePaths)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	if len(validFiles) == 0 {
+		fmt.Println("No valid files to upload.")
+		return
+	}
+	UploadFiles(r, api, response, validFiles)
+}
+
+// UploadFiles uploads files to a remote server using parameters from the API response.
+// Shows progress for each file and reports any failures.
+func UploadFiles(r *Request, api string, response map[string]interface{}, validFiles []string) {
+	paramsRaw, ok := response["getuploadparams"]
+	if !ok || reflect.TypeOf(paramsRaw).Kind() != reflect.Map {
+		fmt.Println("Invalid response format for getuploadparams.")
+		return
+	}
+	params := paramsRaw.(map[string]interface{})
+	requiredKeys := []string{"postURL", "metadata", "signature", "expires"}
+	for _, key := range requiredKeys {
+		if _, ok := params[key]; !ok {
+			fmt.Printf("Missing required key '%s' in getuploadparams response.\n", key)
+			return
+		}
+	}
+	postURL, _ := params["postURL"].(string)
+	signature, _ := params["signature"].(string)
+	expires, _ := params["expires"].(string)
+	metadata, _ := params["metadata"].(string)
+
+	fmt.Println("Uploading files for", api, ":", validFiles)
+	spinner := r.Config.StartSpinner(uploadingMessage)
+	errored := 0
+	for i, filePath := range validFiles {
+		spinner.Suffix = fmt.Sprintf(" uploading %d/%d %s...", i+1, len(validFiles), filepath.Base(filePath))
+		if err := uploadFile(i, len(validFiles), postURL, filePath, signature, expires, metadata, spinner); err != nil {
+			spinner.Stop()
+			fmt.Println("Error uploading", filePath, ":", err)
+			errored++
+			spinner.Suffix = fmt.Sprintf(" %s", uploadingMessage)
+			spinner.Start()
+		}
+	}
+	r.Config.StopSpinner(spinner)
+	if errored > 0 {
+		fmt.Printf("🙈 %d out of %d files failed to upload.\n", errored, len(validFiles))
+	} else {
+		fmt.Println("All files uploaded successfully.")
+	}
+}
+
+// progressReader streams file data and updates progress as bytes are read.
+type progressBody struct {
+	f      *os.File
+	read   int64
+	total  int64
+	update func(int)
+}
+
+func (pb *progressBody) Read(p []byte) (int, error) {
+	n, err := pb.f.Read(p)
+	if n > 0 {
+		pb.read += int64(n)
+		pct := int(float64(pb.read) * 100 / float64(pb.total))
+		pb.update(pct)
+	}
+	return n, err
+}
+func (pb *progressBody) Close() error { return pb.f.Close() }
+
+func barArrow(pct int) string {
+	width := progressCharCount
+	if pct < 0 {
+		pct = 0
+	}
+	if pct > 100 {
+		pct = 100
+	}
+	pos := (pct * width) / 100
+	// 100%: full bar, no head
+	if pos >= width {
+		return fmt.Sprintf("[%s]",
+			strings.Repeat("=", width))
+	}
+	left := strings.Repeat("=", pos) + ">"
+	right := strings.Repeat(" ", width-pos-1)
+
+	return fmt.Sprintf("[%s%s]", left, right)
+}
+
+// uploadFile streams a large file to the server with progress updates.
+func uploadFile(index, count int, postURL, filePath, signature, expires, metadata string, spn *spinner.Spinner) error {
+	fileName := filepath.Base(filePath)
+	in, err := os.Open(filePath)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	_, err = in.Stat()
+	if err != nil {
+		return err
+	}
+	tmp, err := os.CreateTemp("", "multipart-body-*.tmp")
+	if err != nil {
+		return err
+	}
+	defer func() {
+		tmp.Close()
+		os.Remove(tmp.Name())
+	}()
+	mw := multipart.NewWriter(tmp)
+	part, err := mw.CreateFormFile("file", filepath.Base(filePath))
+	if err != nil {
+		return err
+	}
+	if _, err := io.Copy(part, in); err != nil {
+		return err
+	}
+	if err := mw.Close(); err != nil {
+		return err
+	}
+	size, err := tmp.Seek(0, io.SeekEnd)
+	if err != nil {
+		return err
+	}
+	if _, err := tmp.Seek(0, io.SeekStart); err != nil {
+		return err
+	}
+	req, err := http.NewRequest("POST", postURL, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", mw.FormDataContentType())
+	req.Header.Set("x-signature", signature)
+	req.Header.Set("x-expires", expires)
+	req.Header.Set("x-metadata", metadata)
+	req.ContentLength = size
+	pb := &progressBody{
+		f:     tmp,
+		total: size,
+		update: func(pct int) {
+			spn.Suffix = fmt.Sprintf(" [%d/%d] %s\t%s %d%%", index+1, count, fileName, barArrow(pct), pct)
+		},
+	}
+	req.Body = pb
+	req.GetBody = func() (io.ReadCloser, error) {
+		f, err := os.Open(tmp.Name())
+		if err != nil {
+			return nil, err
+		}
+		return f, nil
+	}
+	client := &http.Client{
+		Timeout: 24 * time.Hour,
+		Transport: &http.Transport{
+			ExpectContinueTimeout: 0,
+		},
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		b, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("[%d/%d] %s\tupload failed: %s", index+1, count, fileName, string(b))
+	}
+
+	spn.Stop()
+	fmt.Printf("[%d/%d] %s\t%s ✅\n", index+1, count, fileName, barArrow(100))
+	spn.Suffix = fmt.Sprintf(" %s", uploadingMessage)
+	spn.Start()
+	return nil
+}
diff --git a/cmd/network.go b/cmd/network.go
index 69cf6ce..c6dfe4b 100644
--- a/cmd/network.go
+++ b/cmd/network.go
@@ -278,7 +278,21 @@
 func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[string]interface{}, error) {
 	params := make(url.Values)
 	params.Add("command", api)
+	apiData := r.Config.GetCache()[api]
 	for _, arg := range args {
+		if apiData != nil {
+			skip := false
+			for _, fakeArg := range apiData.FakeArgs {
+				if strings.HasPrefix(arg, fakeArg) {
+					skip = true
+					break
+				}
+			}
+			if skip {
+				continue
+			}
+
+		}
 		parts := strings.SplitN(arg, "=", 2)
 		if len(parts) == 2 {
 			key := parts[0]
diff --git a/config/cache.go b/config/cache.go
index 13596dd..7359427 100644
--- a/config/cache.go
+++ b/config/cache.go
@@ -28,7 +28,10 @@
 )
 
 // FAKE is used for fake CLI only options like filter=
-const FAKE = "fake"
+const (
+	FAKE        = "fake"
+	FilePathArg = "filepath="
+)
 
 // APIArg are the args passable to an API
 type APIArg struct {
@@ -47,6 +50,7 @@
 	Noun         string
 	Args         []*APIArg
 	RequiredArgs []string
+	FakeArgs     []string
 	Related      []string
 	Async        bool
 	Description  string
@@ -145,18 +149,32 @@
 		}
 
 		// Add filter arg
-		apiArgs = append(apiArgs, &APIArg{
+		fakeArg := &APIArg{
 			Name:        "filter=",
 			Type:        FAKE,
 			Description: "cloudmonkey specific response key filtering",
-		})
+		}
+		apiArgs = append(apiArgs, fakeArg)
+		fakeArgs := []string{fakeArg.Name}
 
 		// Add exclude arg
-		apiArgs = append(apiArgs, &APIArg{
+		fakeArg = &APIArg{
 			Name:        "exclude=",
 			Type:        FAKE,
 			Description: "cloudmonkey specific response key to exlude when filtering",
-		})
+		}
+		apiArgs = append(apiArgs, fakeArg)
+		fakeArgs = append(fakeArgs, fakeArg.Name)
+
+		if IsFileUploadAPI(apiName) {
+			fakeArg = &APIArg{
+				Name:        FilePathArg,
+				Type:        FAKE,
+				Description: "cloudmonkey specific key to upload files for supporting APIs. Comma-separated list of file paths can be provided",
+			}
+			apiArgs = append(apiArgs, fakeArg)
+			fakeArgs = append(fakeArgs, fakeArg.Name)
+		}
 
 		sort.Slice(apiArgs, func(i, j int) bool {
 			return apiArgs[i].Name < apiArgs[j].Name
@@ -186,6 +204,7 @@
 			Noun:         noun,
 			Args:         apiArgs,
 			RequiredArgs: requiredArgs,
+			FakeArgs:     fakeArgs,
 			Async:        isAsync,
 			Description:  description,
 			ResponseKeys: responseKeys,
@@ -193,3 +212,13 @@
 	}
 	return count
 }
+
+// IsFileUploadAPI checks if the provided API name corresponds to a file upload-related API.
+// It returns true if the API name matches one of the following (case-insensitive):
+// "getUploadParamsForIso", "getUploadParamsForVolume", or "getUploadParamsForTemplate".
+func IsFileUploadAPI(api string) bool {
+	apiName := strings.ToLower(api)
+	return apiName == "getuploadparamsforiso" ||
+		apiName == "getuploadparamsforvolume" ||
+		apiName == "getuploadparamsfortemplate"
+}