Skip to content

Commit e8eb668

Browse files
authored
Add rwx logs command (#261)
1 parent 3d687a9 commit e8eb668

File tree

10 files changed

+738
-0
lines changed

10 files changed

+738
-0
lines changed

cmd/rwx/logs.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
8+
"github.com/rwx-cloud/cli/internal/cli"
9+
"github.com/rwx-cloud/cli/internal/errors"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
LogsOutput string
16+
17+
logsCmd = &cobra.Command{
18+
PreRunE: func(cmd *cobra.Command, args []string) error {
19+
return requireAccessToken()
20+
},
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
if len(args) == 0 {
23+
return errors.New("task ID is required")
24+
}
25+
26+
taskId := args[0]
27+
28+
outputDir := LogsOutput
29+
if outputDir == "" {
30+
var err error
31+
outputDir, err = getDefaultLogsDir()
32+
if err != nil {
33+
return errors.Wrap(err, "unable to determine default logs directory")
34+
}
35+
}
36+
37+
if err := os.MkdirAll(outputDir, 0755); err != nil {
38+
return errors.Wrapf(err, "unable to create output directory %s", outputDir)
39+
}
40+
41+
absOutputDir, err := filepath.Abs(outputDir)
42+
if err != nil {
43+
return errors.Wrapf(err, "unable to resolve absolute path for %s", outputDir)
44+
}
45+
46+
err = service.DownloadLogs(cli.DownloadLogsConfig{
47+
TaskID: taskId,
48+
OutputDir: absOutputDir,
49+
})
50+
if err != nil {
51+
return err
52+
}
53+
54+
return nil
55+
},
56+
Short: "Download logs for a task",
57+
Use: "logs <taskId> [flags]",
58+
}
59+
)
60+
61+
func init() {
62+
logsCmd.Flags().StringVarP(&LogsOutput, "output", "o", "", "output directory for the downloaded log file (defaults to Downloads folder)")
63+
}
64+
65+
func getDefaultLogsDir() (string, error) {
66+
if runtime.GOOS == "linux" {
67+
if xdgDownload := os.Getenv("XDG_DOWNLOAD_DIR"); xdgDownload != "" {
68+
return xdgDownload, nil
69+
}
70+
}
71+
72+
homeDir, err := os.UserHomeDir()
73+
if err != nil {
74+
return "", errors.Wrap(err, "unable to determine user home directory")
75+
}
76+
77+
return filepath.Join(homeDir, "Downloads"), nil
78+
}

cmd/rwx/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func init() {
118118
rootCmd.AddCommand(mcpCmd)
119119
rootCmd.AddCommand(imageCmd)
120120
rootCmd.AddCommand(pushCmd)
121+
rootCmd.AddCommand(logsCmd)
121122

122123
cobra.OnInitialize(func() {
123124
if AccessToken == "$RWX_ACCESS_TOKEN" {

internal/api/client.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"net/url"
1010
"strings"
11+
"time"
1112

1213
"github.com/rwx-cloud/cli/cmd/rwx/config"
1314
"github.com/rwx-cloud/cli/internal/accesstoken"
@@ -575,6 +576,131 @@ func (c Client) TaskStatus(cfg TaskStatusConfig) (TaskStatusResult, error) {
575576
return result, nil
576577
}
577578

579+
func (c Client) GetLogDownloadRequest(taskId string) (LogDownloadRequestResult, error) {
580+
endpoint := fmt.Sprintf("/mint/api/log_downloads/%s", url.PathEscape(taskId))
581+
result := LogDownloadRequestResult{}
582+
583+
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
584+
if err != nil {
585+
return result, errors.Wrap(err, "unable to create new HTTP request")
586+
}
587+
req.Header.Set("Content-Type", "application/json")
588+
req.Header.Set("Accept", "application/json")
589+
590+
resp, err := c.RoundTrip(req)
591+
if err != nil {
592+
return result, errors.Wrap(err, "HTTP request failed")
593+
}
594+
defer resp.Body.Close()
595+
596+
if err = decodeResponseJSON(resp, &result); err != nil {
597+
return result, err
598+
}
599+
600+
return result, nil
601+
}
602+
603+
func (c Client) DownloadLogs(request LogDownloadRequestResult, maxRetryDurationSeconds ...int) ([]byte, error) {
604+
maxRetryDuration := 30 * time.Second
605+
if len(maxRetryDurationSeconds) > 0 && maxRetryDurationSeconds[0] > 0 {
606+
maxRetryDuration = time.Duration(maxRetryDurationSeconds[0]) * time.Second
607+
}
608+
const initialBackoff = 1 * time.Second
609+
610+
startTime := time.Now()
611+
backoff := initialBackoff
612+
attempt := 0
613+
614+
var lastErr error
615+
616+
for {
617+
attempt++
618+
619+
// need to recreate for each attempt since body readers are consumed
620+
var req *http.Request
621+
var err error
622+
623+
if request.Contents != nil {
624+
// POST approach, for zip files (group tasks)
625+
formData := url.Values{}
626+
formData.Set("token", request.Token)
627+
formData.Set("filename", request.Filename)
628+
formData.Set("contents", *request.Contents)
629+
encodedBody := formData.Encode()
630+
631+
req, err = http.NewRequest(http.MethodPost, request.URL, strings.NewReader(encodedBody))
632+
if err != nil {
633+
return nil, errors.Wrap(err, "unable to create new HTTP request")
634+
}
635+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
636+
req.Header.Set("Accept", "application/octet-stream")
637+
} else {
638+
// GET approach, for single log files
639+
req, err = http.NewRequest(http.MethodGet, request.URL, nil)
640+
if err != nil {
641+
return nil, errors.Wrap(err, "unable to create new HTTP request")
642+
}
643+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", request.Token))
644+
req.Header.Set("Accept", "application/octet-stream")
645+
}
646+
647+
// Use http.DefaultClient directly since the logs will come from a task server URL rather than Cloud
648+
resp, err := http.DefaultClient.Do(req)
649+
if err != nil {
650+
lastErr = errors.Wrap(err, "HTTP request failed")
651+
652+
if time.Since(startTime) >= maxRetryDuration {
653+
return nil, errors.Wrapf(lastErr, "failed after %d attempts over %v", attempt, time.Since(startTime).Round(time.Second))
654+
}
655+
656+
time.Sleep(backoff)
657+
backoff *= 2
658+
if backoff > 5*time.Second {
659+
backoff = 5 * time.Second
660+
}
661+
continue
662+
}
663+
664+
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
665+
defer resp.Body.Close()
666+
logBytes, err := io.ReadAll(resp.Body)
667+
if err != nil {
668+
return nil, errors.Wrap(err, "unable to read response body")
669+
}
670+
return logBytes, nil
671+
}
672+
673+
bodyBytes, _ := io.ReadAll(resp.Body)
674+
resp.Body.Close()
675+
676+
// Don't retry on 4xx errors
677+
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
678+
errMsg := extractErrorMessage(bytes.NewReader(bodyBytes))
679+
if errMsg == "" {
680+
errMsg = fmt.Sprintf("Unable to download logs - %s", resp.Status)
681+
}
682+
return nil, errors.New(errMsg)
683+
}
684+
685+
// Retry on 5xx errors - task server may be waking up
686+
errMsg := extractErrorMessage(bytes.NewReader(bodyBytes))
687+
if errMsg == "" {
688+
errMsg = fmt.Sprintf("Unable to download logs - %s", resp.Status)
689+
}
690+
lastErr = errors.New(errMsg)
691+
692+
if time.Since(startTime) >= maxRetryDuration {
693+
return nil, errors.Wrapf(lastErr, "failed after %d attempts over %v", attempt, time.Since(startTime).Round(time.Second))
694+
}
695+
696+
time.Sleep(backoff)
697+
backoff *= 2
698+
if backoff > 5*time.Second {
699+
backoff = 5 * time.Second
700+
}
701+
}
702+
}
703+
578704
func decodeResponseJSON(resp *http.Response, result any) error {
579705
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
580706
if result == nil {

0 commit comments

Comments
 (0)