Skip to content

Commit e6eef1b

Browse files
authored
Merge pull request #21 from cyclimse/feat/skip-code-uploads
feat: skip code uploads when code hasn't changed
2 parents 0f4003e + b1960e8 commit e6eef1b

File tree

13 files changed

+347
-48
lines changed

13 files changed

+347
-48
lines changed

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ linters:
4747
- 15
4848
- name: cognitive-complexity
4949
arguments:
50-
- 15 # Yes, I'm aware this is insane, but whatever, this is a pet project
50+
- 16 # Yes, I'm aware this is insane, but whatever, this is a pet project
5151
- name: max-public-structs
5252
arguments:
5353
- 10 # Up from 5

README.md

Lines changed: 4 additions & 5 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-
```console
17+
```bash
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-
```console
100+
```bash
101101
SCW_DEFAULT_REGION=nl-ams ./mcp-scaleway-functions
102102
```
103103

@@ -129,13 +129,12 @@ Logs are stored in the `$XDG_STATE_HOME/mcp-scaleway-functions` directory (usual
129129

130130
Running tests:
131131

132-
```console
132+
```bash
133133
go tool gotestsum --format testdox
134134
```
135135

136136
Generating mocks:
137137

138-
```console
138+
```bash
139139
go tool mockery
140140
```
141-

internal/constants/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ 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+
// TagCodeArchiveDigestPrefix is the tag key used to store the code archive digest.
18+
// It's used to avoid redeploying the same code.
19+
TagCodeArchiveDigestPrefix = "code_archive_digest="
20+
1721
// RequiredPermissionSets is the minimum permission sets required for the Scaleway API key.
1822
RequiredPermissionSets = "FunctionsFullAccess, ObservabilityFullAccess"
1923

internal/middlewares/middlewares.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,12 @@ func NewLogging() mcp.Middleware {
4747
logger = logger.With(slog.Duration("duration", duration))
4848
logger = logger.With(slogAttrFromResult(result)...)
4949

50-
if err != nil {
50+
switch {
51+
case err != nil:
5152
logger.Error("Request failed", slog.String("error", err.Error()))
52-
} else {
53+
case isErrorResult(result):
54+
logger.Error("Request errored")
55+
default:
5356
logger.Info("Request succeeded")
5457
}
5558

@@ -73,11 +76,28 @@ func slogAttrFromRequest(method string, req mcp.Request) []any {
7376
return attrs
7477
}
7578

79+
func isErrorResult(result mcp.Result) bool {
80+
if callResult, ok := result.(*mcp.CallToolResult); ok {
81+
return callResult.IsError
82+
}
83+
84+
return false
85+
}
86+
7687
func slogAttrFromResult(result mcp.Result) []any {
7788
attrs := []any{}
7889

7990
if callResult, ok := result.(*mcp.CallToolResult); ok {
8091
attrs = append(attrs, slogJSON("tool_result", callResult.StructuredContent))
92+
93+
if callResult.IsError {
94+
attrs = append(attrs, slog.Bool("tool_error", true))
95+
96+
if len(callResult.Content) > 0 {
97+
firstLines := callResult.Content[0]
98+
attrs = append(attrs, slogJSON("tool_error_content", firstLines))
99+
}
100+
}
81101
}
82102

83103
return attrs

internal/scaleway/cockpit/entry.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,11 @@ func (e *Entry) UnmarshalJSON(data []byte) error {
2121
_, err := jsonparser.ArrayEach(
2222
data,
2323
func(value []byte, t jsonparser.ValueType, _ int, _ error) {
24-
// assert that both items in array are of type string
24+
// Assert that both items in array are of type string
2525
switch i {
2626
case 0: // timestamp
2727
if t != jsonparser.String {
28-
parseError = fmt.Errorf(
29-
"%w: expected string timestamp",
30-
jsonparser.MalformedStringError,
31-
)
28+
parseError = jsonparser.MalformedStringError
3229

3330
return
3431
}
@@ -41,7 +38,7 @@ func (e *Entry) UnmarshalJSON(data []byte) error {
4138
}
4239

4340
e.Timestamp = time.Unix(0, ts)
44-
case 1: // value
41+
case 1: // log line
4542
if t != jsonparser.String {
4643
parseError = jsonparser.MalformedStringError
4744

@@ -57,8 +54,7 @@ func (e *Entry) UnmarshalJSON(data []byte) error {
5754

5855
e.Line = v
5956
default:
60-
// Ignore extra values
61-
return
57+
return // no-op
6258
}
6359

6460
i++
@@ -69,5 +65,9 @@ func (e *Entry) UnmarshalJSON(data []byte) error {
6965
return parseError
7066
}
7167

72-
return fmt.Errorf("parsing log entry array: %w", err)
68+
if err != nil {
69+
return fmt.Errorf("parsing log entry array: %w", err)
70+
}
71+
72+
return nil
7373
}

internal/scaleway/create_and_deploy_function_namespace.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type CreateAndDeployFunctionNamespace struct {
2626
func (in CreateAndDeployFunctionNamespace) ToSDK() *function.CreateNamespaceRequest {
2727
return &function.CreateNamespaceRequest{
2828
Name: in.Name,
29-
Tags: setCreatedByTagIfAbsent(in.Tags),
29+
Tags: setCreatedByTag(in.Tags),
3030
}
3131
}
3232

internal/scaleway/create_deploy_function.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func (req CreateAndDeployFunctionRequest) ToSDK(
8181
Seconds: int64(timeout.Seconds()),
8282
},
8383
Description: &req.Description,
84-
Tags: setCreatedByTagIfAbsent(req.Tags),
84+
Tags: setCreatedByTag(req.Tags),
8585
EnvironmentVariables: &req.EnvironmentVariables,
8686
SecretEnvironmentVariables: secrets,
8787
MinScale: req.MinScale,
@@ -90,6 +90,7 @@ func (req CreateAndDeployFunctionRequest) ToSDK(
9090
}, nil
9191
}
9292

93+
//nolint:funlen
9394
func (t *Tools) CreateAndDeployFunction(
9495
ctx context.Context,
9596
req *mcp.CallToolRequest,
@@ -107,6 +108,8 @@ func (t *Tools) CreateAndDeployFunction(
107108
return nil, Function{}, fmt.Errorf("converting to SDK request: %w", err)
108109
}
109110

111+
// We always create the function first before zipping the code archive for
112+
// faster feedback to the user in case of errors.
110113
fun, err := t.functionsAPI.CreateFunction(createReq, scw.WithContext(ctx))
111114
if err != nil {
112115
return nil, Function{}, fmt.Errorf("creating function: %w", err)
@@ -119,6 +122,23 @@ func (t *Tools) CreateAndDeployFunction(
119122
return nil, Function{}, fmt.Errorf("creating archive: %w", err)
120123
}
121124

125+
tags := setCodeArchiveDigestTag(fun.Tags, archive.Digest)
126+
127+
// However, as a side-effect of doing creation first, we need to
128+
// update the function to add the code archive digest tag (which helps
129+
// avoid redeploying the same code in future updates).
130+
fun, err = t.functionsAPI.UpdateFunction(&function.UpdateFunctionRequest{
131+
FunctionID: fun.ID,
132+
Redeploy: scw.BoolPtr(false),
133+
Tags: scw.StringsPtr(tags),
134+
}, scw.WithContext(ctx))
135+
if err != nil {
136+
return nil, Function{}, fmt.Errorf(
137+
"updating function with code archive digest tag: %w",
138+
err,
139+
)
140+
}
141+
122142
presignedURLResp, err := t.functionsAPI.GetFunctionUploadURL(
123143
&function.GetFunctionUploadURLRequest{
124144
FunctionID: fun.ID,

internal/scaleway/fetch_function_logs.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"log/slog"
87
"strings"
98
"time"
109

1110
"github.com/cyclimse/mcp-scaleway-functions/internal/scaleway/cockpit"
11+
"github.com/cyclimse/mcp-scaleway-functions/pkg/slogctx"
1212
"github.com/modelcontextprotocol/go-sdk/mcp"
1313
)
1414

@@ -37,6 +37,8 @@ func (t *Tools) FetchFunctionLogs(
3737
_ *mcp.CallToolRequest,
3838
req FetchFunctionLogsRequest,
3939
) (*mcp.CallToolResult, FetchFunctionLogsResponse, error) {
40+
logger := slogctx.FromContext(ctx)
41+
4042
function, ns, err := getFunctionAndNamespaceByFunctionName(
4143
ctx,
4244
t.functionsAPI,
@@ -47,7 +49,7 @@ func (t *Tools) FetchFunctionLogs(
4749
}
4850

4951
if ns.ProjectID != t.projectID {
50-
slog.WarnContext(
52+
logger.WarnContext(
5153
ctx,
5254
"fetching logs across multiple Scaleway projects is not supported yet",
5355
"function_project_id", ns.ProjectID,

internal/scaleway/helpers.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"slices"
8+
"strings"
89
"time"
910

1011
"github.com/cyclimse/mcp-scaleway-functions/internal/constants"
@@ -79,14 +80,36 @@ func getFunctionAndNamespaceByFunctionName(
7980
return fun, ns, nil
8081
}
8182

82-
func setCreatedByTagIfAbsent(tags []string) []string {
83-
if !slices.Contains(tags, constants.TagCreatedByScalewayMCP) {
84-
tags = append(tags, constants.TagCreatedByScalewayMCP)
83+
func setTag(tags []string, tag string) []string {
84+
if !slices.Contains(tags, tag) {
85+
tags = append(tags, tag)
8586
}
8687

8788
return tags
8889
}
8990

91+
func setCreatedByTag(tags []string) []string {
92+
return setTag(tags, constants.TagCreatedByScalewayMCP)
93+
}
94+
95+
func setCodeArchiveDigestTag(tags []string, digest string) []string {
96+
prefix := constants.TagCodeArchiveDigestPrefix
97+
98+
// Remove any existing digest tag.
99+
filtered := make([]string, 0, len(tags))
100+
101+
for _, tag := range tags {
102+
if !strings.HasPrefix(tag, prefix) {
103+
filtered = append(filtered, tag)
104+
}
105+
}
106+
107+
// Add the new digest tag.
108+
filtered = append(filtered, prefix+digest)
109+
110+
return filtered
111+
}
112+
90113
func checkResourceOwnership(tags []string) error {
91114
if !slices.Contains(tags, constants.TagCreatedByScalewayMCP) {
92115
return fmt.Errorf("%w: resource does not belong to this tool", ErrResourceNotOwnedByTool)
@@ -95,6 +118,19 @@ func checkResourceOwnership(tags []string) error {
95118
return nil
96119
}
97120

121+
func getCodeArchiveDigestFromTags(tags []string) (string, bool) {
122+
prefix := constants.TagCodeArchiveDigestPrefix
123+
124+
for _, tag := range tags {
125+
after, found := strings.CutPrefix(tag, prefix)
126+
if found {
127+
return after, true
128+
}
129+
}
130+
131+
return "", false
132+
}
133+
98134
type WaitForFunctionCallback func(fun *function.Function)
99135

100136
// waitForFunction waits for a function to be in a terminal state (ready or error), running the

0 commit comments

Comments
 (0)