From e85b80c179f0497c4b322647c09ca50d67016b29 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 4 Sep 2026 20:51:46 +0100 Subject: [PATCH 1/4] fix(build): resolve build issues after last merge --- docs/runware_serverless_apps_invoke.md | 8 +++ internal/api/serverless/client.go | 8 ++- internal/api/serverless/tasks.go | 99 +++++++++++++++----------- internal/api/serverless/tasks_test.go | 87 +++++++++++----------- internal/cmd/serverless/apps_invoke.go | 17 +++-- 5 files changed, 128 insertions(+), 91 deletions(-) diff --git a/docs/runware_serverless_apps_invoke.md b/docs/runware_serverless_apps_invoke.md index 2469160..3f5e081 100644 --- a/docs/runware_serverless_apps_invoke.md +++ b/docs/runware_serverless_apps_invoke.md @@ -16,6 +16,10 @@ to poll until the task is completed or failed. expires, the command polls the returned task id; it never treats expiry as a failure and never resubmits. +A client-generated task id is sent with every invoke. Omit --task-id to +generate one. Resubmitting the same id returns the task it already names +instead of starting a second run. + ``` runware serverless apps invoke [flags] ``` @@ -32,6 +36,9 @@ runware serverless apps invoke [flags] # async invoke and poll runware serverless apps invoke my-app infer --wait -f payload.json + + # retry a lost response without starting a second task + runware serverless apps invoke my-app infer --task-id 7c9e6679-7425-40de-944b-e07fc1f90ae7 -f payload.json ``` ### Options @@ -41,6 +48,7 @@ runware serverless apps invoke [flags] -h, --help help for invoke --poll-interval duration Polling interval when waiting for a task (default 2s) --sync Use sync invocation and wait for a terminal task + --task-id string Client task id (UUID); generated if omitted --wait Poll until the task is completed or failed ``` diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 066111c..fc6598a 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -29,8 +29,9 @@ const defaultTimeout = 30 * time.Second const createAppTimeout = 5 * time.Minute // invokeSyncTimeout bounds startSyncTask. It must exceed the platform wait -// window so a 504/202 with taskId is received; a client-side timeout would -// lose the id and force a resubmit (a second billable run). +// window so a 202 with the accepted task is received; a client-side timeout +// would lose the response and force a resubmit (a second billable run unless +// the same client task id is reused). const invokeSyncTimeout = 5 * time.Minute // GpuType is the public catalogue entry for a supported GPU type. @@ -94,7 +95,8 @@ type Task = gen.Task type TaskStatus = gen.TaskStatus // TaskPayload is the JSON object forwarded to an endpoint handler. -type TaskPayload = gen.TaskPayload +// It is the TaskInvocation.payload member, not the request body itself. +type TaskPayload = map[string]interface{} // ListTasksParams are optional filters for ListTasks. type ListTasksParams = gen.ListTasksParams diff --git a/internal/api/serverless/tasks.go b/internal/api/serverless/tasks.go index 712d456..9c5e42f 100644 --- a/internal/api/serverless/tasks.go +++ b/internal/api/serverless/tasks.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/runware/runware-cli/internal/api/serverless/gen" "github.com/runware/runware-cli/internal/api/transport" ) @@ -44,18 +45,20 @@ func ValidateEndpointPath(path string) error { } // InvokeAsync starts a task and returns the accepted (typically pending) task. -func (c *Client) InvokeAsync(ctx context.Context, appID, endpointPath string, body TaskPayload) (*Task, error) { +// taskID is the client-owned identifier; an empty value is replaced with a new UUID. +func (c *Client) InvokeAsync(ctx context.Context, appID, endpointPath, taskID string, body TaskPayload) (*Task, error) { if c.apiKey == "" { return nil, transport.ErrNoAPIKey } if err := ValidateEndpointPath(endpointPath); err != nil { return nil, err } - if body == nil { - body = TaskPayload{} + invocation, err := newTaskInvocation(taskID, body) + if err != nil { + return nil, err } - resp, err := c.inner.StartAsyncTaskWithResponse(ctx, appID, endpointPath, gen.StartAsyncTaskJSONRequestBody(body)) + resp, err := c.inner.StartAsyncTaskWithResponse(ctx, appID, endpointPath, invocation) if err != nil { return nil, fmt.Errorf("invoke async: %w", err) } @@ -80,26 +83,29 @@ func (c *Client) InvokeAsync(ctx context.Context, appID, endpointPath string, bo return nil, problemToError(resp.ApplicationproblemJSON409, http.StatusConflict) case http.StatusUnprocessableEntity: return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + case http.StatusServiceUnavailable: + return nil, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) default: return nil, problemFromBody(resp.Body, resp.StatusCode()) } } // InvokeSync starts a task and waits up to the platform wait window. -// A wait-window expiry (504 today, 202 after RUNSERV-547) is not a failure: -// the accepted task is returned so the caller can poll. Never resubmit. -func (c *Client) InvokeSync(ctx context.Context, appID, endpointPath string, body TaskPayload) (*Task, error) { +// A wait-window expiry is not a failure: the accepted task is returned so +// the caller can poll. Never resubmit. +func (c *Client) InvokeSync(ctx context.Context, appID, endpointPath, taskID string, body TaskPayload) (*Task, error) { if c.apiKey == "" { return nil, transport.ErrNoAPIKey } if err := ValidateEndpointPath(endpointPath); err != nil { return nil, err } - if body == nil { - body = TaskPayload{} + invocation, err := newTaskInvocation(taskID, body) + if err != nil { + return nil, err } - resp, err := c.innerWithMinTimeout(invokeSyncTimeout).StartSyncTaskWithResponse(ctx, appID, endpointPath, gen.StartSyncTaskJSONRequestBody(body)) + resp, err := c.innerWithMinTimeout(invokeSyncTimeout).StartSyncTaskWithResponse(ctx, appID, endpointPath, invocation) if err != nil { return nil, fmt.Errorf("invoke sync: %w", err) } @@ -113,20 +119,14 @@ func (c *Client) InvokeSync(ctx context.Context, appID, endpointPath string, bod } return resp.JSON200, nil case http.StatusAccepted: - // Settled shape (RUNSERV-547): wait expiry returns 202 + Task. + if resp.JSON202 != nil { + return resp.JSON202, nil + } task, err := taskFromBody(resp.Body) if err != nil { - if id := taskIDFromProblem(nil, resp.Body); id != "" { - return pendingTask(appID, id), nil - } return nil, fmt.Errorf("invoke sync: wait window expired without a task id") } return task, nil - case http.StatusGatewayTimeout: - if id := taskIDFromProblem(resp.ApplicationproblemJSON504, resp.Body); id != "" { - return pendingTask(appID, id), nil - } - return nil, fmt.Errorf("invoke sync: wait window expired without a task id") case http.StatusBadRequest: return nil, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) case http.StatusUnauthorized: @@ -139,6 +139,8 @@ func (c *Client) InvokeSync(ctx context.Context, appID, endpointPath string, bod return nil, problemToError(resp.ApplicationproblemJSON409, http.StatusConflict) case http.StatusUnprocessableEntity: return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + case http.StatusServiceUnavailable: + return nil, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) default: return nil, problemFromBody(resp.Body, resp.StatusCode()) } @@ -150,7 +152,12 @@ func (c *Client) GetTask(ctx context.Context, appID, taskID string) (*Task, erro return nil, transport.ErrNoAPIKey } - resp, err := c.inner.GetTaskWithResponse(ctx, appID, taskID) + id, err := parseTaskID(taskID) + if err != nil { + return nil, err + } + + resp, err := c.inner.GetTaskWithResponse(ctx, appID, id) if err != nil { return nil, fmt.Errorf("get task: %w", err) } @@ -169,6 +176,8 @@ func (c *Client) GetTask(ctx context.Context, appID, taskID string) (*Task, erro return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) case http.StatusNotFound: return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusServiceUnavailable: + return nil, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) default: return nil, problemFromBody(resp.Body, resp.StatusCode()) } @@ -203,6 +212,8 @@ func (c *Client) ListTasks(ctx context.Context, appID string, params *ListTasksP return Page[Task]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) case http.StatusUnprocessableEntity: return Page[Task]{}, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + case http.StatusServiceUnavailable: + return Page[Task]{}, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) default: return Page[Task]{}, problemFromBody(resp.Body, resp.StatusCode()) } @@ -247,12 +258,33 @@ func (c *Client) WaitTask(ctx context.Context, appID, taskID string, interval ti } } -func pendingTask(appID, taskID string) *Task { - return &Task{ - Id: taskID, - AppId: appID, - Status: TaskStatusPending, +func newTaskInvocation(taskID string, body TaskPayload) (gen.TaskInvocation, error) { + id, err := resolveTaskID(taskID) + if err != nil { + return gen.TaskInvocation{}, err + } + if body == nil { + body = TaskPayload{} + } + return gen.TaskInvocation{ + Payload: body, + TaskId: id, + }, nil +} + +func resolveTaskID(taskID string) (uuid.UUID, error) { + if taskID == "" { + return uuid.New(), nil + } + return parseTaskID(taskID) +} + +func parseTaskID(taskID string) (uuid.UUID, error) { + id, err := uuid.Parse(taskID) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid task id %q: must be a lowercase UUID", taskID) } + return id, nil } func taskFromBody(body []byte) (*Task, error) { @@ -266,23 +298,6 @@ func taskFromBody(body []byte) (*Task, error) { return &task, nil } -func taskIDFromProblem(p *gen.ProblemDetails, body []byte) string { - if p != nil && p.TaskId != nil && *p.TaskId != "" { - return *p.TaskId - } - if len(body) == 0 { - return "" - } - var parsed gen.ProblemDetails - if err := json.Unmarshal(body, &parsed); err != nil { - return "" - } - if parsed.TaskId == nil { - return "" - } - return *parsed.TaskId -} - func isNotFound(err error) bool { var re *transport.RunwareError return errors.As(err, &re) && re.Code == transport.CodeNotFound diff --git a/internal/api/serverless/tasks_test.go b/internal/api/serverless/tasks_test.go index 7ba756b..dc016e6 100644 --- a/internal/api/serverless/tasks_test.go +++ b/internal/api/serverless/tasks_test.go @@ -13,15 +13,16 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/runware/runware-cli/internal/api/transport" ) const ( - testTaskID = "task-1" + testTaskID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" testEndpoint = "infer" - testTaskJSON = `{"id":"task-1","status":"pending","appId":"my-app","createdAt":"2026-07-30T12:00:00Z"}` - testTaskDone = `{"id":"task-1","status":"completed","appId":"my-app","createdAt":"2026-07-30T12:00:00Z","completedAt":"2026-07-30T12:00:05Z","output":{"ok":true}}` - testTaskFailed = `{"id":"task-1","status":"failed","appId":"my-app","createdAt":"2026-07-30T12:00:00Z","error":"oom killed"}` + testTaskJSON = `{"id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","status":"pending","appId":"my-app","endpointPath":"infer","createdAt":"2026-07-30T12:00:00Z"}` + testTaskDone = `{"id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","status":"completed","appId":"my-app","endpointPath":"infer","createdAt":"2026-07-30T12:00:00Z","completedAt":"2026-07-30T12:00:05Z","output":{"ok":true}}` + testTaskFailed = `{"id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","status":"failed","appId":"my-app","endpointPath":"infer","createdAt":"2026-07-30T12:00:00Z","error":"oom killed"}` ) func TestValidateEndpointPath(t *testing.T) { @@ -70,10 +71,14 @@ func TestInvokeAsync(t *testing.T) { if err != nil { t.Errorf("read body: %v", err) } - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { + var invocation map[string]any + if err := json.Unmarshal(body, &invocation); err != nil { t.Errorf("body is not JSON: %s", body) } + if invocation["taskId"] != testTaskID { + t.Errorf("taskId = %v, want %s", invocation["taskId"], testTaskID) + } + payload, _ := invocation["payload"].(map[string]any) if payload["prompt"] != "hi" { t.Errorf("payload = %s", body) } @@ -84,7 +89,7 @@ func TestInvokeAsync(t *testing.T) { defer srv.Close() c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - task, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, TaskPayload{"prompt": "hi"}) + task, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, testTaskID, TaskPayload{"prompt": "hi"}) if err != nil { t.Fatalf("InvokeAsync: %v", err) } @@ -98,7 +103,7 @@ func TestInvokeAsync(t *testing.T) { func TestInvokeAsync_RejectsLeadingSlash(t *testing.T) { c := NewClient("test-key", "https://example.invalid", slog.Default()) - _, err := c.InvokeAsync(context.Background(), testAppID, "/infer", nil) + _, err := c.InvokeAsync(context.Background(), testAppID, "/infer", "", nil) if err == nil || !strings.Contains(err.Error(), "bare segment") { t.Fatalf("expected leading-slash error, got %v", err) } @@ -106,7 +111,7 @@ func TestInvokeAsync_RejectsLeadingSlash(t *testing.T) { func TestInvokeAsync_NoAPIKey(t *testing.T) { c := NewClient("", "https://example.invalid", slog.Default()) - if _, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, nil); !errors.Is(err, transport.ErrNoAPIKey) { + if _, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, "", nil); !errors.Is(err, transport.ErrNoAPIKey) { t.Fatalf("expected ErrNoAPIKey, got %v", err) } } @@ -123,7 +128,7 @@ func TestInvokeSync_Completed(t *testing.T) { defer srv.Close() c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - task, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, nil) + task, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, "", nil) if err != nil { t.Fatalf("InvokeSync: %v", err) } @@ -132,30 +137,30 @@ func TestInvokeSync_Completed(t *testing.T) { } } -func TestInvokeSync_WaitExpiry504(t *testing.T) { - var posts atomic.Int32 +func TestInvokeAsync_GeneratesTaskID(t *testing.T) { + var gotID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/invoke-sync/") { - posts.Add(1) - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(http.StatusGatewayTimeout) - _, _ = w.Write([]byte(`{"type":"about:blank","title":"Gateway Timeout","status":504,"taskId":"task-1"}`)) - return + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) } - t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + var invocation map[string]any + if err := json.Unmarshal(body, &invocation); err != nil { + t.Errorf("body is not JSON: %s", body) + } + gotID, _ = invocation["taskId"].(string) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(testTaskJSON)) })) defer srv.Close() c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - task, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, nil) - if err != nil { - t.Fatalf("InvokeSync must not treat 504 as failure: %v", err) - } - if task.Id != testTaskID || task.Status != TaskStatusPending { - t.Fatalf("expected pending task %s, got %+v", testTaskID, task) + if _, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, "", nil); err != nil { + t.Fatalf("InvokeAsync: %v", err) } - if posts.Load() != 1 { - t.Fatalf("504 must not resubmit: got %d POSTs", posts.Load()) + if _, err := uuid.Parse(gotID); err != nil { + t.Fatalf("generated taskId %q is not a UUID: %v", gotID, err) } } @@ -168,7 +173,7 @@ func TestInvokeSync_WaitExpiry202(t *testing.T) { defer srv.Close() c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - task, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, nil) + task, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, "", nil) if err != nil { t.Fatalf("InvokeSync must not treat 202 as failure: %v", err) } @@ -177,21 +182,19 @@ func TestInvokeSync_WaitExpiry202(t *testing.T) { } } -func TestInvokeSync_504WithoutTaskID(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(http.StatusGatewayTimeout) - _, _ = w.Write([]byte(`{"type":"about:blank","title":"Gateway Timeout","status":504}`)) - })) - defer srv.Close() - - c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - _, err := c.InvokeSync(context.Background(), testAppID, testEndpoint, nil) - if err == nil || !strings.Contains(err.Error(), "without a task id") { - t.Fatalf("expected missing-id error, got %v", err) +func TestGetTask_InvalidID(t *testing.T) { + c := NewClient("test-key", "https://example.invalid", slog.Default()) + _, err := c.GetTask(context.Background(), testAppID, "not-a-uuid") + if err == nil || !strings.Contains(err.Error(), "invalid task id") { + t.Fatalf("expected invalid task id error, got %v", err) } - if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "Timeout") { - t.Fatalf("must not report a timeout error: %v", err) +} + +func TestInvokeAsync_InvalidTaskID(t *testing.T) { + c := NewClient("test-key", "https://example.invalid", slog.Default()) + _, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, "not-a-uuid", nil) + if err == nil || !strings.Contains(err.Error(), "invalid task id") { + t.Fatalf("expected invalid task id error, got %v", err) } } diff --git a/internal/cmd/serverless/apps_invoke.go b/internal/cmd/serverless/apps_invoke.go index d163f57..c0d3664 100644 --- a/internal/cmd/serverless/apps_invoke.go +++ b/internal/cmd/serverless/apps_invoke.go @@ -20,6 +20,7 @@ func newAppsInvokeCmd(logger *log.Logger) *cobra.Command { sync bool wait bool bodyFile string + taskID string pollInterval time.Duration ) @@ -36,7 +37,11 @@ to poll until the task is completed or failed. --sync uses the sync invocation endpoint. If the platform wait window expires, the command polls the returned task id; it never treats expiry as -a failure and never resubmits.`, +a failure and never resubmits. + +A client-generated task id is sent with every invoke. Omit --task-id to +generate one. Resubmitting the same id returns the task it already names +instead of starting a second run.`, Example: ` # list endpoint paths, then invoke asynchronously runware serverless apps endpoints my-app runware serverless apps invoke my-app infer -f payload.json @@ -45,7 +50,10 @@ a failure and never resubmits.`, runware serverless apps invoke my-app infer --sync -f payload.json # async invoke and poll - runware serverless apps invoke my-app infer --wait -f payload.json`, + runware serverless apps invoke my-app infer --wait -f payload.json + + # retry a lost response without starting a second task + runware serverless apps invoke my-app infer --task-id 7c9e6679-7425-40de-944b-e07fc1f90ae7 -f payload.json`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { appID := args[0] @@ -60,9 +68,9 @@ a failure and never resubmits.`, var task *serverlessapi.Task if sync { - task, err = client.InvokeSync(cmd.Context(), appID, endpointPath, payload) + task, err = client.InvokeSync(cmd.Context(), appID, endpointPath, taskID, payload) } else { - task, err = client.InvokeAsync(cmd.Context(), appID, endpointPath, payload) + task, err = client.InvokeAsync(cmd.Context(), appID, endpointPath, taskID, payload) } if err != nil { spin.Stop() @@ -91,6 +99,7 @@ a failure and never resubmits.`, cmd.Flags().BoolVar(&sync, "sync", false, "Use sync invocation and wait for a terminal task") cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the task is completed or failed") cmd.Flags().StringVarP(&bodyFile, "body", "f", "", "JSON payload file, or - for stdin (default {})") + cmd.Flags().StringVar(&taskID, "task-id", "", "Client task id (UUID); generated if omitted") cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for a task") return cmd } From 5691976ab68fadef17395280ca14b933d6ae267d Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 4 Sep 2026 21:35:52 +0100 Subject: [PATCH 2/4] feat(serverless): activate a ready app version by number Expose POST /v1/apps/{appId}/deploy so operators can pin or roll back to a ready version without creating a new build. Co-authored-by: Cursor --- docs/runware_serverless_apps.md | 2 +- docs/runware_serverless_apps_versions.md | 5 +- ...nware_serverless_apps_versions_activate.md | 50 +++++++++ docs/runware_serverless_apps_versions_list.md | 2 +- docs/runware_serverless_apps_versions_show.md | 2 +- internal/api/serverless/client.go | 28 +++++ internal/api/serverless/client_test.go | 103 ++++++++++++++++++ internal/cmd/serverless/apps_versions.go | 48 +++++++- internal/cmd/serverless/display.go | 28 ++--- internal/cmd/serverless/display_test.go | 13 ++- 10 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 docs/runware_serverless_apps_versions_activate.md diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 3898fa5..df6ffda 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -41,6 +41,6 @@ runware serverless apps [flags] * [runware serverless apps stop](runware_serverless_apps_stop.md) - Stop a serverless application * [runware serverless apps tasks](runware_serverless_apps_tasks.md) - List and inspect application tasks * [runware serverless apps usage](runware_serverless_apps_usage.md) - Show usage for a serverless application -* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions +* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions * [runware serverless apps workers](runware_serverless_apps_workers.md) - List workers for a serverless application diff --git a/docs/runware_serverless_apps_versions.md b/docs/runware_serverless_apps_versions.md index 37712c1..d3a29d4 100644 --- a/docs/runware_serverless_apps_versions.md +++ b/docs/runware_serverless_apps_versions.md @@ -1,10 +1,10 @@ ## runware serverless apps versions -Inspect application versions +Manage application versions ### Synopsis -List and inspect immutable versions of a serverless application. +List, inspect, and activate immutable versions of a serverless application. ``` runware serverless apps versions [flags] @@ -28,6 +28,7 @@ runware serverless apps versions [flags] ### SEE ALSO * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications +* [runware serverless apps versions activate](runware_serverless_apps_versions_activate.md) - Activate a ready application version * [runware serverless apps versions list](runware_serverless_apps_versions_list.md) - List versions of a serverless application * [runware serverless apps versions show](runware_serverless_apps_versions_show.md) - Show a version of a serverless application diff --git a/docs/runware_serverless_apps_versions_activate.md b/docs/runware_serverless_apps_versions_activate.md new file mode 100644 index 0000000..fd2c14f --- /dev/null +++ b/docs/runware_serverless_apps_versions_activate.md @@ -0,0 +1,50 @@ +## runware serverless apps versions activate + +Activate a ready application version + +### Synopsis + +Activate a ready version by number, including rollback to an older version. + +The server accepts the deploy and returns immediately with the updated app. +Worker rollout is asynchronous; this command does not wait until workers are +healthy. Re-activating the currently active version is permitted and re-applies +it. On a stopped app the version is recorded and applied on resume. + +A missing app is 404. A missing version, a version that is not ready, or an +app that is deleting is 409. + +``` +runware serverless apps versions activate [flags] +``` + +### Examples + +``` + # list versions, then activate one + runware serverless apps versions list my-app + runware serverless apps versions activate my-app 1 + + # roll back to an older ready version + runware serverless apps versions activate my-app 2 +``` + +### Options + +``` + -h, --help help for activate +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions + diff --git a/docs/runware_serverless_apps_versions_list.md b/docs/runware_serverless_apps_versions_list.md index 1acb8e7..d0d010e 100644 --- a/docs/runware_serverless_apps_versions_list.md +++ b/docs/runware_serverless_apps_versions_list.md @@ -41,5 +41,5 @@ runware serverless apps versions list [flags] ### SEE ALSO -* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions +* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions diff --git a/docs/runware_serverless_apps_versions_show.md b/docs/runware_serverless_apps_versions_show.md index bbcc90c..5e3bc47 100644 --- a/docs/runware_serverless_apps_versions_show.md +++ b/docs/runware_serverless_apps_versions_show.md @@ -34,5 +34,5 @@ runware serverless apps versions show [flags] ### SEE ALSO -* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions +* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index fc6598a..8781e61 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -67,6 +67,9 @@ type AppUpdate = gen.AppUpdate // WorkerConfigPatch is a partial worker configuration for updateApp. type WorkerConfigPatch = gen.WorkerConfigPatch +// DeployRequest is the request body for DeployVersion. +type DeployRequest = gen.DeployRequest + // ListAppsParams are optional filters for ListApps. type ListAppsParams = gen.ListAppsParams @@ -449,6 +452,31 @@ func (c *Client) DeleteApp(ctx context.Context, appID string) (*App, error) { }) } +// DeployVersion activates a ready version by number. Worker rollout is +// asynchronous; the 202 App reflects the persisted intent (activeVersionId +// and status), not healthy workers. An older ready number is a rollback. +func (c *Client) DeployVersion(ctx context.Context, appID string, versionNumber int32) (*App, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.DeployVersionWithResponse(ctx, appID, DeployRequest{ + VersionNumber: versionNumber, + }) + if err != nil { + return nil, fmt.Errorf("deploy version: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + return acceptedApp("deploy version", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{ + Unauthorized: resp.ApplicationproblemJSON401, + Forbidden: resp.ApplicationproblemJSON403, + NotFound: resp.ApplicationproblemJSON404, + Conflict: resp.ApplicationproblemJSON409, + }) +} + // lifecycleProblems are typed RFC 9457 bodies bound by the generated client. type lifecycleProblems struct { Unauthorized *gen.ProblemDetails diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index 9dfa639..f3d12c0 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -908,6 +908,109 @@ func TestGetVersion_NoAPIKey(t *testing.T) { } } +func TestDeployVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/apps/" + testAppID + "/deploy" + if r.Method != http.MethodPost || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + var req DeployRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("body is not JSON: %s", body) + } + if req.VersionNumber != testVersionNumber { + t.Errorf("versionNumber = %d, want %d", req.VersionNumber, testVersionNumber) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{ + "appId":"my-app", + "appName":"My App", + "status":"initializing", + "activeVersionId":"` + testVersionID + `", + "configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"computeType":"gpu"}, + "environmentVariables":[], + "secrets":[], + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + app, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber) + if err != nil { + t.Fatalf("DeployVersion: %v", err) + } + if app.AppId != testAppID { + t.Fatalf("unexpected app: %+v", app) + } + if app.ActiveVersionId == nil || app.ActiveVersionId.String() != testVersionID { + t.Fatalf("unexpected activeVersionId: %+v", app.ActiveVersionId) + } +} + +func TestDeployVersion_Conflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Conflict","status":409,"detail":"Version is not ready"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeValidation { + t.Errorf("expected CodeValidation, got %v", re.Code) + } + if re.StatusCode != http.StatusConflict { + t.Errorf("expected status 409, got %d", re.StatusCode) + } + if re.Message != "Version is not ready" { + t.Errorf("unexpected message: %q", re.Message) + } +} + +func TestDeployVersion_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No app 'missing' exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.DeployVersion(context.Background(), "missing", testVersionNumber) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", re.Code) + } + if re.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", re.StatusCode) + } + if re.Message != "No app 'missing' exists" { + t.Errorf("unexpected message: %q", re.Message) + } +} + +func TestDeployVersion_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + func TestListWorkers(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { want := "/v1/apps/" + testAppID + "/workers" diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go index ba53b51..3783a5d 100644 --- a/internal/cmd/serverless/apps_versions.go +++ b/internal/cmd/serverless/apps_versions.go @@ -14,11 +14,12 @@ import ( ) func newAppsVersionsCmd(logger *log.Logger) *cobra.Command { - cmd := stubGroup("versions", "Inspect application versions") - cmd.Long = "List and inspect immutable versions of a serverless application." + cmd := stubGroup("versions", "Manage application versions") + cmd.Long = "List, inspect, and activate immutable versions of a serverless application." cmd.AddCommand( newAppsVersionsListCmd(logger), newAppsVersionsShowCmd(logger), + newAppsVersionsActivateCmd(logger), ) return cmd } @@ -104,6 +105,49 @@ func newAppsVersionsShowCmd(logger *log.Logger) *cobra.Command { return cmd } +func newAppsVersionsActivateCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "activate ", + Short: "Activate a ready application version", + Long: `Activate a ready version by number, including rollback to an older version. + +The server accepts the deploy and returns immediately with the updated app. +Worker rollout is asynchronous; this command does not wait until workers are +healthy. Re-activating the currently active version is permitted and re-applies +it. On a stopped app the version is recorded and applied on resume. + +A missing app is 404. A missing version, a version that is not ready, or an +app that is deleting is 409.`, + Example: ` # list versions, then activate one + runware serverless apps versions list my-app + runware serverless apps versions activate my-app 1 + + # roll back to an older ready version + runware serverless apps versions activate my-app 2`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + n, err := parseVersionNumber(args[1]) + if err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Activating version %d on %s...", n, appID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + app, err := client.DeployVersion(cmd.Context(), appID, n) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + }, + } +} + func parseVersionNumber(s string) (int32, error) { n, err := strconv.ParseInt(s, 10, 32) if err != nil || n < 1 { diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 9c64cdd..7c8c87d 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -12,19 +12,20 @@ import ( ) const ( - colID = "ID" - colName = "Name" - colStatus = "Status" - colCreated = "Created" - colUpdated = "Updated" - colType = "Type" - colField = "Field" - colValue = "Value" - colApp = "App" - colKey = "Key" - colEnvVar = "Env var" - colError = "Error" - colCompleted = "Completed" + colID = "ID" + colName = "Name" + colStatus = "Status" + colActiveVersion = "Active version" + colCreated = "Created" + colUpdated = "Updated" + colType = "Type" + colField = "Field" + colValue = "Value" + colApp = "App" + colKey = "Key" + colEnvVar = "Env var" + colError = "Error" + colCompleted = "Completed" colComputeType = "Compute type" colGPUType = "GPU type" @@ -52,6 +53,7 @@ func (r appResult) Rows() [][]any { {colID, r.AppId}, {colName, r.AppName}, {colStatus, string(r.Status)}, + {colActiveVersion, formatOptionalUUID(r.ActiveVersionId)}, {colCreated, r.CreatedAt.Format(time.RFC3339)}, {colUpdated, r.UpdatedAt.Format(time.RFC3339)}, {colComputeType, string(cfg.ComputeType)}, diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 2b1f97c..9b1dd00 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -159,12 +159,14 @@ func TestExtraStatusCursorFlag(t *testing.T) { func TestAppResult_IncludesConfiguration(t *testing.T) { gpu := testGPUType created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + activeVersion := uuid.MustParse("22222222-2222-2222-2222-222222222222") r := appResult{ - AppId: testAppID, - AppName: "My App", - Status: "active", - CreatedAt: created, - UpdatedAt: created, + AppId: testAppID, + AppName: "My App", + Status: "active", + ActiveVersionId: &activeVersion, + CreatedAt: created, + UpdatedAt: created, Configuration: serverlessapi.WorkerConfig{ ComputeType: "gpu", GpuType: &gpu, @@ -185,6 +187,7 @@ func TestAppResult_IncludesConfiguration(t *testing.T) { colID: testAppID, colName: "My App", colStatus: "active", + colActiveVersion: activeVersion.String(), colCreated: createdAt, colUpdated: createdAt, colComputeType: "gpu", From b9b235f6a5f507fae5aad86b87bc4326e88257fa Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 4 Sep 2026 21:55:59 +0100 Subject: [PATCH 3/4] fix(serverless): clarify version activate examples and label The rollback example now activates a lower version number, and the app table labels the UUID as Active version ID. Co-authored-by: Cursor --- docs/runware_serverless_apps_versions_activate.md | 4 ++-- internal/cmd/serverless/apps_versions.go | 4 ++-- internal/cmd/serverless/display.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/runware_serverless_apps_versions_activate.md b/docs/runware_serverless_apps_versions_activate.md index fd2c14f..35e6038 100644 --- a/docs/runware_serverless_apps_versions_activate.md +++ b/docs/runware_serverless_apps_versions_activate.md @@ -23,10 +23,10 @@ runware serverless apps versions activate [flags] ``` # list versions, then activate one runware serverless apps versions list my-app - runware serverless apps versions activate my-app 1 + runware serverless apps versions activate my-app 2 # roll back to an older ready version - runware serverless apps versions activate my-app 2 + runware serverless apps versions activate my-app 1 ``` ### Options diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go index 3783a5d..8ff9bd5 100644 --- a/internal/cmd/serverless/apps_versions.go +++ b/internal/cmd/serverless/apps_versions.go @@ -120,10 +120,10 @@ A missing app is 404. A missing version, a version that is not ready, or an app that is deleting is 409.`, Example: ` # list versions, then activate one runware serverless apps versions list my-app - runware serverless apps versions activate my-app 1 + runware serverless apps versions activate my-app 2 # roll back to an older ready version - runware serverless apps versions activate my-app 2`, + runware serverless apps versions activate my-app 1`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { appID := args[0] diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 7c8c87d..eaa9524 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -15,7 +15,7 @@ const ( colID = "ID" colName = "Name" colStatus = "Status" - colActiveVersion = "Active version" + colActiveVersion = "Active version ID" colCreated = "Created" colUpdated = "Updated" colType = "Type" From a8d52636bea50d3b12194102ed2180ea43349d48 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 4 Sep 2026 22:13:56 +0100 Subject: [PATCH 4/4] feat(serverless): deploy apps from a container source Upload a Dockerfile + container.yaml directory as sourceType container so create can build a hosted wrapper image instead of requiring a customer image ref. Co-authored-by: Cursor --- docs/runware_serverless_deploy.md | 35 +++-- internal/api/serverless/client.go | 26 +++- internal/api/serverless/client_test.go | 63 ++++++++ internal/cmd/serverless/deploy.go | 140 +++++++++++++++--- internal/cmd/serverless/deploy_test.go | 193 +++++++++++++++++++++++++ internal/cmd/serverless/pack.go | 96 ++++++++++-- internal/cmd/serverless/pack_test.go | 108 ++++++++++++++ internal/cmd/serverless/upload.go | 4 +- internal/cmd/serverless/upload_test.go | 31 +++- 9 files changed, 644 insertions(+), 52 deletions(-) create mode 100644 internal/cmd/serverless/deploy_test.go diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index 82e6a59..cab465f 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -4,15 +4,27 @@ Deploy a new serverless application ### Synopsis -Create a new serverless application from a Python entry file. +Create a new serverless application from Python code or a container source. -The whole source directory is zipped and submitted as the application source, so -the entry file can import its own modules and read its own data files. That -directory is the working directory unless --src-dir says otherwise. +A code deploy takes a Python entry file. The whole source directory is zipped +and submitted as the application source, so the entry file can import its own +modules and read its own data files. That directory is the working directory +unless --src-dir says otherwise. The entry file must live inside the source directory. A relative path is resolved inside it; an absolute path is taken as given. +A container deploy takes --container pointing at a directory whose root contains +Dockerfile and container.yaml (plus any build-context files the Dockerfile +copies). The directory is zipped and uploaded as source type container. Runware +builds a hosted wrapper image from that archive; the version records a buildId, +not a customer image reference. Invalid container.yaml is rejected on create +(400 if it cannot be parsed, 422 if it breaks a rule). The app stays +initializing until that first build rolls out. + +--container cannot be combined with an entry file, --src-dir, --base-image, or +--requirement. + Exclude what the app does not need with a .runwareignore file at the root of the source directory; it takes gitignore syntax. A .gitignore is NOT consulted -- what a project keeps out of version control is a different question from what it @@ -31,10 +43,11 @@ download is copied into every checkpoint and fetched again on every cold start. A volume keeps it out of both. Worker settings are supplied via flags (a local project config via 'runware -serverless init' is planned). Endpoints are derived server-side from the SDK. +serverless init' is planned). Endpoints are derived server-side from the SDK +(code) or from container.yaml (container). ``` -runware serverless deploy [flags] +runware serverless deploy [file] [flags] ``` ### Examples @@ -61,12 +74,16 @@ runware serverless deploy [flags] runware serverless deploy ./app.py --id my-app --name "My App" \ --max-workers 2 --idle-ttl 120 --gpu-type h100 \ --base-image python:3.11-slim --requirement torch + + # deploy a container source (Dockerfile + container.yaml at the directory root) + runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper ``` ### Options ``` - --base-image string Builder base image (default "python:3.11-slim") + --base-image string Builder base image (code deploys only) (default "python:3.11-slim") + --container string Directory whose root contains Dockerfile and container.yaml --env stringArray Environment variable as KEY=VALUE (repeatable) --env-file stringArray File of KEY=VALUE lines to read environment variables from (repeatable) --gpu-type string GPU type ID (see 'serverless gpus') @@ -77,9 +94,9 @@ runware serverless deploy [flags] --max-workers int32 Maximum number of workers (default 1) --min-workers int32 Minimum number of workers --name string Display name (defaults to --id) - --requirement stringArray Additional pip package to install (repeatable) + --requirement stringArray Additional pip package to install (repeatable; code deploys only) --scaling-delay int32 Scaling delay in seconds (default 10) - --src-dir string Directory to package as the application source (default: the working directory) + --src-dir string Directory to package as the application source (default: the working directory; code deploys only) --volume stringArray Absolute path inside the app backed by persistent node-local storage (repeatable) ``` diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 8781e61..3b72b13 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -43,6 +43,9 @@ type App = gen.App // AppCreate is the request body for createApp. type AppCreate = gen.AppCreate +// AppSourceType selects the version creation path (`code` or `container`). +type AppSourceType = gen.AppSourceType + // AppSourceUpsert selects the initial version creation path. type AppSourceUpsert = gen.AppSourceUpsert @@ -52,6 +55,9 @@ type CodeSourceUpsert = gen.CodeSourceUpsert // CodebaseSource is the zipped customer code payload. type CodebaseSource = gen.CodebaseSource +// ContainerSource is a Dockerfile + container.yaml archive identified by sourceId. +type ContainerSource = gen.ContainerSource + // AppVolume is a persistent node-local directory mounted into the application. type AppVolume = gen.AppVolume @@ -140,8 +146,12 @@ type Page[T any] struct { NextCursor *string `json:"nextCursor,omitempty"` } -// AppSourceTypeCode is appSource.type = "code". -const AppSourceTypeCode = gen.Code +const ( + // AppSourceTypeCode is appSource.type = "code". + AppSourceTypeCode = gen.Code + // AppSourceTypeContainer is appSource.type = "container". + AppSourceTypeContainer = gen.Container +) func pageOf[T any](data *[]T, nextCursor *string) Page[T] { if data == nil || *data == nil { @@ -699,3 +709,15 @@ func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { Source: source, }, nil } + +// NewContainerAppSource builds an appSource for a container-based create. +func NewContainerAppSource(src ContainerSource) (AppSourceUpsert, error) { + var source gen.AppSourceUpsert_Source + if err := source.FromContainerSource(src); err != nil { + return AppSourceUpsert{}, err + } + return AppSourceUpsert{ + Type: AppSourceTypeContainer, + Source: source, + }, nil +} diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index f3d12c0..fa2e9ae 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -224,6 +224,69 @@ func TestCreateApp_Conflict(t *testing.T) { } } +func TestNewContainerAppSource(t *testing.T) { + id := uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456") + source, err := NewContainerAppSource(ContainerSource{ + SourceId: id, + }) + if err != nil { + t.Fatalf("NewContainerAppSource: %v", err) + } + if source.Type != AppSourceTypeContainer { + t.Errorf("type = %q, want container", source.Type) + } + inner, err := source.Source.AsContainerSource() + if err != nil { + t.Fatalf("AsContainerSource: %v", err) + } + if inner.SourceId != id { + t.Errorf("sourceId = %s, want %s", inner.SourceId, id) + } + + raw, err := json.Marshal(source) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var wire AppSourceUpsert + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if wire.Type != AppSourceTypeContainer { + t.Errorf("wire type = %q, want container", wire.Type) + } + got, err := wire.Source.AsContainerSource() + if err != nil { + t.Fatalf("wire AsContainerSource: %v", err) + } + if got.SourceId != id { + t.Errorf("wire sourceId = %s, want %s", got.SourceId, id) + } +} + +func TestNewCodeAppSource(t *testing.T) { + id := uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456") + source, err := NewCodeAppSource(CodeSourceUpsert{ + BaseImage: "python:3.11-slim", + Codebase: CodebaseSource{ + SourceId: id, + ModelFile: "model.py", + }, + }) + if err != nil { + t.Fatalf("NewCodeAppSource: %v", err) + } + if source.Type != AppSourceTypeCode { + t.Errorf("type = %q, want code", source.Type) + } + inner, err := source.Source.AsCodeSourceUpsert() + if err != nil { + t.Fatalf("AsCodeSourceUpsert: %v", err) + } + if inner.Codebase.SourceId != id || inner.Codebase.ModelFile != "model.py" { + t.Errorf("codebase = %+v", inner.Codebase) + } +} + func TestCreateApp_NoAPIKey(t *testing.T) { c := NewClient("", "https://example.invalid", slog.Default()) if _, err := c.CreateApp(context.Background(), AppCreate{}); !errors.Is(err, transport.ErrNoAPIKey) { diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 0e0d9cb..1ed4c22 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/charmbracelet/log" + "github.com/google/uuid" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" "github.com/runware/runware-cli/internal/cmdutil" "github.com/runware/runware-cli/internal/config" @@ -12,6 +13,86 @@ import ( "github.com/spf13/cobra" ) +const deploySourceChoice = "pass an entry file or --container" + +var codeOnlyDeployFlags = []string{ + "src-dir", + "base-image", + "requirement", +} + +// deploySource is the packed archive's type plus the fields CreateApp needs +// once the upload publishes a sourceId. +type deploySource struct { + sourceType serverlessapi.AppSourceType + baseImage string + modelFile string + requirements []string +} + +func (s deploySource) appSource(sourceID uuid.UUID) (serverlessapi.AppSourceUpsert, error) { + switch s.sourceType { + case serverlessapi.AppSourceTypeContainer: + return serverlessapi.NewContainerAppSource(serverlessapi.ContainerSource{ + SourceId: sourceID, + }) + case serverlessapi.AppSourceTypeCode: + return serverlessapi.NewCodeAppSource(serverlessapi.CodeSourceUpsert{ + BaseImage: s.baseImage, + Codebase: serverlessapi.CodebaseSource{ + SourceId: sourceID, + ModelFile: s.modelFile, + }, + Requirements: optionalStringSlice(s.requirements), + }) + default: + return serverlessapi.AppSourceUpsert{}, fmt.Errorf("unsupported source type %q", s.sourceType) + } +} + +func validateDeployArgs(cmd *cobra.Command, args []string, containerDir string) error { + hasFile := len(args) == 1 + hasContainer := containerDir != "" + if hasFile == hasContainer { + if hasFile { + return fmt.Errorf("%s, not both", deploySourceChoice) + } + return fmt.Errorf("%s", deploySourceChoice) + } + if !hasContainer { + return nil + } + for _, name := range codeOnlyDeployFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--%s applies to code deploys only; omit it when using --container", name) + } + } + return nil +} + +func buildDeployArchive(srcDir, containerDir, baseImage string, requirements []string, args []string) ([]byte, deploySource, error) { + if containerDir != "" { + archive, err := packContainerDirectory(containerDir) + if err != nil { + return nil, deploySource{}, err + } + return archive, deploySource{ + sourceType: serverlessapi.AppSourceTypeContainer, + }, nil + } + + archive, modelFile, err := packDirectory(srcDir, args[0]) + if err != nil { + return nil, deploySource{}, err + } + return archive, deploySource{ + sourceType: serverlessapi.AppSourceTypeCode, + baseImage: baseImage, + modelFile: modelFile, + requirements: requirements, + }, nil +} + func newDeployCmd(logger *log.Logger) *cobra.Command { var ( id string @@ -25,23 +106,36 @@ func newDeployCmd(logger *log.Logger) *cobra.Command { minWorkers int32 gpusPerWorker int32 srcDir string + containerDir string volumes []string envVars []string envFiles []string ) cmd := &cobra.Command{ - Use: "deploy ", + Use: "deploy [file]", Short: "Deploy a new serverless application", - Long: `Create a new serverless application from a Python entry file. + Long: `Create a new serverless application from Python code or a container source. -The whole source directory is zipped and submitted as the application source, so -the entry file can import its own modules and read its own data files. That -directory is the working directory unless --src-dir says otherwise. +A code deploy takes a Python entry file. The whole source directory is zipped +and submitted as the application source, so the entry file can import its own +modules and read its own data files. That directory is the working directory +unless --src-dir says otherwise. The entry file must live inside the source directory. A relative path is resolved inside it; an absolute path is taken as given. +A container deploy takes --container pointing at a directory whose root contains +Dockerfile and container.yaml (plus any build-context files the Dockerfile +copies). The directory is zipped and uploaded as source type container. Runware +builds a hosted wrapper image from that archive; the version records a buildId, +not a customer image reference. Invalid container.yaml is rejected on create +(400 if it cannot be parsed, 422 if it breaks a rule). The app stays +initializing until that first build rolls out. + +--container cannot be combined with an entry file, --src-dir, --base-image, or +--requirement. + Exclude what the app does not need with a .runwareignore file at the root of the source directory; it takes gitignore syntax. A .gitignore is NOT consulted -- what a project keeps out of version control is a different question from what it @@ -60,7 +154,8 @@ download is copied into every checkpoint and fetched again on every cold start. A volume keeps it out of both. Worker settings are supplied via flags (a local project config via 'runware -serverless init' is planned). Endpoints are derived server-side from the SDK.`, +serverless init' is planned). Endpoints are derived server-side from the SDK +(code) or from container.yaml (container).`, Example: ` # deploy the current directory, with app.py as the entry point runware serverless deploy ./app.py --id my-app --gpu-type h100 @@ -81,15 +176,20 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`, # override worker settings and base image runware serverless deploy ./app.py --id my-app --name "My App" \ --max-workers 2 --idle-ttl 120 --gpu-type h100 \ - --base-image python:3.11-slim --requirement torch`, - Args: cobra.ExactArgs(1), + --base-image python:3.11-slim --requirement torch + + # deploy a container source (Dockerfile + container.yaml at the directory root) + runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - entryFile := args[0] + if err := validateDeployArgs(cmd, args, containerDir); err != nil { + return err + } if name == "" { name = id } - archive, modelFile, err := packDirectory(srcDir, entryFile) + archive, source, err := buildDeployArchive(srcDir, containerDir, baseImage, requirements, args) if err != nil { return err } @@ -108,20 +208,13 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`, spin := cmdutil.NewSpinner(fmt.Sprintf("Uploading source for %s...", id)) spin.Start() - sourceID, err := uploadSource(cmd.Context(), client, archive) + sourceID, err := uploadSource(cmd.Context(), client, archive, source.sourceType) spin.Stop() if err != nil { return err } - source, err := serverlessapi.NewCodeAppSource(serverlessapi.CodeSourceUpsert{ - BaseImage: baseImage, - Codebase: serverlessapi.CodebaseSource{ - SourceId: sourceID, - ModelFile: modelFile, - }, - Requirements: optionalStringSlice(requirements), - }) + appSource, err := source.appSource(sourceID) if err != nil { return fmt.Errorf("build application source: %w", err) } @@ -129,7 +222,7 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`, body := serverlessapi.AppCreate{ AppId: id, AppName: name, - AppSource: source, + AppSource: appSource, Volumes: appVolumes, EnvironmentVariables: appEnv, Configuration: serverlessapi.WorkerConfigCreate{ @@ -156,7 +249,8 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`, }, } - cmd.Flags().StringVar(&srcDir, "src-dir", "", "Directory to package as the application source (default: the working directory)") + cmd.Flags().StringVar(&srcDir, "src-dir", "", "Directory to package as the application source (default: the working directory; code deploys only)") + cmd.Flags().StringVar(&containerDir, "container", "", "Directory whose root contains Dockerfile and container.yaml") cmd.Flags().StringArrayVar(&volumes, "volume", nil, "Absolute path inside the app backed by persistent node-local storage (repeatable)") cmd.Flags().StringArrayVar(&envVars, "env", nil, "Environment variable as KEY=VALUE (repeatable)") cmd.Flags().StringArrayVar(&envFiles, "env-file", nil, "File of KEY=VALUE lines to read environment variables from (repeatable)") @@ -165,9 +259,9 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`, cmd.Flags().Int32Var(&maxWorkers, "max-workers", 1, "Maximum number of workers") cmd.Flags().Int32Var(&idleTTL, "idle-ttl", 60, "Idle TTL in seconds before scaling down") cmd.Flags().Int32Var(&scalingDelay, "scaling-delay", 10, "Scaling delay in seconds") - cmd.Flags().StringVar(&baseImage, "base-image", "python:3.11-slim", "Builder base image") + cmd.Flags().StringVar(&baseImage, "base-image", "python:3.11-slim", "Builder base image (code deploys only)") cmd.Flags().StringVar(&gpuType, "gpu-type", "", "GPU type ID (see 'serverless gpus')") - cmd.Flags().StringArrayVar(&requirements, "requirement", nil, "Additional pip package to install (repeatable)") + cmd.Flags().StringArrayVar(&requirements, "requirement", nil, "Additional pip package to install (repeatable; code deploys only)") cmd.Flags().Int32Var(&minWorkers, "min-workers", 0, "Minimum number of workers") cmd.Flags().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker") diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go new file mode 100644 index 0000000..a7bfd2c --- /dev/null +++ b/internal/cmd/serverless/deploy_test.go @@ -0,0 +1,193 @@ +package serverless + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/google/uuid" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" +) + +const ( + testContainerFlag = "--container" + testWrapperDir = "./wrapper" + testPipPackage = "torch" + testSourceID = "019c7654-8b21-7abc-9123-abcdef123456" +) + +func TestValidateDeployArgs(t *testing.T) { + cases := []struct { + name string + args []string + flags []string + wantErr string + }{ + { + name: "file only", + args: []string{testModelFile}, + }, + { + name: "container only", + flags: []string{testContainerFlag, testWrapperDir}, + }, + { + name: "neither", + wantErr: deploySourceChoice, + }, + { + name: "both", + args: []string{testModelFile}, + flags: []string{testContainerFlag, testWrapperDir}, + wantErr: "not both", + }, + { + name: "container with src-dir", + flags: []string{testContainerFlag, testWrapperDir, "--src-dir", "."}, + wantErr: "--src-dir", + }, + { + name: "container with base-image", + flags: []string{testContainerFlag, testWrapperDir, "--base-image", "python:3.12-slim"}, + wantErr: "--base-image", + }, + { + name: "container with requirement", + flags: []string{testContainerFlag, testWrapperDir, "--requirement", testPipPackage}, + wantErr: "--requirement", + }, + { + name: "code with src-dir", + args: []string{testModelFile}, + flags: []string{"--src-dir", "."}, + }, + { + name: "code with default base-image is allowed", + args: []string{testModelFile}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := newDeployCmd(nil) + if err := cmd.ParseFlags(tc.flags); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + containerDir, err := cmd.Flags().GetString("container") + if err != nil { + t.Fatalf("container: %v", err) + } + err = validateDeployArgs(cmd, tc.args, containerDir) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err, tc.wantErr) + } + }) + } +} + +func TestBuildDeployArchive_Code(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + testModelFile: testPySource, + }) + + archive, source, err := buildDeployArchive(dir, "", "python:3.11-slim", []string{testPipPackage}, []string{testModelFile}) + if err != nil { + t.Fatalf("buildDeployArchive: %v", err) + } + if source.sourceType != serverlessapi.AppSourceTypeCode { + t.Errorf("sourceType = %q, want code", source.sourceType) + } + if len(archive) == 0 { + t.Fatal("empty archive") + } + + id := uuid.MustParse(testSourceID) + appSource, err := source.appSource(id) + if err != nil { + t.Fatalf("appSource: %v", err) + } + if appSource.Type != serverlessapi.AppSourceTypeCode { + t.Errorf("type = %q, want code", appSource.Type) + } + inner, err := appSource.Source.AsCodeSourceUpsert() + if err != nil { + t.Fatalf("AsCodeSourceUpsert: %v", err) + } + if inner.Codebase.SourceId != id || inner.Codebase.ModelFile != testModelFile { + t.Errorf("codebase = %+v", inner.Codebase) + } + if inner.Requirements == nil || len(*inner.Requirements) != 1 || (*inner.Requirements)[0] != testPipPackage { + t.Errorf("requirements = %v, want [%s]", inner.Requirements, testPipPackage) + } +} + +func TestBuildDeployArchive_Container(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + containerDockerfile: testDockerfile, + containerConfig: testContainer, + }) + + archive, source, err := buildDeployArchive("", dir, "python:3.11-slim", []string{testPipPackage}, nil) + if err != nil { + t.Fatalf("buildDeployArchive: %v", err) + } + if source.sourceType != serverlessapi.AppSourceTypeContainer { + t.Errorf("sourceType = %q, want container", source.sourceType) + } + if len(archive) == 0 { + t.Fatal("empty archive") + } + + id := uuid.MustParse(testSourceID) + appSource, err := source.appSource(id) + if err != nil { + t.Fatalf("appSource: %v", err) + } + if appSource.Type != serverlessapi.AppSourceTypeContainer { + t.Errorf("type = %q, want container", appSource.Type) + } + inner, err := appSource.Source.AsContainerSource() + if err != nil { + t.Fatalf("AsContainerSource: %v", err) + } + if inner.SourceId != id { + t.Errorf("sourceId = %s, want %s", inner.SourceId, id) + } + + raw, err := json.Marshal(appSource) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), "baseImage") || strings.Contains(string(raw), "modelFile") { + t.Errorf("container source leaked code fields: %s", raw) + } +} + +func TestDeploySource_UnsupportedType(t *testing.T) { + var source deploySource + _, err := source.appSource(uuid.MustParse(testSourceID)) + if err == nil { + t.Fatal("expected an error for an empty source type") + } +} + +func TestNewDeployCmd_RegistersContainerFlag(t *testing.T) { + cmd := newDeployCmd(nil) + if cmd.Flags().Lookup("container") == nil { + t.Fatal("deploy is missing --container") + } + if cmd.Use != "deploy [file]" { + t.Errorf("Use = %q, want deploy [file]", cmd.Use) + } +} diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 5e9c293..7c59f1c 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -102,7 +102,7 @@ func packDirectory(srcDir, modelFile string) (archive []byte, modelFileRel strin return nil, "", err } - files, err := collectFiles(root, modelFileRel, matcher) + files, err := collectFiles(root, []string{modelFileRel}, matcher) if err != nil { return nil, "", err } @@ -120,6 +120,55 @@ func packDirectory(srcDir, modelFile string) (archive []byte, modelFileRel strin return raw, modelFileRel, nil } +const ( + containerDockerfile = "Dockerfile" + containerConfig = "container.yaml" +) + +// packContainerDirectory zips a container source directory. The root must +// contain Dockerfile and container.yaml; both are packed even if an ignore +// rule would otherwise exclude them. +func packContainerDirectory(srcDir string) ([]byte, error) { + root, err := resolveSrcDir(srcDir) + if err != nil { + return nil, err + } + if err := requireContainerFiles(root); err != nil { + return nil, err + } + + matcher, err := loadIgnoreMatcher(root) + if err != nil { + return nil, err + } + + files, err := collectFiles(root, []string{containerDockerfile, containerConfig}, matcher) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("no files to pack under %q", root) + } + + return writeArchive(root, files) +} + +func requireContainerFiles(root string) error { + for _, name := range []string{containerDockerfile, containerConfig} { + info, err := os.Stat(filepath.Join(root, name)) + if err != nil { + return fmt.Errorf( + "read %s: %w (a container source directory must contain Dockerfile and container.yaml at its root)", + name, err, + ) + } + if info.IsDir() { + return fmt.Errorf("%s is a directory; the container source root must contain that file", name) + } + } + return nil +} + // resolveSrcDir defaults an empty srcDir to the working directory and checks it // is a directory. Symlinks are resolved so the relative path of the model file // is computed against the same tree WalkDir will produce. @@ -258,9 +307,10 @@ type packedFile struct { // collectFiles walks the tree and returns what belongs in the archive, in the // lexical order WalkDir yields — so the same tree always packs to the same bytes. -func collectFiles(root, modelFileRel string, matcher gitignore.Matcher) ([]packedFile, error) { +func collectFiles(root string, required []string, matcher gitignore.Matcher) ([]packedFile, error) { var files []packedFile var total int64 + mustPack := requiredSet(required) err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -283,22 +333,22 @@ func collectFiles(root, modelFileRel string, matcher gitignore.Matcher) ([]packe } return nil } - // The model file is packed whatever the rules say: the deploy cannot - // succeed without it, and an ignore rule that happens to cover it is a - // worse failure than a file the customer did not mean to ship. - if rel != modelFileRel && matcher.Match(segments, d.IsDir()) { + // Required files are packed whatever the rules say: the deploy cannot + // succeed without them, and an ignore rule that happens to cover one is + // a worse failure than a file the customer did not mean to ship. + if !isRequired(rel, mustPack) && matcher.Match(segments, d.IsDir()) { if d.IsDir() { // An excluded directory is pruned rather than walked, which keeps // a .venv from costing a stat per file and reproduces git's own // rule that a negation cannot re-include a file whose parent // directory is excluded. // - // Except when the model file is inside it. Pruning there would + // Except when a required file is inside it. Pruning there would // drop the one entry the build cannot proceed without, and the // exemption above never fires because the walk stops at the - // directory, whose path is not the model file's. Descend, and let - // the per-file checks exclude everything else it holds. - if isAncestorOf(rel, modelFileRel) { + // directory, whose path is not the required file's. Descend, and + // let the per-file checks exclude everything else it holds. + if isRequiredAncestor(rel, mustPack) { return nil } return fs.SkipDir @@ -315,9 +365,9 @@ func collectFiles(root, modelFileRel string, matcher gitignore.Matcher) ([]packe // Silently skipping the model file would upload an archive whose // declared entry point is missing, which the builder can only report // as a 422 after the upload. Say it here instead. - if rel == modelFileRel { + if isRequired(rel, mustPack) { return fmt.Errorf( - "model file %s is a %s, not a regular file; point --src-dir at the directory holding the real file", + "%s is a %s, not a regular file; point the source directory at the directory holding the real file", rel, d.Type().String(), ) } @@ -357,6 +407,28 @@ func isAncestorOf(dir, file string) bool { return strings.HasPrefix(file, dir+"/") } +func requiredSet(files []string) map[string]struct{} { + out := make(map[string]struct{}, len(files)) + for _, f := range files { + out[f] = struct{}{} + } + return out +} + +func isRequired(rel string, required map[string]struct{}) bool { + _, ok := required[rel] + return ok +} + +func isRequiredAncestor(dir string, required map[string]struct{}) bool { + for rel := range required { + if isAncestorOf(dir, rel) { + return true + } + } + return false +} + // largestFilesSummary names what filled the archive. "Too big" on its own leaves // the caller to find the offender by hand, which for a deep tree is the whole // problem rather than a detail of it. diff --git a/internal/cmd/serverless/pack_test.go b/internal/cmd/serverless/pack_test.go index 44b108a..6782098 100644 --- a/internal/cmd/serverless/pack_test.go +++ b/internal/cmd/serverless/pack_test.go @@ -686,3 +686,111 @@ func TestPackDirectory_IgnorePatternsAreNotTrimmed(t *testing.T) { t.Errorf("an unrelated file with a space was dropped; archive = %v", names(packed)) } } + +const ( + testDockerfile = "FROM python:3.11-slim\n" + testContainer = "name: wrapper\n" +) + +func TestPackContainerDirectory(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + containerDockerfile: testDockerfile, + containerConfig: testContainer, + "context/weights.bin": "weights", + }) + + encoded, err := packContainerDirectory(dir) + if err != nil { + t.Fatalf("packContainerDirectory: %v", err) + } + packed := unpack(t, encoded) + if packed[containerDockerfile] != testDockerfile { + t.Errorf("Dockerfile = %q", packed[containerDockerfile]) + } + if packed[containerConfig] != testContainer { + t.Errorf("container.yaml = %q", packed[containerConfig]) + } + if packed["context/weights.bin"] != "weights" { + t.Errorf("build context missing; archive = %v", names(packed)) + } +} + +func TestPackContainerDirectory_RequiredFilesAlwaysPacked(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + runwareIgnoreFile: "Dockerfile\ncontainer.yaml\n*.md\n", + containerDockerfile: testDockerfile, + containerConfig: testContainer, + "notes.md": "drop", + "keep.txt": "keep", + }) + + encoded, err := packContainerDirectory(dir) + if err != nil { + t.Fatalf("packContainerDirectory: %v", err) + } + packed := unpack(t, encoded) + for _, name := range []string{containerDockerfile, containerConfig, "keep.txt"} { + if _, ok := packed[name]; !ok { + t.Errorf("%s missing from archive; got %v", name, names(packed)) + } + } + if _, ok := packed["notes.md"]; ok { + t.Errorf("notes.md should have been ignored; archive = %v", names(packed)) + } +} + +func TestPackContainerDirectory_MissingRequiredFile(t *testing.T) { + cases := []struct { + name string + files map[string]string + want string + }{ + { + name: "missing Dockerfile", + files: map[string]string{ + containerConfig: testContainer, + }, + want: containerDockerfile, + }, + { + name: "missing container.yaml", + files: map[string]string{ + containerDockerfile: testDockerfile, + }, + want: containerConfig, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, tc.files) + _, err := packContainerDirectory(dir) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q does not mention %s", err, tc.want) + } + }) + } +} + +func TestPackContainerDirectory_RequiredFileIsDirectory(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + containerConfig: testContainer, + }) + if err := os.Mkdir(filepath.Join(dir, containerDockerfile), 0o750); err != nil { + t.Fatal(err) + } + + _, err := packContainerDirectory(dir) + if err == nil { + t.Fatal("expected an error for a directory named Dockerfile") + } + if !strings.Contains(err.Error(), containerDockerfile) { + t.Errorf("error %q does not mention Dockerfile", err) + } +} diff --git a/internal/cmd/serverless/upload.go b/internal/cmd/serverless/upload.go index 39f71c3..508ddb2 100644 --- a/internal/cmd/serverless/upload.go +++ b/internal/cmd/serverless/upload.go @@ -18,7 +18,7 @@ import ( // the API names, and completion is what opens the archive and verifies it // against the declaration. Only then does the published source mean anything to // a create. -func uploadSource(ctx context.Context, client *serverlessapi.Client, archive []byte) (uuid.UUID, error) { +func uploadSource(ctx context.Context, client *serverlessapi.Client, archive []byte, sourceType serverlessapi.AppSourceType) (uuid.UUID, error) { digest := sha256.Sum256(archive) created, err := client.CreateSourceUpload(ctx, serverlessapi.SourceUploadCreate{ @@ -29,7 +29,7 @@ func uploadSource(ctx context.Context, client *serverlessapi.Client, archive []b // could never be deployed again. IdempotencyKey: uuid.NewString(), Sha256: hex.EncodeToString(digest[:]), - SourceType: serverlessapi.AppSourceTypeCode, + SourceType: sourceType, }) if err != nil { return uuid.Nil, err diff --git a/internal/cmd/serverless/upload_test.go b/internal/cmd/serverless/upload_test.go index 4ebe59a..73a1cfc 100644 --- a/internal/cmd/serverless/upload_test.go +++ b/internal/cmd/serverless/upload_test.go @@ -100,7 +100,7 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) { defer api.Close() client := serverlessapi.NewClient("test-key", api.URL, slog.Default()) - id, err := uploadSource(context.Background(), client, archive) + id, err := uploadSource(context.Background(), client, archive, serverlessapi.AppSourceTypeCode) if err != nil { t.Fatalf("uploadSource: %v", err) } @@ -173,7 +173,7 @@ func TestUploadSource_AbortsTheSessionWhenStagingFails(t *testing.T) { defer api.Close() client := serverlessapi.NewClient("test-key", api.URL, slog.Default()) - if _, err := uploadSource(context.Background(), client, []byte("zip")); err == nil { + if _, err := uploadSource(context.Background(), client, []byte("zip"), serverlessapi.AppSourceTypeCode); err == nil { t.Fatal("uploadSource succeeded despite a refused transfer") } if !aborted { @@ -230,7 +230,7 @@ func TestUploadSource_ReportsARejectedArchive(t *testing.T) { defer api.Close() client := serverlessapi.NewClient("test-key", api.URL, slog.Default()) - _, err := uploadSource(context.Background(), client, []byte("zip")) + _, err := uploadSource(context.Background(), client, []byte("zip"), serverlessapi.AppSourceTypeCode) if err == nil { t.Fatal("uploadSource accepted a rejected archive") } @@ -298,7 +298,7 @@ func TestUploadSource_UsesAFreshKeyPerInvocation(t *testing.T) { client := serverlessapi.NewClient("test-key", api.URL, slog.Default()) for range 2 { - if _, err := uploadSource(context.Background(), client, archive); err != nil { + if _, err := uploadSource(context.Background(), client, archive, serverlessapi.AppSourceTypeCode); err != nil { t.Fatalf("uploadSource: %v", err) } } @@ -310,3 +310,26 @@ func TestUploadSource_UsesAFreshKeyPerInvocation(t *testing.T) { t.Errorf("both deploys sent idempotency key %q; the second would 409 on a consumed session", keys[0]) } } + +// TestUploadSource_DeclaresContainerSourceType is the container half of the +// declaration: completion and create both key off sourceType, so a container +// archive uploaded as code would be validated against the wrong rules. +func TestUploadSource_DeclaresContainerSourceType(t *testing.T) { + var declaration serverlessapi.SourceUploadCreate + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&declaration); err != nil { + t.Fatalf("decode declaration: %v", err) + } + w.WriteHeader(http.StatusBadRequest) + })) + defer api.Close() + + client := serverlessapi.NewClient("test-key", api.URL, slog.Default()) + _, err := uploadSource(context.Background(), client, []byte("zip"), serverlessapi.AppSourceTypeContainer) + if err == nil { + t.Fatal("uploadSource succeeded against a refused declaration") + } + if declaration.SourceType != serverlessapi.AppSourceTypeContainer { + t.Errorf("declared sourceType = %q, want container", declaration.SourceType) + } +}