Skip to content

Commit 4072546

Browse files
committed
feat(container): add a command to display container logs
1 parent da45726 commit 4072546

5 files changed

Lines changed: 2415 additions & 0 deletions

File tree

internal/namespaces/container/v1beta1/custom.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ func GetCommands() *core.Commands {
3131
cmds.MustFind("container", "namespace", "update").Override(containerNamespaceUpdateBuilder)
3232
cmds.MustFind("container", "namespace", "delete").Override(containerNamespaceDeleteBuilder)
3333

34+
// Logs and Metrics
35+
36+
cmds.Merge(core.NewCommands(
37+
containerLogs(),
38+
))
39+
40+
// Deploy
41+
3442
if cmdDeploy := containerDeployCommand(); cmdDeploy != nil {
3543
cmds.Add(cmdDeploy)
3644
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
package container
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"reflect"
12+
"strconv"
13+
"time"
14+
15+
"github.com/scaleway/scaleway-cli/v2/core"
16+
"github.com/scaleway/scaleway-sdk-go/api/cockpit/v1"
17+
"github.com/scaleway/scaleway-sdk-go/scw"
18+
)
19+
20+
type containerLogsRequest struct {
21+
ContainerID string
22+
Region scw.Region
23+
}
24+
25+
func containerLogs() *core.Command {
26+
return &core.Command{
27+
Short: `Show container logs`,
28+
Long: ``, // TODO
29+
Namespace: "container",
30+
Resource: "container",
31+
Verb: "logs",
32+
// Groups: []string{"workflow"}, // TODO
33+
ArgsType: reflect.TypeOf(containerLogsRequest{}),
34+
ArgSpecs: core.ArgSpecs{
35+
{
36+
Name: "container-id",
37+
Short: "ID of the container which logs are to be displayed",
38+
Positional: true,
39+
},
40+
core.RegionArgSpec(
41+
scw.RegionFrPar,
42+
scw.RegionNlAms,
43+
scw.RegionPlWaw,
44+
scw.Region(core.AllLocalities), // TODO: test region=all
45+
),
46+
},
47+
Run: containerLogsRun,
48+
}
49+
}
50+
51+
func containerLogsRun(ctx context.Context, argsI any) (any, error) {
52+
args := argsI.(*containerLogsRequest)
53+
scwClient := core.ExtractClient(ctx)
54+
httpClient := core.ExtractHTTPClient(ctx)
55+
cockpitAPI := cockpit.NewRegionalAPI(scwClient)
56+
57+
// Find at least one data source for logs
58+
ds, err := cockpitAPI.ListDataSources(&cockpit.RegionalAPIListDataSourcesRequest{
59+
Region: args.Region,
60+
Origin: cockpit.DataSourceOriginScaleway,
61+
Types: []cockpit.DataSourceType{cockpit.DataSourceTypeLogs},
62+
}, scw.WithAllPages(), scw.WithContext(ctx))
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
if ds.TotalCount == 0 {
68+
return nil, errors.New("could not find any cockpit datasource to fetch the logs from")
69+
}
70+
71+
// Setup request
72+
req, err := buildLokiQuery(ds.DataSources[0].URL, args.ContainerID)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
// Setup token
78+
token, isNew, err := getOrCreateToken(
79+
ctx,
80+
cockpitAPI,
81+
args.Region,
82+
cockpit.TokenScopeReadOnlyLogs,
83+
)
84+
if err != nil {
85+
return nil, err
86+
}
87+
if isNew {
88+
defer deleteToken(ctx, cockpitAPI, args.Region, token)
89+
}
90+
91+
if token != nil && token.SecretKey != nil {
92+
req.Header.Set("X-Token", *token.SecretKey)
93+
}
94+
95+
// Query datasource
96+
var logsResponse []LogEntry
97+
98+
resp, err := httpClient.Do(req)
99+
if err != nil {
100+
return nil, fmt.Errorf("Error making request: %v\n", err)
101+
}
102+
defer resp.Body.Close()
103+
104+
logsResponse, err = readLokiResponseBody(resp.Body)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
return logsResponse, nil
110+
}
111+
112+
// curl -s -H "X-Token: $COCKPIT_TOKEN" --data-urlencode 'query={resource_type="serverless_container", resource_id="'$CONTAINER_ID'"}' \
113+
// --data-urlencode "start=2026-01-26T16:00:00Z" --data-urlencode "end=2026-01-26T16:30:00Z" \
114+
// $SCALEWAY_LOGS_DATASOURCE_URL/loki/api/v1/query_range |
115+
// jq -r '.data.result[0].values[] | .[1]' | jq -r '.resource_instance + " " + .message'
116+
func buildLokiQuery(datasourceURL, containerID string) (*http.Request, error) {
117+
query := fmt.Sprintf(
118+
`{resource_type="serverless_container", resource_id="%s"}`,
119+
containerID,
120+
)
121+
start := time.Now().Add(-2 * time.Hour).Format(time.RFC3339) //"2026-01-26T16:00:00Z"
122+
end := time.Now().Format(time.RFC3339) //"2026-01-26T16:30:00Z"
123+
124+
reqURL := fmt.Sprintf("%s/loki/api/v1/query_range", datasourceURL)
125+
126+
formData := url.Values{}
127+
formData.Set("query", query)
128+
formData.Set("start", start)
129+
formData.Set("end", end)
130+
131+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
132+
if err != nil {
133+
return nil, fmt.Errorf("Error creating request: %v\n", err)
134+
}
135+
136+
// req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
137+
req.URL.RawQuery = formData.Encode()
138+
139+
return req, nil
140+
}
141+
142+
type LokiResponse struct {
143+
Data struct {
144+
Result []struct {
145+
Values [][]string `json:"values"`
146+
} `json:"result"`
147+
} `json:"data"`
148+
}
149+
150+
type LogEntry struct {
151+
Timestamp time.Time `json:"timestamp"`
152+
ResourceInstance string `json:"resource_instance"`
153+
Message string `json:"message"`
154+
}
155+
156+
func readLokiResponseBody(requestBody io.ReadCloser) ([]LogEntry, error) {
157+
body, err := io.ReadAll(requestBody)
158+
if err != nil {
159+
return nil, fmt.Errorf("Error reading response: %v\n", err)
160+
}
161+
162+
var lokiResp LokiResponse
163+
164+
if err := json.Unmarshal(body, &lokiResp); err != nil {
165+
return nil, fmt.Errorf("Error parsing JSON: %v\n", err)
166+
}
167+
168+
if len(lokiResp.Data.Result) == 0 { //|| len(lokiResp.Data.Result[0].Values) == 0 {
169+
return nil, fmt.Errorf("No results found\n")
170+
}
171+
172+
var response []LogEntry
173+
174+
for _, value := range lokiResp.Data.Result[0].Values {
175+
if len(value) < 2 {
176+
continue
177+
}
178+
179+
var entry LogEntry
180+
181+
if err := json.Unmarshal([]byte(value[1]), &entry); err != nil {
182+
return nil, fmt.Errorf("Error parsing log entry: %v\n", err)
183+
}
184+
185+
if nanos, err := strconv.Atoi(value[0]); err == nil {
186+
entry.Timestamp = time.Unix(0, int64(nanos))
187+
} else {
188+
return nil, err
189+
}
190+
191+
response = append(response, entry)
192+
}
193+
194+
return response, nil
195+
}
196+
197+
func deleteToken(
198+
ctx context.Context,
199+
api *cockpit.RegionalAPI,
200+
region scw.Region,
201+
token *cockpit.Token,
202+
) error {
203+
return api.DeleteToken(&cockpit.RegionalAPIDeleteTokenRequest{
204+
Region: region,
205+
TokenID: token.ID,
206+
}, scw.WithContext(ctx))
207+
}
208+
209+
func getOrCreateToken(
210+
ctx context.Context,
211+
cockpitAPI *cockpit.RegionalAPI,
212+
region scw.Region, scope cockpit.TokenScope,
213+
) (*cockpit.Token, bool, error) {
214+
// var tokenToUse *cockpit.Token
215+
216+
readOnlyTokens, err := cockpitAPI.ListTokens(&cockpit.RegionalAPIListTokensRequest{
217+
Region: region,
218+
// ProjectID: "",
219+
TokenScopes: []cockpit.TokenScope{scope},
220+
}, scw.WithAllPages(), scw.WithContext(ctx))
221+
if err != nil {
222+
return nil, false, err
223+
}
224+
225+
for _, roToken := range readOnlyTokens.Tokens {
226+
token, err := cockpitAPI.GetToken(&cockpit.RegionalAPIGetTokenRequest{
227+
Region: region,
228+
TokenID: roToken.ID,
229+
}, scw.WithContext(ctx))
230+
if err != nil {
231+
return nil, false, err
232+
}
233+
234+
if token.SecretKey != nil {
235+
return token, false, nil
236+
}
237+
}
238+
239+
// fullAccessTokens, err := cockpitAPI.ListTokens(&cockpit.RegionalAPIListTokensRequest{
240+
// Region: region,
241+
// // ProjectID: "",
242+
// TokenScopes: []cockpit.TokenScope{cockpit.TokenScopeFullAccessLogsRules},
243+
// }, scw.WithAllPages(), scw.WithContext(ctx))
244+
// if err != nil {
245+
// return nil, err
246+
// }
247+
//
248+
// if len(fullAccessTokens.Tokens) > 0 {
249+
// tokenToUse = fullAccessTokens.Tokens[0]
250+
// } else {
251+
token, err := cockpitAPI.CreateToken(&cockpit.RegionalAPICreateTokenRequest{
252+
Region: region,
253+
// ProjectID: "",
254+
Name: "cli-generated-for-container-logs",
255+
TokenScopes: []cockpit.TokenScope{
256+
scope,
257+
// cockpit.TokenScopeFullAccessMetricsRules,
258+
// cockpit.TokenScopeFullAccessLogsRules,
259+
260+
},
261+
}, scw.WithContext(ctx))
262+
if err != nil {
263+
return nil, false, err
264+
}
265+
266+
return token, true, nil
267+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package container_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/scaleway/scaleway-cli/v2/core"
8+
container "github.com/scaleway/scaleway-cli/v2/internal/namespaces/container/v1beta1"
9+
)
10+
11+
func Test_ContainerLogs(t *testing.T) {
12+
image := "hello-world:latest"
13+
14+
t.Run("Simple", core.Test(&core.TestConfig{
15+
Commands: container.GetCommands(),
16+
BeforeFunc: core.BeforeFuncCombine(
17+
createNamespace("Namespace"),
18+
core.ExecStoreBeforeCmd("Container", fmt.Sprintf(
19+
"scw container container create namespace-id={{ .Namespace.ID }} name=%s registry-image=%s -w",
20+
core.GetRandomName("test-logs"),
21+
image,
22+
)),
23+
),
24+
Cmd: "scw container container logs {{ .Container.ID }}",
25+
Check: core.TestCheckCombine(
26+
core.TestCheckExitCode(0),
27+
core.TestCheckGolden(),
28+
),
29+
AfterFunc: core.AfterFuncCombine(
30+
deleteNamespace("Namespace"),
31+
),
32+
}))
33+
}

0 commit comments

Comments
 (0)