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 @@ import ( "errors" "fmt" "strings" + + "github.com/apache/cloudstack-cloudmonkey/config" ) var apiCommand *Command @@ -46,11 +48,23 @@ func init() { 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 @@ func init() { 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 pollAsyncJob(r *Request, jobID string) (map[string]interface{}, error) { 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 @@ import ( ) // 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 @@ type API struct { Noun string Args []*APIArg RequiredArgs []string + FakeArgs []string Related []string Async bool Description string @@ -145,18 +149,32 @@ func (c *Config) UpdateCache(response map[string]interface{}) interface{} { } // 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 @@ func (c *Config) UpdateCache(response map[string]interface{}) interface{} { Noun: noun, Args: apiArgs, RequiredArgs: requiredArgs, + FakeArgs: fakeArgs, Async: isAsync, Description: description, ResponseKeys: responseKeys, @@ -193,3 +212,13 @@ func (c *Config) UpdateCache(response map[string]interface{}) interface{} { } 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" +}