Skip to content

Commit 9f8ce06

Browse files
authored
feature: allow file upload for getuploadparams* apis (#177)
Signed-off-by: Abhishek Kumar <[email protected]>
1 parent 902d732 commit 9f8ce06

File tree

4 files changed

+323
-7
lines changed

4 files changed

+323
-7
lines changed

cmd/api.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
"errors"
2222
"fmt"
2323
"strings"
24+
25+
"github.com/apache/cloudstack-cloudmonkey/config"
2426
)
2527

2628
var apiCommand *Command
@@ -46,11 +48,23 @@ func init() {
4648
apiArgs = r.Args[2:]
4749
}
4850

49-
for _, arg := range r.Args {
51+
var uploadFiles []string
52+
53+
for _, arg := range apiArgs {
5054
if arg == "-h" {
5155
r.Args[0] = apiName
5256
return helpCommand.Handle(r)
5357
}
58+
if strings.HasPrefix(arg, config.FilePathArg) && config.IsFileUploadAPI(apiName) {
59+
var err error
60+
uploadFiles, err = ValidateAndGetFileList(arg[len(config.FilePathArg):])
61+
if err != nil {
62+
return err
63+
}
64+
if len(uploadFiles) == 0 {
65+
return errors.New("no valid files to upload")
66+
}
67+
}
5468
}
5569

5670
api := r.Config.GetCache()[apiName]
@@ -111,8 +125,12 @@ func init() {
111125

112126
if len(response) > 0 {
113127
printResult(r.Config.Core.Output, response, filterKeys, excludeKeys)
128+
if len(uploadFiles) > 0 {
129+
UploadFiles(r, api.Name, response, uploadFiles)
130+
} else {
131+
PromptAndUploadFilesIfNeeded(r, api.Name, response)
132+
}
114133
}
115-
116134
return nil
117135
},
118136
}

cmd/fileupload.go

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package cmd
19+
20+
import (
21+
"fmt"
22+
"io"
23+
"mime/multipart"
24+
"net/http"
25+
"os"
26+
"path/filepath"
27+
"reflect"
28+
"strings"
29+
"time"
30+
31+
"github.com/apache/cloudstack-cloudmonkey/config"
32+
"github.com/briandowns/spinner"
33+
)
34+
35+
const (
36+
uploadingMessage = "Uploading files, please wait..."
37+
progressCharCount = 24
38+
)
39+
40+
// ValidateAndGetFileList parses a comma-separated string of file paths, trims them,
41+
// checks for existence, and returns a slice of valid file paths or an error if any are missing.
42+
func ValidateAndGetFileList(filePaths string) ([]string, error) {
43+
filePathsList := strings.FieldsFunc(filePaths, func(r rune) bool { return r == ',' })
44+
45+
var missingFiles []string
46+
var validFiles []string
47+
for _, filePath := range filePathsList {
48+
filePath = strings.TrimSpace(filePath)
49+
if filePath == "" {
50+
continue
51+
}
52+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
53+
missingFiles = append(missingFiles, filePath)
54+
} else {
55+
validFiles = append(validFiles, filePath)
56+
}
57+
}
58+
if len(missingFiles) > 0 {
59+
return nil, fmt.Errorf("file(s) do not exist or are not accessible: %s", strings.Join(missingFiles, ", "))
60+
}
61+
return validFiles, nil
62+
}
63+
64+
// PromptAndUploadFilesIfNeeded prompts the user to provide file paths for upload and the API is getUploadParamsFor*
65+
func PromptAndUploadFilesIfNeeded(r *Request, api string, response map[string]interface{}) {
66+
if !r.Config.HasShell {
67+
return
68+
}
69+
apiName := strings.ToLower(api)
70+
if !config.IsFileUploadAPI(apiName) {
71+
return
72+
}
73+
fmt.Print("Enter path of the file(s) to upload (comma-separated), leave empty to skip: ")
74+
var filePaths string
75+
fmt.Scanln(&filePaths)
76+
if filePaths == "" {
77+
return
78+
}
79+
validFiles, err := ValidateAndGetFileList(filePaths)
80+
if err != nil {
81+
fmt.Println(err)
82+
return
83+
}
84+
if len(validFiles) == 0 {
85+
fmt.Println("No valid files to upload.")
86+
return
87+
}
88+
UploadFiles(r, api, response, validFiles)
89+
}
90+
91+
// UploadFiles uploads files to a remote server using parameters from the API response.
92+
// Shows progress for each file and reports any failures.
93+
func UploadFiles(r *Request, api string, response map[string]interface{}, validFiles []string) {
94+
paramsRaw, ok := response["getuploadparams"]
95+
if !ok || reflect.TypeOf(paramsRaw).Kind() != reflect.Map {
96+
fmt.Println("Invalid response format for getuploadparams.")
97+
return
98+
}
99+
params := paramsRaw.(map[string]interface{})
100+
requiredKeys := []string{"postURL", "metadata", "signature", "expires"}
101+
for _, key := range requiredKeys {
102+
if _, ok := params[key]; !ok {
103+
fmt.Printf("Missing required key '%s' in getuploadparams response.\n", key)
104+
return
105+
}
106+
}
107+
postURL, _ := params["postURL"].(string)
108+
signature, _ := params["signature"].(string)
109+
expires, _ := params["expires"].(string)
110+
metadata, _ := params["metadata"].(string)
111+
112+
fmt.Println("Uploading files for", api, ":", validFiles)
113+
spinner := r.Config.StartSpinner(uploadingMessage)
114+
errored := 0
115+
for i, filePath := range validFiles {
116+
spinner.Suffix = fmt.Sprintf(" uploading %d/%d %s...", i+1, len(validFiles), filepath.Base(filePath))
117+
if err := uploadFile(i, len(validFiles), postURL, filePath, signature, expires, metadata, spinner); err != nil {
118+
spinner.Stop()
119+
fmt.Println("Error uploading", filePath, ":", err)
120+
errored++
121+
spinner.Suffix = fmt.Sprintf(" %s", uploadingMessage)
122+
spinner.Start()
123+
}
124+
}
125+
r.Config.StopSpinner(spinner)
126+
if errored > 0 {
127+
fmt.Printf("🙈 %d out of %d files failed to upload.\n", errored, len(validFiles))
128+
} else {
129+
fmt.Println("All files uploaded successfully.")
130+
}
131+
}
132+
133+
// progressReader streams file data and updates progress as bytes are read.
134+
type progressBody struct {
135+
f *os.File
136+
read int64
137+
total int64
138+
update func(int)
139+
}
140+
141+
func (pb *progressBody) Read(p []byte) (int, error) {
142+
n, err := pb.f.Read(p)
143+
if n > 0 {
144+
pb.read += int64(n)
145+
pct := int(float64(pb.read) * 100 / float64(pb.total))
146+
pb.update(pct)
147+
}
148+
return n, err
149+
}
150+
func (pb *progressBody) Close() error { return pb.f.Close() }
151+
152+
func barArrow(pct int) string {
153+
width := progressCharCount
154+
if pct < 0 {
155+
pct = 0
156+
}
157+
if pct > 100 {
158+
pct = 100
159+
}
160+
pos := (pct * width) / 100
161+
// 100%: full bar, no head
162+
if pos >= width {
163+
return fmt.Sprintf("[%s]",
164+
strings.Repeat("=", width))
165+
}
166+
left := strings.Repeat("=", pos) + ">"
167+
right := strings.Repeat(" ", width-pos-1)
168+
169+
return fmt.Sprintf("[%s%s]", left, right)
170+
}
171+
172+
// uploadFile streams a large file to the server with progress updates.
173+
func uploadFile(index, count int, postURL, filePath, signature, expires, metadata string, spn *spinner.Spinner) error {
174+
fileName := filepath.Base(filePath)
175+
in, err := os.Open(filePath)
176+
if err != nil {
177+
return err
178+
}
179+
defer in.Close()
180+
_, err = in.Stat()
181+
if err != nil {
182+
return err
183+
}
184+
tmp, err := os.CreateTemp("", "multipart-body-*.tmp")
185+
if err != nil {
186+
return err
187+
}
188+
defer func() {
189+
tmp.Close()
190+
os.Remove(tmp.Name())
191+
}()
192+
mw := multipart.NewWriter(tmp)
193+
part, err := mw.CreateFormFile("file", filepath.Base(filePath))
194+
if err != nil {
195+
return err
196+
}
197+
if _, err := io.Copy(part, in); err != nil {
198+
return err
199+
}
200+
if err := mw.Close(); err != nil {
201+
return err
202+
}
203+
size, err := tmp.Seek(0, io.SeekEnd)
204+
if err != nil {
205+
return err
206+
}
207+
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
208+
return err
209+
}
210+
req, err := http.NewRequest("POST", postURL, nil)
211+
if err != nil {
212+
return err
213+
}
214+
req.Header.Set("Content-Type", mw.FormDataContentType())
215+
req.Header.Set("x-signature", signature)
216+
req.Header.Set("x-expires", expires)
217+
req.Header.Set("x-metadata", metadata)
218+
req.ContentLength = size
219+
pb := &progressBody{
220+
f: tmp,
221+
total: size,
222+
update: func(pct int) {
223+
spn.Suffix = fmt.Sprintf(" [%d/%d] %s\t%s %d%%", index+1, count, fileName, barArrow(pct), pct)
224+
},
225+
}
226+
req.Body = pb
227+
req.GetBody = func() (io.ReadCloser, error) {
228+
f, err := os.Open(tmp.Name())
229+
if err != nil {
230+
return nil, err
231+
}
232+
return f, nil
233+
}
234+
client := &http.Client{
235+
Timeout: 24 * time.Hour,
236+
Transport: &http.Transport{
237+
ExpectContinueTimeout: 0,
238+
},
239+
}
240+
resp, err := client.Do(req)
241+
if err != nil {
242+
return err
243+
}
244+
defer resp.Body.Close()
245+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
246+
b, _ := io.ReadAll(resp.Body)
247+
return fmt.Errorf("[%d/%d] %s\tupload failed: %s", index+1, count, fileName, string(b))
248+
}
249+
250+
spn.Stop()
251+
fmt.Printf("[%d/%d] %s\t%s ✅\n", index+1, count, fileName, barArrow(100))
252+
spn.Suffix = fmt.Sprintf(" %s", uploadingMessage)
253+
spn.Start()
254+
return nil
255+
}

cmd/network.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,21 @@ func pollAsyncJob(r *Request, jobID string) (map[string]interface{}, error) {
278278
func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[string]interface{}, error) {
279279
params := make(url.Values)
280280
params.Add("command", api)
281+
apiData := r.Config.GetCache()[api]
281282
for _, arg := range args {
283+
if apiData != nil {
284+
skip := false
285+
for _, fakeArg := range apiData.FakeArgs {
286+
if strings.HasPrefix(arg, fakeArg) {
287+
skip = true
288+
break
289+
}
290+
}
291+
if skip {
292+
continue
293+
}
294+
295+
}
282296
parts := strings.SplitN(arg, "=", 2)
283297
if len(parts) == 2 {
284298
key := parts[0]

0 commit comments

Comments
 (0)