Skip to content

Commit ff44031

Browse files
committed
feat: add 'device log local' command for local log retrieval
1 parent 4a2592f commit ff44031

3 files changed

Lines changed: 483 additions & 0 deletions

File tree

internal/cmd/device/log.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func NewCmdLog(f *factory.Factory) *cobra.Command {
1313
Long: "View and download device logs from the InCloud platform.",
1414
}
1515

16+
cmd.AddCommand(NewCmdLogLocal(f))
1617
cmd.AddCommand(NewCmdLogSyslog(f))
1718
cmd.AddCommand(NewCmdLogDiagnostic(f))
1819
cmd.AddCommand(NewCmdLogMqtt(f))

internal/cmd/device/log_local.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package device
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"path/filepath"
12+
"strconv"
13+
"strings"
14+
"time"
15+
16+
"github.com/spf13/cobra"
17+
18+
"github.com/inhandnet/incloud-cli/internal/factory"
19+
)
20+
21+
type LogLocalOptions struct {
22+
Lines int
23+
Path string
24+
All bool
25+
Timeout int
26+
File string
27+
}
28+
29+
func NewCmdLogLocal(f *factory.Factory) *cobra.Command {
30+
opts := &LogLocalOptions{}
31+
32+
cmd := &cobra.Command{
33+
Use: "local <device-id>",
34+
Short: "Read log files directly from the device (requires device online)",
35+
Long: `Read log files directly from the device filesystem in real time.
36+
37+
The device must be online. This command sends a direct method to the device to read
38+
its local log files and prints the content to stdout.`,
39+
Example: ` # Read the last 100 lines (default)
40+
incloud device log local 507f1f77bcf86cd799439011
41+
42+
# Read the last 50 lines
43+
incloud device log local 507f1f77bcf86cd799439011 --lines 50
44+
45+
# Read a specific log file
46+
incloud device log local 507f1f77bcf86cd799439011 --path /var/log/messages
47+
48+
# Get full log content
49+
incloud device log local 507f1f77bcf86cd799439011 --all
50+
51+
# Save to a file for repeated access
52+
incloud device log local 507f1f77bcf86cd799439011 --file /tmp/device.log
53+
54+
# With a longer timeout for slow connections
55+
incloud device log local 507f1f77bcf86cd799439011 --timeout 60`,
56+
Args: cobra.ExactArgs(1),
57+
RunE: func(cmd *cobra.Command, args []string) error {
58+
deviceID := args[0]
59+
60+
client, err := f.APIClient()
61+
if err != nil {
62+
return err
63+
}
64+
65+
q := url.Values{}
66+
if opts.All {
67+
q.Set("all", "true")
68+
} else if opts.Lines > 0 {
69+
q.Set("lines", strconv.Itoa(opts.Lines))
70+
}
71+
if opts.Path != "" {
72+
q.Set("localPath", opts.Path)
73+
}
74+
q.Set("timeout", strconv.Itoa(opts.Timeout))
75+
76+
body, err := client.Get("/api/v1/devices/"+deviceID+"/logs/local", q)
77+
if err != nil {
78+
return err
79+
}
80+
81+
content, err := extractLocalLogs(body)
82+
if err != nil {
83+
return err
84+
}
85+
86+
if opts.File != "" {
87+
if err := os.WriteFile(opts.File, content, 0o600); err != nil {
88+
return fmt.Errorf("writing file: %w", err)
89+
}
90+
absPath, err := filepath.Abs(opts.File)
91+
if err != nil {
92+
absPath = opts.File
93+
}
94+
fmt.Fprintf(f.IO.ErrOut, "Saved to %s (%d bytes)\n", absPath, len(content))
95+
return nil
96+
}
97+
98+
_, err = f.IO.Out.Write(content)
99+
return err
100+
},
101+
}
102+
103+
cmd.Flags().IntVar(&opts.Lines, "lines", 100, "Number of log lines to read")
104+
cmd.Flags().StringVar(&opts.Path, "path", "", "Log file path on the device (e.g. /var/log/messages)")
105+
cmd.Flags().BoolVar(&opts.All, "all", false, "Get full log content")
106+
cmd.Flags().IntVar(&opts.Timeout, "timeout", 30, "Timeout in seconds for device response")
107+
cmd.Flags().StringVar(&opts.File, "file", "", "Save log content to a file instead of stdout")
108+
cmd.MarkFlagsMutuallyExclusive("all", "lines")
109+
110+
return cmd
111+
}
112+
113+
// localLogResponse matches the DeviceLog structure from the API.
114+
type localLogResponse struct {
115+
Status string `json:"status"`
116+
Error string `json:"error"`
117+
Result json.RawMessage `json:"result"`
118+
}
119+
120+
type localLogResult struct {
121+
Logs []string `json:"logs"`
122+
URL string `json:"url"`
123+
}
124+
125+
func extractLocalLogs(body []byte) ([]byte, error) {
126+
var resp localLogResponse
127+
if err := json.Unmarshal(body, &resp); err != nil {
128+
return nil, fmt.Errorf("parsing response: %w", err)
129+
}
130+
131+
if resp.Status != "succeeded" {
132+
errMsg := resp.Error
133+
if errMsg == "" {
134+
errMsg = "unknown error"
135+
}
136+
return nil, fmt.Errorf("device returned status %q: %s", resp.Status, errMsg)
137+
}
138+
139+
if resp.Result == nil {
140+
return nil, nil
141+
}
142+
143+
var result localLogResult
144+
if err := json.Unmarshal(resp.Result, &result); err != nil {
145+
return append(resp.Result, '\n'), nil
146+
}
147+
148+
// Case 1: logs array returned (server already downloaded from S3).
149+
if len(result.Logs) > 0 {
150+
content := strings.Join(result.Logs, "\n")
151+
if !strings.HasSuffix(content, "\n") {
152+
content += "\n"
153+
}
154+
return []byte(content), nil
155+
}
156+
157+
// Case 2: presigned URL returned (all=true). Download content.
158+
if result.URL != "" {
159+
return downloadContent(result.URL)
160+
}
161+
162+
// Case 3: no logs/url — return raw result as fallback.
163+
return append(resp.Result, '\n'), nil
164+
}
165+
166+
var downloadClient = &http.Client{Timeout: 5 * time.Minute}
167+
168+
func downloadContent(rawURL string) ([]byte, error) {
169+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, http.NoBody)
170+
if err != nil {
171+
return nil, fmt.Errorf("downloading log content: %w", err)
172+
}
173+
174+
resp, err := downloadClient.Do(req)
175+
if err != nil {
176+
return nil, fmt.Errorf("downloading log content: %w", err)
177+
}
178+
defer resp.Body.Close()
179+
180+
if resp.StatusCode != http.StatusOK {
181+
return nil, fmt.Errorf("downloading log content: HTTP %d", resp.StatusCode)
182+
}
183+
184+
return io.ReadAll(resp.Body)
185+
}

0 commit comments

Comments
 (0)