Skip to content

Commit 0f4003e

Browse files
authored
Merge pull request #20 from cyclimse/feat/fetch-cockpit-logs
feat(tools): fetch function logs via Cockpit
2 parents 689e71a + cfb2959 commit 0f4003e

File tree

24 files changed

+1118
-25
lines changed

24 files changed

+1118
-25
lines changed

.github/workflows/quality.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: golangci-lint
2626
uses: golangci/golangci-lint-action@v8
2727
with:
28-
version: v2.4
28+
version: v2.5
2929

3030
test:
3131
name: test

.golangci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ linters:
1717
- noinlineerr
1818
- ireturn
1919
- testpackage
20+
# It's a binary project
21+
- godoclint
2022

2123
settings:
2224
lll:
@@ -46,6 +48,9 @@ linters:
4648
- name: cognitive-complexity
4749
arguments:
4850
- 15 # Yes, I'm aware this is insane, but whatever, this is a pet project
51+
- name: max-public-structs
52+
arguments:
53+
- 10 # Up from 5
4954

5055
exclusions:
5156
rules:

.mockery.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
all: false
22

3-
dir: "{{.InterfaceDir}}/testing/mock{{.SrcPackageName}}"
3+
dir: "internal/testing/mock{{.SrcPackageName}}"
44

55
filename: mock{{.SrcPackageName}}.go
66
force-file-write: true
@@ -19,9 +19,12 @@ packages:
1919
github.com/cyclimse/mcp-scaleway-functions/internal/scaleway:
2020
config:
2121
all: true
22+
github.com/cyclimse/mcp-scaleway-functions/internal/scaleway/cockpit:
23+
config:
24+
all: true
2225
github.com/moby/moby/client:
2326
config:
24-
dir: "internal/scaleway/testing/mockdocker"
27+
dir: "internal/testing/mockdocker"
2528
pkgname: "mockdocker"
2629
interfaces:
2730
APIClient:

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Download the latest release from the [releases page](https://github.com/cyclimse
1414

1515
Run the MCP server:
1616

17-
```bash
17+
```console
1818
./mcp-scaleway-functions
1919
```
2020

@@ -97,7 +97,7 @@ Further configuration can be done via the
9797

9898
For instance, you can set a region to work in via the `SCW_DEFAULT_REGION` environment variable.
9999

100-
```bash
100+
```console
101101
SCW_DEFAULT_REGION=nl-ams ./mcp-scaleway-functions
102102
```
103103

@@ -114,6 +114,7 @@ SCW_DEFAULT_REGION=nl-ams ./mcp-scaleway-functions
114114
| `update_function` | Update the code or the configuration of an existing function. |
115115
| `delete_function` | Delete a function. |
116116
| `download_function` | Download the code of a function. This is useful to work on an existing function. |
117+
| `fetch_function_logs` | Fetch the logs of a function. |
117118
| `add_dependency` | Add a dependency to a local function. Useful for dependencies that rely on native code and therefore need Docker to be installed. |
118119

119120
## Debugging
@@ -123,3 +124,18 @@ You can enable debug logging by using the `--debug` flag when starting the MCP s
123124
To configure the log level, use the `--log-level` flag (default is `info`). Available log levels are: `debug`, `info`, `warn`, `error`.
124125

125126
Logs are stored in the `$XDG_STATE_HOME/mcp-scaleway-functions` directory (usually `~/.local/state/mcp-scaleway-functions`).
127+
128+
## Development
129+
130+
Running tests:
131+
132+
```console
133+
go tool gotestsum --format testdox
134+
```
135+
136+
Generating mocks:
137+
138+
```console
139+
go tool mockery
140+
```
141+

cmd/mcp-scaleway-functions/main.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ func (cmd *serveCmd) Run(cliCtx *cliContext) error {
6969
return fmt.Errorf("loading Scaleway profile: %w", err)
7070
}
7171

72+
projectID := p.DefaultProjectID
73+
if projectID == nil {
74+
logger.Warn("No default project ID set in Scaleway profile; some operations may fail.")
75+
}
76+
7277
scwClient, err := scw.NewClient(
7378
scw.WithProfile(p),
7479
scw.WithUserAgent(constants.UserAgent),
@@ -81,7 +86,7 @@ func (cmd *serveCmd) Run(cliCtx *cliContext) error {
8186
return fmt.Errorf("warning about permissions: %w", err)
8287
}
8388

84-
tools := scaleway.NewTools(scwClient)
89+
tools := scaleway.NewTools(scwClient, *projectID)
8590
server := mcp.NewServer(&mcp.Implementation{
8691
Name: constants.ProjectName,
8792
Title: "MCP Scaleway Serverless Functions",
@@ -278,7 +283,7 @@ func warnOnExcessivePermissions(
278283
logger.WarnContext(
279284
ctx,
280285
"It seems that your Scaleway API key has permissions that are too open. "+
281-
`Consider creating a new API key with only the "`+constants.RequiredPermissionSet+`" permission set. `+
286+
`Consider creating a new API key with only the "`+constants.RequiredPermissionSets+`" permission sets. `+
282287
"See: https://www.scaleway.com/en/docs/iam/reference-content/policy/ for more information.",
283288
)
284289

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/Microsoft/go-winio v0.6.2 // indirect
2020
github.com/bitfield/gotestdox v0.2.2 // indirect
2121
github.com/brunoga/deep v1.2.4 // indirect
22+
github.com/buger/jsonparser v1.1.1
2223
github.com/containerd/errdefs v1.0.0 // indirect
2324
github.com/containerd/errdefs/pkg v0.3.0 // indirect
2425
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CD
1010
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
1111
github.com/brunoga/deep v1.2.4 h1:Aj9E9oUbE+ccbyh35VC/NHlzzjfIVU69BXu2mt2LmL8=
1212
github.com/brunoga/deep v1.2.4/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI=
13+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
14+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
1315
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
1416
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
1517
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=

internal/constants/constants.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ const (
1414
// It's used as an extra safety measure to avoid deleting resources not created by this tool.
1515
TagCreatedByScalewayMCP = "created_by=" + ProjectName
1616

17-
// RequiredPermissionSet is the minimum permission set required for the Scaleway API key.
18-
RequiredPermissionSet = "FunctionsFullAccess"
17+
// RequiredPermissionSets is the minimum permission sets required for the Scaleway API key.
18+
RequiredPermissionSets = "FunctionsFullAccess, ObservabilityFullAccess"
1919

2020
PublicRuntimesRegistry = "rg.fr-par.scw.cloud/scwfunctionsruntimes-public"
2121
PythonPackageFolder = "package"

internal/scaleway/add_dependency_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import (
77
"testing"
88

99
"github.com/cyclimse/mcp-scaleway-functions/internal/constants"
10-
"github.com/cyclimse/mcp-scaleway-functions/internal/scaleway/testing/fixed"
11-
"github.com/cyclimse/mcp-scaleway-functions/internal/scaleway/testing/mockdocker"
12-
"github.com/cyclimse/mcp-scaleway-functions/internal/scaleway/testing/mockscaleway"
10+
"github.com/cyclimse/mcp-scaleway-functions/internal/testing/fixed"
11+
"github.com/cyclimse/mcp-scaleway-functions/internal/testing/mockdocker"
12+
"github.com/cyclimse/mcp-scaleway-functions/internal/testing/mockscaleway"
1313
"github.com/moby/moby/api/types/container"
1414
function "github.com/scaleway/scaleway-sdk-go/api/function/v1beta1"
1515
"github.com/stretchr/testify/assert"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package cockpit
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"slices"
8+
"sync"
9+
"time"
10+
11+
"github.com/cyclimse/mcp-scaleway-functions/internal/constants"
12+
cockpit "github.com/scaleway/scaleway-sdk-go/api/cockpit/v1"
13+
"github.com/scaleway/scaleway-sdk-go/scw"
14+
)
15+
16+
const (
17+
tokenName = constants.ProjectName
18+
19+
// queryTemplateServerless is the template used to query logs from Loki for Serverless Functions & Containers.
20+
// Because Serverless logs are sent as JSON, we only get the message field.
21+
queryTemplateServerless = `{resource_name="%s", resource_type="%s"} |~ "^{.*}$" | json | line_format "{{.message}}"`
22+
)
23+
24+
var (
25+
ErrNoScalewayLogsDataSource = errors.New(
26+
"no Scaleway logs data source found; please wait a few minutes and try again",
27+
)
28+
ErrTokenHasNoSecretKey = errors.New("token has no secret key")
29+
)
30+
31+
type Log struct {
32+
Timestamp time.Time `json:"timestamp"`
33+
Message string `json:"message"`
34+
}
35+
36+
type Client interface {
37+
ListFunctionLogs(
38+
ctx context.Context,
39+
resourceName string,
40+
start time.Time,
41+
end time.Time,
42+
) ([]Log, error)
43+
// note(cyclimse): makes me think we should have a buildID in Scaleway Functions build logs
44+
// to link logs to a specific build.
45+
ListFunctionBuildLogs(
46+
ctx context.Context,
47+
resourceName string,
48+
start time.Time,
49+
end time.Time,
50+
) ([]Log, error)
51+
}
52+
53+
type client struct {
54+
cockpitAPI *cockpit.RegionalAPI
55+
projectID string
56+
57+
initLokiClientOnce sync.Once
58+
lokiClient LokiClient
59+
}
60+
61+
func NewClient(scwClient *scw.Client, projectID string) Client {
62+
return &client{
63+
cockpitAPI: cockpit.NewRegionalAPI(scwClient),
64+
projectID: projectID,
65+
}
66+
}
67+
68+
// ListFunctionLogs implements Client.
69+
func (c *client) ListFunctionLogs(
70+
ctx context.Context,
71+
resourceName string,
72+
start time.Time,
73+
end time.Time,
74+
) ([]Log, error) {
75+
lokiClient, err := c.getLokiClient(ctx)
76+
if err != nil {
77+
return nil, fmt.Errorf("getting Loki client: %w", err)
78+
}
79+
80+
logs, err := lokiClient.Query(
81+
ctx,
82+
fmt.Sprintf(queryTemplateServerless, resourceName, "serverless_function"),
83+
start,
84+
end,
85+
)
86+
if err != nil {
87+
return nil, fmt.Errorf("querying logs: %w", err)
88+
}
89+
90+
return logs, nil
91+
}
92+
93+
// ListFunctionBuildLogs implements Client.
94+
func (*client) ListFunctionBuildLogs(
95+
_ context.Context,
96+
_ string,
97+
_ time.Time,
98+
_ time.Time,
99+
) ([]Log, error) {
100+
panic("unimplemented")
101+
}
102+
103+
//nolint:nonamedreturns // actually like it this way.
104+
func (c *client) getLokiClient(ctx context.Context) (lokiClient LokiClient, err error) {
105+
c.initLokiClientOnce.Do(func() {
106+
var (
107+
dataSource string
108+
token string
109+
)
110+
111+
dataSource, err = c.getScalewayLogsDataSourceURL(ctx)
112+
if err != nil {
113+
err = fmt.Errorf(
114+
"getting Scaleway logs data source for project %q: %w",
115+
c.projectID,
116+
err,
117+
)
118+
119+
return
120+
}
121+
122+
token, err = c.createToken(ctx)
123+
if err != nil {
124+
err = fmt.Errorf("creating token: %w", err)
125+
126+
return
127+
}
128+
129+
c.lokiClient = NewLokiClient(dataSource, token)
130+
})
131+
132+
return c.lokiClient, err
133+
}
134+
135+
func (c *client) createToken(ctx context.Context) (string, error) {
136+
resp, err := c.cockpitAPI.ListTokens(&cockpit.RegionalAPIListTokensRequest{
137+
TokenScopes: []cockpit.TokenScope{cockpit.TokenScopeReadOnlyLogs},
138+
ProjectID: c.projectID,
139+
}, scw.WithAllPages(), scw.WithContext(ctx))
140+
if err != nil {
141+
return "", fmt.Errorf("listing tokens: %w", err)
142+
}
143+
144+
i := slices.IndexFunc(resp.Tokens, func(t *cockpit.Token) bool {
145+
return t.Name == tokenName
146+
})
147+
148+
if i != -1 {
149+
// Unfortunately, the SecretKey is only shown once. So we're going to have to
150+
// delete and recreate it.
151+
err := c.cockpitAPI.DeleteToken(&cockpit.RegionalAPIDeleteTokenRequest{
152+
TokenID: resp.Tokens[i].ID,
153+
}, scw.WithContext(ctx))
154+
if err != nil {
155+
return "", fmt.Errorf("deleting existing token: %w", err)
156+
}
157+
}
158+
159+
token, err := c.cockpitAPI.CreateToken(&cockpit.RegionalAPICreateTokenRequest{
160+
Name: tokenName,
161+
TokenScopes: []cockpit.TokenScope{cockpit.TokenScopeReadOnlyLogs},
162+
}, scw.WithContext(ctx))
163+
if err != nil {
164+
return "", fmt.Errorf("creating token: %w", err)
165+
}
166+
167+
if token.SecretKey == nil {
168+
return "", ErrTokenHasNoSecretKey
169+
}
170+
171+
return *token.SecretKey, nil
172+
}
173+
174+
func (c *client) getScalewayLogsDataSourceURL(ctx context.Context) (string, error) {
175+
resp, err := c.cockpitAPI.ListDataSources(&cockpit.RegionalAPIListDataSourcesRequest{
176+
Origin: cockpit.DataSourceOriginScaleway,
177+
Types: []cockpit.DataSourceType{cockpit.DataSourceTypeLogs},
178+
ProjectID: c.projectID,
179+
// There should be at most one such data source.
180+
Page: scw.Int32Ptr(1),
181+
PageSize: scw.Uint32Ptr(1),
182+
}, scw.WithContext(ctx))
183+
if err != nil {
184+
return "", fmt.Errorf("listing data sources: %w", err)
185+
}
186+
187+
if len(resp.DataSources) == 0 {
188+
return "", ErrNoScalewayLogsDataSource
189+
}
190+
191+
dataSource := resp.DataSources[0]
192+
193+
return dataSource.URL, nil
194+
}

0 commit comments

Comments
 (0)