Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟩🟩🟩 STDOUT️ 🟩🟩🟩️
Display the logs of a container from the last 2 hours

USAGE:
scw container container logs <container-id ...> [arg=value ...]

ARGS:
container-id ID of the container which logs are to be displayed
[region=fr-par] Region to target. If none is passed will use default region from the config (fr-par | nl-ams | pl-waw)

FLAGS:
-h, --help help for logs
--list-sub-commands List all subcommands

GLOBAL FLAGS:
-c, --config string The path to the config file
-D, --debug Enable debug mode
-o, --output string Output format: json or human, see 'scw help output' for more info (default "human")
-p, --profile string The config profile to use
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ AVAILABLE COMMANDS:
redeploy Redeploy a container
update Update the container associated with the specified ID.

UTILITY COMMANDS:
logs Show container logs

FLAGS:
-h, --help help for container
--list-sub-commands List all subcommands
Expand Down
21 changes: 21 additions & 0 deletions docs/commands/container.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This API allows you to manage your Serverless Containers.
- [Delete the container associated with the specified ID.](#delete-the-container-associated-with-the-specified-id.)
- [Get the container associated with the specified ID.](#get-the-container-associated-with-the-specified-id.)
- [List all containers the caller can access (read permission).](#list-all-containers-the-caller-can-access-(read-permission).)
- [Show container logs](#show-container-logs)
- [Redeploy a container](#redeploy-a-container)
- [Update the container associated with the specified ID.](#update-the-container-associated-with-the-specified-id.)
- [Deploy a container](#deploy-a-container)
Expand Down Expand Up @@ -159,6 +160,26 @@ scw container container list [arg=value ...]



### Show container logs

Display the logs of a container from the last 2 hours

**Usage:**

```
scw container container logs <container-id ...> [arg=value ...]
```


**Args:**

| Name | | Description |
|------|---|-------------|
| container-id | | ID of the container which logs are to be displayed |
| region | Default: `fr-par`<br />One of: `fr-par`, `nl-ams`, `pl-waw` | Region to target. If none is passed will use default region from the config |



### Redeploy a container

Performs a rollout of the container by creating new instances with the latest image version and terminating the old instances.
Expand Down
7 changes: 7 additions & 0 deletions internal/namespaces/container/v1/custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func GetCommands() *core.Commands {
cmds.MustFind("container", "namespace", "update").Override(containerNamespaceUpdateBuilder)
cmds.MustFind("container", "namespace", "delete").Override(containerNamespaceDeleteBuilder)

// Logs and Metrics
cmds.Merge(core.NewCommands(
containerLogs(),
// containerMetrics(), // TODO: coming soon
))

// Deploy
if cmdDeploy := containerDeployCommand(); cmdDeploy != nil {
cmds.Add(cmdDeploy)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"testing"

"github.com/scaleway/scaleway-cli/v2/core"
container "github.com/scaleway/scaleway-cli/v2/internal/namespaces/container/v1"
"github.com/scaleway/scaleway-cli/v2/internal/namespaces/container/v1"
"github.com/scaleway/scaleway-cli/v2/internal/namespaces/registry/v1"
"github.com/scaleway/scaleway-cli/v2/internal/testhelpers"
containerSDK "github.com/scaleway/scaleway-sdk-go/api/container/v1"
Expand Down
231 changes: 231 additions & 0 deletions internal/namespaces/container/v1/custom_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package container

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"strconv"
"time"

"github.com/scaleway/scaleway-cli/v2/core"
"github.com/scaleway/scaleway-sdk-go/api/cockpit/v1"
"github.com/scaleway/scaleway-sdk-go/scw"
)

type containerLogsRequest struct {
ContainerID string
Region scw.Region
}

func containerLogs() *core.Command {
return &core.Command{
Short: `Show container logs`,
Long: `Display the logs of a container from the last 2 hours`,
Namespace: "container",
Resource: "container",
Verb: "logs",
Groups: []string{"utility"},
ArgsType: reflect.TypeOf(containerLogsRequest{}),
ArgSpecs: core.ArgSpecs{
{
Name: "container-id",
Short: "ID of the container which logs are to be displayed",
Positional: true,
},
core.RegionArgSpec(
scw.RegionFrPar,
scw.RegionNlAms,
scw.RegionPlWaw,
),
},
Run: containerLogsRun,
}
}

func containerLogsRun(ctx context.Context, argsI any) (any, error) {
args := argsI.(*containerLogsRequest)
scwClient := core.ExtractClient(ctx)
httpClient := core.ExtractHTTPClient(ctx)
cockpitAPI := cockpit.NewRegionalAPI(scwClient)

// Find at least one data source for logs
ds, err := cockpitAPI.ListDataSources(&cockpit.RegionalAPIListDataSourcesRequest{
Region: args.Region,
Origin: cockpit.DataSourceOriginScaleway,
Types: []cockpit.DataSourceType{cockpit.DataSourceTypeLogs},
}, scw.WithAllPages(), scw.WithContext(ctx))
if err != nil {
return nil, err
}

if ds.TotalCount == 0 {
return nil, errors.New("could not find any cockpit datasource to fetch the logs from")
}

// Setup request
req, err := buildLokiQuery(ds.DataSources[0].URL, args.ContainerID)
if err != nil {
return nil, err
}

// Setup token
token, isNew, err := getOrCreateToken(
ctx,
cockpitAPI,
args.Region,
cockpit.TokenScopeReadOnlyLogs,
)
if err != nil {
return nil, err
}
if isNew {
defer deleteToken(ctx, cockpitAPI, args.Region, token)
}

if token != nil && token.SecretKey != nil {
req.Header.Set("X-Token", *token.SecretKey)
}

// Query datasource
var logsResponse []LogEntry

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Error making request: %v\n", err)
}
defer resp.Body.Close()

logsResponse, err = readLokiResponseBody(resp.Body)
if err != nil {
return nil, err
}

return logsResponse, nil
}

// curl -s -H "X-Token: $COCKPIT_TOKEN" --data-urlencode 'query={resource_type="serverless_container", resource_id="'$CONTAINER_ID'"}' \
// --data-urlencode "start=2026-01-26T16:00:00Z" --data-urlencode "end=2026-01-26T16:30:00Z" \
// $SCALEWAY_LOGS_DATASOURCE_URL/loki/api/v1/query_range |
// jq -r '.data.result[0].values[] | .[1]' | jq -r '.resource_instance + " " + .message'
func buildLokiQuery(datasourceURL, containerID string) (*http.Request, error) {
query := fmt.Sprintf(
`{resource_type="serverless_container", resource_id="%s"}`,
containerID,
)
start := time.Now().Add(-2 * time.Hour).Format(time.RFC3339) // TODO: customize timespan ?
end := time.Now().Format(time.RFC3339)

reqURL := datasourceURL + "/loki/api/v1/query_range"

formData := url.Values{}
formData.Set("query", query)
formData.Set("start", start)
formData.Set("end", end)

req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("Error creating request: %v\n", err)
}

// req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.URL.RawQuery = formData.Encode()

return req, nil
}

type LokiResponse struct {
Data struct {
Result []struct {
Values [][]string `json:"values"`
} `json:"result"`
} `json:"data"`
}

type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
ResourceInstance string `json:"resource_instance"`
Message string `json:"message"`
}

func readLokiResponseBody(requestBody io.ReadCloser) ([]LogEntry, error) {
body, err := io.ReadAll(requestBody)
if err != nil {
return nil, fmt.Errorf("Error reading response: %v\n", err)
}

var lokiResp LokiResponse

if err := json.Unmarshal(body, &lokiResp); err != nil {
return nil, fmt.Errorf("Error parsing JSON: %v\n", err)
}

if len(lokiResp.Data.Result) == 0 {
// || len(lokiResp.Data.Result[0].Values) == 0 { TODO: rework no-data case
return nil, errors.New("No results found\n")
}

var response []LogEntry

for _, value := range lokiResp.Data.Result[0].Values {
if len(value) < 2 {
continue
}

var entry LogEntry

if err := json.Unmarshal([]byte(value[1]), &entry); err != nil {
return nil, fmt.Errorf("Error parsing log entry: %v\n", err)
}

if nanos, err := strconv.Atoi(value[0]); err == nil {
entry.Timestamp = time.Unix(0, int64(nanos))
} else {
return nil, err
}

response = append(response, entry)
}

return response, nil
}

func deleteToken(
ctx context.Context,
api *cockpit.RegionalAPI,
region scw.Region,
token *cockpit.Token,
) error {
return api.DeleteToken(&cockpit.RegionalAPIDeleteTokenRequest{
Region: region,
TokenID: token.ID,
}, scw.WithContext(ctx))
}

func getOrCreateToken(
ctx context.Context,
cockpitAPI *cockpit.RegionalAPI,
region scw.Region,
scope cockpit.TokenScope,
) (*cockpit.Token, bool, error) {
token, err := cockpitAPI.CreateToken(&cockpit.RegionalAPICreateTokenRequest{
Region: region,
// ProjectID: "",
Name: "cli-generated-for-container-logs",
TokenScopes: []cockpit.TokenScope{
scope,
// cockpit.TokenScopeFullAccessMetricsRules,
// cockpit.TokenScopeFullAccessLogsRules,

},
}, scw.WithContext(ctx))
if err != nil {
return nil, false, err
}

return token, true, nil
}
33 changes: 33 additions & 0 deletions internal/namespaces/container/v1/custom_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package container_test

import (
"fmt"
"testing"

"github.com/scaleway/scaleway-cli/v2/core"
"github.com/scaleway/scaleway-cli/v2/internal/namespaces/container/v1"
)

func Test_ContainerLogs(t *testing.T) {
image := "hello-world:latest"

t.Run("Simple", core.Test(&core.TestConfig{
Commands: container.GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
createNamespace("Namespace"),
core.ExecStoreBeforeCmd("Container", fmt.Sprintf(
"scw container container create namespace-id={{ .Namespace.ID }} name=%s image=%s -w",
core.GetRandomName("test-logs"),
image,
)),
),
Cmd: "scw container container logs {{ .Container.ID }}",
Check: core.TestCheckCombine(
core.TestCheckExitCode(0),
core.TestCheckGolden(),
),
AfterFunc: core.AfterFuncCombine(
deleteNamespace("Namespace"),
),
}))
}
Loading
Loading