| // 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 |
| } |