From 8d8283d787e5180b64e9336179fccae91e8a05e4 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Sun, 6 Sep 2026 10:48:08 +0100 Subject: [PATCH 1/2] feat(serverless): upsert source on deploy Create when --id is new and PATCH appSource when it already exists, rejecting create-only flags on update so leftover env or scale flags cannot apply. --- docs/runware_serverless.md | 2 +- docs/runware_serverless_deploy.md | 31 +++-- internal/api/serverless/client.go | 7 +- internal/api/serverless/client_test.go | 79 +++++++++++- internal/cmd/serverless/deploy.go | 165 +++++++++++++++++++------ internal/cmd/serverless/deploy_test.go | 110 ++++++++++++++++- 6 files changed, 337 insertions(+), 57 deletions(-) diff --git a/docs/runware_serverless.md b/docs/runware_serverless.md index 19c2b2b..abf9b16 100644 --- a/docs/runware_serverless.md +++ b/docs/runware_serverless.md @@ -25,7 +25,7 @@ Deploy, monitor, and manage Runware serverless applications on the platform * [runware](runware.md) - CLI tool for the Runware API * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications -* [runware serverless deploy](runware_serverless_deploy.md) - Deploy a new serverless application +* [runware serverless deploy](runware_serverless_deploy.md) - Create or update a serverless application * [runware serverless gpus](runware_serverless_gpus.md) - List available GPU types and pricing * [runware serverless open](runware_serverless_open.md) - Open an application in the Runware dashboard * [runware serverless secrets](runware_serverless_secrets.md) - Manage organisation secrets for serverless applications diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index 560478a..c940003 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -1,10 +1,17 @@ ## runware serverless deploy -Deploy a new serverless application +Create or update a serverless application ### Synopsis -Create a new serverless application from Python code or a container source. +Create or update a serverless application from Python code or a container source. + +A first deploy with a new --id creates the application. A later deploy with the +same --id uploads a new source, records version N+1, and rolls it when the +build is ready. Create-only flags (--gpu-type, worker settings, --volume, +--env, --env-file, --name) apply only to create; passing them when the +application already exists is an error. Change workers with 'apps scale' and +environment with 'apps env'. A source update on a stopped application is 409. 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 @@ -33,19 +40,20 @@ what a project keeps out of version control is a different question from what it ships. Either way .env files are never uploaded, and neither are .git, __pycache__, .venv, node_modules or the usual build and tool caches. -Environment variables must be supplied here with --env or --env-file. An app's -environment is frozen into the version this command creates, which is what the -worker is rendered from, so setting one afterwards with 'apps env set' stores it -without it ever reaching a pod. Prefer --env-file for anything secret: a value -passed as --env is visible in the process list and recorded in shell history. +Environment variables must be supplied at create with --env or --env-file. An +app's environment is frozen into the version this command creates, which is +what the worker is rendered from, so setting one afterwards with 'apps env set' +stores it without it ever reaching a pod. Prefer --env-file for anything secret: +a value passed as --env is visible in the process list and recorded in shell +history. Anything the app downloads at runtime belongs on a --volume. The app runs in a sandbox whose filesystem is part of the checkpointed state, so an unmounted 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. Endpoints are derived server-side from -the SDK (code) or from container.yaml (container). +Worker settings are supplied via flags on create. Endpoints are derived +server-side from the SDK (code) or from container.yaml (container). ``` runware serverless deploy [file] [flags] @@ -57,6 +65,9 @@ runware serverless deploy [file] [flags] # deploy the current directory, with app.py as the entry point runware serverless deploy ./app.py --id my-app --gpu-type h100 + # update source on an existing application + runware serverless deploy ./app.py --id my-app --wait + # deploy a project that lives elsewhere; app.py is resolved inside --src-dir runware serverless deploy app.py --src-dir ~/projects/my-app --id my-app --gpu-type h100 @@ -90,7 +101,7 @@ runware serverless deploy [file] [flags] --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') + --gpu-type string GPU type ID (see 'serverless gpus'; required when creating) --gpus-per-worker int32 GPUs allocated per worker (default 1) -h, --help help for deploy --id string Application ID (immutable, lowercase slug) diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index c51944d..52ea38c 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -419,7 +419,8 @@ func AppDeployTerminal(status AppStatus) bool { } // UpdateApp patches an app in place. Omitted fields are left unchanged. -// Currently persisted: appName and configuration. +// appSource starts a build and records version N+1; name and configuration +// do not replace source. func (c *Client) UpdateApp(ctx context.Context, appID string, body AppUpdate) (*App, error) { if c.apiKey == "" { return nil, transport.ErrNoAPIKey @@ -884,7 +885,7 @@ func (c *Client) GetWorker(ctx context.Context, appID string, workerID uuid.UUID } } -// NewCodeAppSource builds an appSource for a code-based create. +// NewCodeAppSource builds an appSource for a code-based create or update. func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { var source gen.AppSourceUpsert_Source if err := source.FromCodeSourceUpsert(src); err != nil { @@ -896,7 +897,7 @@ func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { }, nil } -// NewContainerAppSource builds an appSource for a container-based create. +// NewContainerAppSource builds an appSource for a container-based create or update. func NewContainerAppSource(src ContainerSource) (AppSourceUpsert, error) { var source gen.AppSourceUpsert_Source if err := source.FromContainerSource(src); err != nil { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index bff3fe5..da58785 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -29,6 +29,7 @@ const ( testCursorPage2 = "page-2" testCursorPage3 = "page-3" testStatusReady = "ready" + testModelFile = "model.py" ) func TestListGpuTypes(t *testing.T) { @@ -171,7 +172,7 @@ func TestCreateApp(t *testing.T) { BaseImage: "python:3.11-slim", Codebase: CodebaseSource{ SourceId: uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456"), - ModelFile: "model.py", + ModelFile: testModelFile, }, }) if err != nil { @@ -274,7 +275,7 @@ func TestNewCodeAppSource(t *testing.T) { BaseImage: "python:3.11-slim", Codebase: CodebaseSource{ SourceId: id, - ModelFile: "model.py", + ModelFile: testModelFile, }, }) if err != nil { @@ -287,7 +288,7 @@ func TestNewCodeAppSource(t *testing.T) { if err != nil { t.Fatalf("AsCodeSourceUpsert: %v", err) } - if inner.Codebase.SourceId != id || inner.Codebase.ModelFile != "model.py" { + if inner.Codebase.SourceId != id || inner.Codebase.ModelFile != testModelFile { t.Errorf("codebase = %+v", inner.Codebase) } } @@ -648,6 +649,78 @@ func TestUpdateApp(t *testing.T) { } } +func TestUpdateApp_AppSource(t *testing.T) { + sourceID := uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456") + appSource, err := NewCodeAppSource(CodeSourceUpsert{ + BaseImage: "python:3.11-slim", + Codebase: CodebaseSource{ + SourceId: sourceID, + ModelFile: testModelFile, + }, + }) + if err != nil { + t.Fatalf("NewCodeAppSource: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/v1/apps/"+testAppID { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var body AppUpdate + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.AppSource == nil { + t.Fatalf("missing appSource: %s", raw) + } + if body.AppName != nil || body.Configuration != nil || body.Secrets != nil || body.EnvironmentVariables != nil { + t.Errorf("patch included out-of-scope fields: %s", raw) + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawMap); err != nil { + t.Fatalf("decode raw map: %v", err) + } + if len(rawMap) != 1 { + t.Errorf("expected only appSource in body, got %s", raw) + } + if body.AppSource.Type != AppSourceTypeCode { + t.Errorf("appSource.type = %q, want code", body.AppSource.Type) + } + inner, err := body.AppSource.Source.AsCodeSourceUpsert() + if err != nil { + t.Fatalf("AsCodeSourceUpsert: %v", err) + } + if inner.Codebase.SourceId != sourceID || inner.Codebase.ModelFile != testModelFile { + t.Errorf("unexpected codebase: %+v", inner.Codebase) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "appId":"my-app", + "appName":"My App", + "status":"initializing", + "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.UpdateApp(context.Background(), testAppID, AppUpdate{AppSource: &appSource}) + if err != nil { + t.Fatalf("UpdateApp: %v", err) + } + if app.AppId != testAppID || app.Status != AppStatusInitializing { + t.Errorf("unexpected app: %+v", app) + } +} + func TestUpdateApp_Unprocessable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/problem+json") diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 823abab..0298501 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -23,6 +23,21 @@ var codeOnlyDeployFlags = []string{ "requirement", } +// createOnlyDeployFlags apply to CreateApp only. On an existing app they are +// rejected so a leftover create invocation cannot wipe env or silently no-op. +var createOnlyDeployFlags = []string{ + "gpu-type", + "name", + "max-workers", + "idle-ttl", + "scaling-delay", + "min-workers", + "gpus-per-worker", + "volume", + "env", + "env-file", +} + // deploySource is the packed archive's type plus the fields CreateApp needs // once the upload publishes a sourceId. type deploySource struct { @@ -118,8 +133,15 @@ func newDeployCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [file]", - Short: "Deploy a new serverless application", - Long: `Create a new serverless application from Python code or a container source. + Short: "Create or update a serverless application", + Long: `Create or update a serverless application from Python code or a container source. + +A first deploy with a new --id creates the application. A later deploy with the +same --id uploads a new source, records version N+1, and rolls it when the +build is ready. Create-only flags (--gpu-type, worker settings, --volume, +--env, --env-file, --name) apply only to create; passing them when the +application already exists is an error. Change workers with 'apps scale' and +environment with 'apps env'. A source update on a stopped application is 409. 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 @@ -148,22 +170,26 @@ what a project keeps out of version control is a different question from what it ships. Either way .env files are never uploaded, and neither are .git, __pycache__, .venv, node_modules or the usual build and tool caches. -Environment variables must be supplied here with --env or --env-file. An app's -environment is frozen into the version this command creates, which is what the -worker is rendered from, so setting one afterwards with 'apps env set' stores it -without it ever reaching a pod. Prefer --env-file for anything secret: a value -passed as --env is visible in the process list and recorded in shell history. +Environment variables must be supplied at create with --env or --env-file. An +app's environment is frozen into the version this command creates, which is +what the worker is rendered from, so setting one afterwards with 'apps env set' +stores it without it ever reaching a pod. Prefer --env-file for anything secret: +a value passed as --env is visible in the process list and recorded in shell +history. Anything the app downloads at runtime belongs on a --volume. The app runs in a sandbox whose filesystem is part of the checkpointed state, so an unmounted 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. Endpoints are derived server-side from -the SDK (code) or from container.yaml (container).`, +Worker settings are supplied via flags on create. 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 + # update source on an existing application + runware serverless deploy ./app.py --id my-app --wait + # deploy a project that lives elsewhere; app.py is resolved inside --src-dir runware serverless deploy app.py --src-dir ~/projects/my-app --id my-app --gpu-type h100 @@ -202,17 +228,34 @@ the SDK (code) or from container.yaml (container).`, return err } - appVolumes, err := buildVolumes(volumes) + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + + update, err := existingApp(cmd.Context(), client, id) if err != nil { return err } - - appEnv, err := buildEnvironmentVariables(envFiles, envVars) - if err != nil { + if update { + if err := validateUpdateDeployFlags(cmd); err != nil { + return err + } + } else if err := validateCreateDeployGPU(gpuType); err != nil { return err } - client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + var ( + appVolumes *[]serverlessapi.AppVolume + appEnv *map[string]string + ) + if !update { + appVolumes, err = buildVolumes(volumes) + if err != nil { + return err + } + appEnv, err = buildEnvironmentVariables(envFiles, envVars) + if err != nil { + return err + } + } spin := cmdutil.NewSpinner(fmt.Sprintf("Uploading source for %s...", id)) spin.Start() @@ -227,26 +270,37 @@ the SDK (code) or from container.yaml (container).`, return fmt.Errorf("build application source: %w", err) } - body := serverlessapi.AppCreate{ - AppId: id, - AppName: name, - AppSource: appSource, - Volumes: appVolumes, - EnvironmentVariables: appEnv, - Configuration: serverlessapi.WorkerConfigCreate{ - MaxWorkers: maxWorkers, - IdleTtlSecs: idleTTL, - ScalingDelaySecs: scalingDelay, - GpuType: gpuType, - MinWorkers: optionalInt32Ptr(cmd, "min-workers", minWorkers), - GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker), - }, + var app *serverlessapi.App + if update { + spin = cmdutil.NewSpinner(fmt.Sprintf("Updating application %s...", id)) + spin.Start() + app, err = client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{ + AppSource: &appSource, + }) + } else { + spin = cmdutil.NewSpinner(fmt.Sprintf("Creating application %s...", id)) + spin.Start() + app, err = client.CreateApp(cmd.Context(), serverlessapi.AppCreate{ + AppId: id, + AppName: name, + AppSource: appSource, + Volumes: appVolumes, + EnvironmentVariables: appEnv, + Configuration: serverlessapi.WorkerConfigCreate{ + MaxWorkers: maxWorkers, + IdleTtlSecs: idleTTL, + ScalingDelaySecs: scalingDelay, + GpuType: gpuType, + MinWorkers: optionalInt32Ptr(cmd, "min-workers", minWorkers), + GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker), + }, + }) + if isHTTPConflict(err) { + app, err = client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{ + AppSource: &appSource, + }) + } } - - spin = cmdutil.NewSpinner(fmt.Sprintf("Creating application %s...", id)) - spin.Start() - - app, err := client.CreateApp(cmd.Context(), body) if err != nil { spin.Stop() return err @@ -283,7 +337,7 @@ the SDK (code) or from container.yaml (container).`, 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 (code deploys only)") - cmd.Flags().StringVar(&gpuType, "gpu-type", "", "GPU type ID (see 'serverless gpus')") + cmd.Flags().StringVar(&gpuType, "gpu-type", "", "GPU type ID (see 'serverless gpus'; required when creating)") 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") @@ -293,13 +347,50 @@ the SDK (code) or from container.yaml (container).`, if err := cmd.MarkFlagRequired("id"); err != nil { panic(err) } - if err := cmd.MarkFlagRequired("gpu-type"); err != nil { - panic(err) - } return cmd } +func existingApp(ctx context.Context, client *serverlessapi.Client, id string) (bool, error) { + _, err := client.GetApp(ctx, id) + if err == nil { + return true, nil + } + if isHTTPNotFound(err) { + return false, nil + } + return false, err +} + +func validateCreateDeployGPU(gpuType string) error { + if gpuType == "" { + return fmt.Errorf("--gpu-type is required when creating an application") + } + return nil +} + +func validateUpdateDeployFlags(cmd *cobra.Command) error { + for _, name := range createOnlyDeployFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--%s applies to create only; %s", name, createOnlyDeployHint(name)) + } + } + return nil +} + +func createOnlyDeployHint(name string) string { + switch name { + case "gpu-type", "max-workers", "idle-ttl", "scaling-delay", "min-workers", "gpus-per-worker": + return "use 'runware serverless apps scale' to change worker configuration" + case "env", "env-file": + return "use 'runware serverless apps env' to change environment variables" + case "volume": + return "volumes are set at create time and cannot be changed here" + default: + return "omit it when updating an existing application" + } +} + func optionalStringSlice(vals []string) *[]string { if len(vals) == 0 { return nil diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 742fa1b..c50d4e9 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/spf13/cobra" ) const ( @@ -18,6 +19,7 @@ const ( testWrapperDir = "./wrapper" testPipPackage = "torch" testSourceID = "019c7654-8b21-7abc-9123-abcdef123456" + testSrcDirFlag = "--src-dir" ) func TestValidateDeployArgs(t *testing.T) { @@ -47,8 +49,8 @@ func TestValidateDeployArgs(t *testing.T) { }, { name: "container with src-dir", - flags: []string{testContainerFlag, testWrapperDir, "--src-dir", "."}, - wantErr: "--src-dir", + flags: []string{testContainerFlag, testWrapperDir, testSrcDirFlag, "."}, + wantErr: testSrcDirFlag, }, { name: "container with base-image", @@ -63,7 +65,7 @@ func TestValidateDeployArgs(t *testing.T) { { name: "code with src-dir", args: []string{testModelFile}, - flags: []string{"--src-dir", "."}, + flags: []string{testSrcDirFlag, "."}, }, { name: "code with default base-image is allowed", @@ -200,6 +202,108 @@ func TestNewDeployCmd_RegistersContainerFlag(t *testing.T) { if cmd.Use != "deploy [file]" { t.Errorf("Use = %q, want deploy [file]", cmd.Use) } + if cmd.Short != "Create or update a serverless application" { + t.Errorf("Short = %q", cmd.Short) + } + if vals := cmd.Flags().Lookup("gpu-type").Annotations[cobra.BashCompOneRequiredFlag]; len(vals) > 0 { + t.Fatal("gpu-type should not be cobra-required") + } +} + +func TestExistingApp(t *testing.T) { + missing := 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 'my-app' exists"}`)) + })) + defer missing.Close() + + ok, err := existingApp(context.Background(), serverlessapi.NewClient("test-key", missing.URL, slog.Default()), testAppID) + if err != nil || ok { + t.Fatalf("404: ok=%v err=%v", ok, err) + } + + found := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "appId":"my-app", + "appName":"My App", + "status":"active", + "configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"gracefulStopTtlSecs":120,"computeType":"gpu"}, + "environmentVariables":[], + "secrets":[], + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer found.Close() + + ok, err = existingApp(context.Background(), serverlessapi.NewClient("test-key", found.URL, slog.Default()), testAppID) + if err != nil || !ok { + t.Fatalf("200: ok=%v err=%v", ok, err) + } + + fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer fail.Close() + + if _, err := existingApp(context.Background(), serverlessapi.NewClient("test-key", fail.URL, slog.Default()), testAppID); err == nil { + t.Fatal("expected an error for 500") + } +} + +func TestValidateCreateDeployGPU(t *testing.T) { + if err := validateCreateDeployGPU(""); err == nil || !strings.Contains(err.Error(), "--gpu-type") { + t.Fatalf("empty: %v", err) + } + if err := validateCreateDeployGPU("h100"); err != nil { + t.Fatalf("h100: %v", err) + } +} + +func TestValidateUpdateDeployFlags(t *testing.T) { + cmd := newDeployCmd(nil) + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if err := validateUpdateDeployFlags(cmd); err != nil { + t.Fatalf("no create flags: %v", err) + } + + cases := []struct { + flags []string + wantErr string + }{ + {flags: []string{"--gpu-type", "h100"}, wantErr: "apps scale"}, + {flags: []string{"--max-workers", "2"}, wantErr: "apps scale"}, + {flags: []string{"--env", "FOO=bar"}, wantErr: "apps env"}, + {flags: []string{"--env-file", ".env"}, wantErr: "apps env"}, + {flags: []string{"--volume", "/data"}, wantErr: "volumes"}, + {flags: []string{"--name", "My App"}, wantErr: "omit it"}, + {flags: []string{"--requirement", testPipPackage}}, + {flags: []string{"--base-image", "python:3.12-slim"}}, + {flags: []string{testSrcDirFlag, "."}}, + {flags: []string{"--wait"}}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.flags, " "), func(t *testing.T) { + cmd := newDeployCmd(nil) + if err := cmd.ParseFlags(tc.flags); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + err := validateUpdateDeployFlags(cmd) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got %v, want substring %q", err, tc.wantErr) + } + }) + } } func TestAppFailedErr(t *testing.T) { From 8c025b5c4ab806406e6aa3a23286bbf5f725dedf Mon Sep 17 00:00:00 2001 From: ryank90 Date: Sun, 6 Sep 2026 10:53:47 +0100 Subject: [PATCH 2/2] test(serverless): name the dotenv basename for goconst CI golangci-lint v2.12 treats the fourth ".env" literal as a failure; share envDotfile across pack, env, and deploy tests. --- internal/cmd/serverless/deploy_test.go | 2 +- internal/cmd/serverless/env_test.go | 4 ++-- internal/cmd/serverless/pack.go | 6 +++++- internal/cmd/serverless/pack_test.go | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index c50d4e9..57a7433 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -278,7 +278,7 @@ func TestValidateUpdateDeployFlags(t *testing.T) { {flags: []string{"--gpu-type", "h100"}, wantErr: "apps scale"}, {flags: []string{"--max-workers", "2"}, wantErr: "apps scale"}, {flags: []string{"--env", "FOO=bar"}, wantErr: "apps env"}, - {flags: []string{"--env-file", ".env"}, wantErr: "apps env"}, + {flags: []string{"--env-file", envDotfile}, wantErr: "apps env"}, {flags: []string{"--volume", "/data"}, wantErr: "volumes"}, {flags: []string{"--name", "My App"}, wantErr: "omit it"}, {flags: []string{"--requirement", testPipPackage}}, diff --git a/internal/cmd/serverless/env_test.go b/internal/cmd/serverless/env_test.go index 899a823..c59fc43 100644 --- a/internal/cmd/serverless/env_test.go +++ b/internal/cmd/serverless/env_test.go @@ -185,7 +185,7 @@ func TestBuildEnvironmentVariables_StripsSurroundingQuotes(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".env") + path := filepath.Join(dir, envDotfile) if err := os.WriteFile(path, []byte(tc.line+"\n"), 0o600); err != nil { t.Fatal(err) } @@ -206,7 +206,7 @@ func TestBuildEnvironmentVariables_StripsSurroundingQuotes(t *testing.T) { // would silently rewrite a token or an indented value. func TestBuildEnvironmentVariables_PreservesValueWhitespace(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".env") + path := filepath.Join(dir, envDotfile) // A trailing space in the value, and a leading-space line that still parses. if err := os.WriteFile(path, []byte("PADDED=value \n INDENTED=x\n"), 0o600); err != nil { t.Fatal(err) diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 7c59f1c..7ef32be 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -34,6 +34,10 @@ const maxPackTotalBytes int64 = 25 << 20 // 25 MiB // this file. const runwareIgnoreFile = ".runwareignore" +// envDotfile is the dotenv basename. alwaysExcluded matches this name on every +// path segment, plus any sibling that starts with ".env.". +const envDotfile = ".env" + // defaultIgnorePatterns are excluded when no rule says otherwise. They are // ordinary gitignore patterns evaluated before the project's own, so a later // `!*.pyc` re-includes what one of the file patterns here excluded. The @@ -72,7 +76,7 @@ var defaultIgnorePatterns = []string{ // the other rules exclude. func alwaysExcluded(segments []string) bool { for _, s := range segments { - if s == ".git" || s == ".env" || strings.HasPrefix(s, ".env.") { + if s == ".git" || s == envDotfile || strings.HasPrefix(s, envDotfile+".") { return true } } diff --git a/internal/cmd/serverless/pack_test.go b/internal/cmd/serverless/pack_test.go index 6782098..834c0a2 100644 --- a/internal/cmd/serverless/pack_test.go +++ b/internal/cmd/serverless/pack_test.go @@ -269,7 +269,7 @@ func TestPackDirectory_NeverPacksEnvFiles(t *testing.T) { dir := t.TempDir() writeTree(t, dir, map[string]string{ testModelFile: "", - ".env": "SECRET=1", + envDotfile: "SECRET=1", ".env.local": "SECRET=2", ".env.production": "SECRET=3", "config/.env": "SECRET=4", @@ -289,7 +289,7 @@ func TestPackDirectory_NeverPacksEnvFiles(t *testing.T) { t.Errorf("%q carries an env secret into the archive", name) } } - for _, absent := range []string{".env", ".env.local", ".env.production", "config/.env", "deep/nest/.env.ci"} { + for _, absent := range []string{envDotfile, ".env.local", ".env.production", "config/.env", "deep/nest/.env.ci"} { if _, ok := packed[absent]; ok { t.Errorf("%q was packed despite the absolute exclusion; archive = %v", absent, names(packed)) }