From 47d80c3bc8f1781bb1c2f34b4c28bcb200b0899e Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 25 Sep 2026 22:58:22 +0100 Subject: [PATCH 1/3] feat(serverless): add apps rename for the display name PATCH already accepts appName, but deploy --name stayed create-only with no follow-up command. Rename updates the name in place and does not pin or roll workers. --- README.md | 5 +- docs/runware_serverless_apps.md | 1 + docs/runware_serverless_apps_rename.md | 41 ++++++++++ docs/runware_serverless_deploy.md | 5 +- internal/cmd/serverless/apps.go | 1 + internal/cmd/serverless/apps_rename.go | 57 +++++++++++++ internal/cmd/serverless/apps_rename_test.go | 89 +++++++++++++++++++++ internal/cmd/serverless/deploy.go | 7 +- internal/cmd/serverless/deploy_test.go | 2 +- 9 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 docs/runware_serverless_apps_rename.md create mode 100644 internal/cmd/serverless/apps_rename.go create mode 100644 internal/cmd/serverless/apps_rename_test.go diff --git a/README.md b/README.md index a4e764a..3f19689 100644 --- a/README.md +++ b/README.md @@ -163,11 +163,12 @@ runware serverless apps events my-app runware serverless usage --for this-month --group-by app,day runware serverless apps usage my-app --for this-month -# Environment, secrets, and scale +# Environment, secrets, scale, and rename runware serverless apps env set my-app MY_KEY --value-file ./value.txt runware serverless secrets set FOO --value-file ./foo.txt runware serverless secrets attach my-app FOO runware serverless apps scale my-app --max-workers 2 +runware serverless apps rename my-app "Image generator" # Lifecycle runware serverless apps stop my-app @@ -175,7 +176,7 @@ runware serverless apps resume my-app runware serverless open my-app ``` -`--volume`, `--env`, `--env-file`, and `--name` apply on create only. Change `--gpu-type` and other worker settings with `apps scale`, and environment with `apps env`. An environment change or a secret attach, detach, or rotation records a new version (or rolls live workers) — you do not need to redeploy source to apply them. +`--volume`, `--env`, `--env-file`, and `--name` apply on create only. Change `--gpu-type` and other worker settings with `apps scale`, environment with `apps env`, and the display name with `apps rename`. An environment change or a secret attach, detach, or rotation records a new version (or rolls live workers) — you do not need to redeploy source to apply them. `.env` files are never packed into the source archive. Prefer `--env-file` and `secrets set --value-file` so values stay out of the process list. diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 2a2c60b..878b975 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -36,6 +36,7 @@ runware serverless apps [flags] * [runware serverless apps invoke](runware_serverless_apps_invoke.md) - Invoke an application endpoint * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications * [runware serverless apps logs](runware_serverless_apps_logs.md) - Show or follow logs for a serverless application +* [runware serverless apps rename](runware_serverless_apps_rename.md) - Rename a serverless application * [runware serverless apps resume](runware_serverless_apps_resume.md) - Resume a stopped serverless application * [runware serverless apps scale](runware_serverless_apps_scale.md) - Scale a serverless application * [runware serverless apps show](runware_serverless_apps_show.md) - Show details for a serverless application diff --git a/docs/runware_serverless_apps_rename.md b/docs/runware_serverless_apps_rename.md new file mode 100644 index 0000000..e4d5e9c --- /dev/null +++ b/docs/runware_serverless_apps_rename.md @@ -0,0 +1,41 @@ +## runware serverless apps rename + +Rename a serverless application + +### Synopsis + +Change the display name of a serverless application. + +The application ID is immutable. A name-only update records a version and does +not pin or roll workers. deploy --name still applies on create only. + +``` +runware serverless apps rename [flags] +``` + +### Examples + +``` + # rename an application + runware serverless apps rename my-app "Image generator" +``` + +### Options + +``` + -h, --help help for rename +``` + +### 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](runware_serverless_apps.md) - Manage deployed serverless applications + diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index eac3cb1..f2607e0 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -11,8 +11,9 @@ 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, --secret, --env, --env-file, --name) apply only to create; passing them when the application already exists is an error. Change workers with 'apps scale', -attach a secret later with 'secrets attach', and change environment with -'apps env'. A source update on a stopped application is 409. +attach a secret later with 'secrets attach', change environment with +'apps env', and the display name with 'apps rename'. 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 diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index ec83d59..2ffa5ba 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -33,6 +33,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsEventsCmd(logger), newAppsWorkersCmd(logger), newAppsScaleCmd(logger), + newAppsRenameCmd(logger), newAppsUsageCmd(logger), newAppsStopCmd(logger), newAppsResumeCmd(logger), diff --git a/internal/cmd/serverless/apps_rename.go b/internal/cmd/serverless/apps_rename.go new file mode 100644 index 0000000..d6bc669 --- /dev/null +++ b/internal/cmd/serverless/apps_rename.go @@ -0,0 +1,57 @@ +package serverless + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +func newAppsRenameCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "rename ", + Short: "Rename a serverless application", + Long: `Change the display name of a serverless application. + +The application ID is immutable. A name-only update records a version and does +not pin or roll workers. deploy --name still applies on create only.`, + Example: ` # rename an application + runware serverless apps rename my-app "Image generator"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + name := args[1] + if err := validateAppName(name); err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Renaming application %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + app, err := client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{ + AppName: &name, + }) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + }, + } +} + +func validateAppName(name string) error { + if name == "" || strings.TrimSpace(name) != name { + return fmt.Errorf("name must start and end with a non-whitespace character") + } + return nil +} diff --git a/internal/cmd/serverless/apps_rename_test.go b/internal/cmd/serverless/apps_rename_test.go new file mode 100644 index 0000000..fa1dac3 --- /dev/null +++ b/internal/cmd/serverless/apps_rename_test.go @@ -0,0 +1,89 @@ +package serverless + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" +) + +func TestValidateAppName(t *testing.T) { + cases := []struct { + name string + wantErr bool + }{ + {name: "Image generator"}, + {name: "A"}, + {name: "", wantErr: true}, + {name: " padded", wantErr: true}, + {name: "padded ", wantErr: true}, + {name: " ", wantErr: true}, + } + for _, tc := range cases { + err := validateAppName(tc.name) + if tc.wantErr && err == nil { + t.Errorf("%q: expected error", tc.name) + } + if !tc.wantErr && err != nil { + t.Errorf("%q: %v", tc.name, err) + } + } +} + +func TestAppsRename_PatchesNameOnly(t *testing.T) { + const wantName = "Image generator" + var patches int + 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) + w.WriteHeader(http.StatusNotFound) + return + } + patches++ + var body serverlessapi.AppUpdate + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode: %v", err) + return + } + if body.AppName == nil || *body.AppName != wantName { + t.Errorf("appName = %v", body.AppName) + } + if body.AppSource != nil || body.Configuration != nil || body.Secrets != nil || body.EnvironmentVariables != nil { + t.Errorf("patch included out-of-scope fields") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(strings.Replace(activeAppBody(""), `"appName":"My App"`, `"appName":"`+wantName+`"`, 1))) + })) + defer srv.Close() + + t.Setenv("RUNWARE_API_KEY", "test-key") + t.Setenv("RUNWARE_SERVERLESS_BASE_URL", srv.URL) + + cmd := newAppsRenameCmd(log.New(io.Discard)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testAppID, wantName}) + if err := cmd.Execute(); err != nil { + t.Fatalf("rename: %v", err) + } + if patches != 1 { + t.Fatalf("patches = %d, want 1", patches) + } +} + +func TestAppsRename_RejectsPaddedName(t *testing.T) { + cmd := newAppsRenameCmd(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testAppID, " padded"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "non-whitespace") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 3876639..874059f 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -150,8 +150,9 @@ 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, --secret, --env, --env-file, --name) apply only to create; passing them when the application already exists is an error. Change workers with 'apps scale', -attach a secret later with 'secrets attach', and change environment with -'apps env'. A source update on a stopped application is 409. +attach a secret later with 'secrets attach', change environment with +'apps env', and the display name with 'apps rename'. 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 @@ -522,6 +523,8 @@ func createOnlyDeployHint(name string) string { return "use 'runware serverless secrets attach' to attach a secret" case "volume": return "volumes are immutable after create" + case "name": + return "use 'runware serverless apps rename' to change the display name" default: return "omit it when updating an existing application" } diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index c6ad94f..5ac910e 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -438,7 +438,7 @@ func TestValidateUpdateDeployFlags(t *testing.T) { {flags: []string{"--fallback-gpu-type", "l40s"}, wantErr: scaleHint}, {flags: []string{"--min-available-workers", "1"}, wantErr: scaleHint}, {flags: []string{"--available-workers-pct", "10"}, wantErr: scaleHint}, - {flags: []string{"--name", "My App"}, wantErr: "omit it"}, + {flags: []string{"--name", "My App"}, wantErr: "apps rename"}, {flags: []string{"--requirement", testPipPackage}}, {flags: []string{"--base-image", "python:3.12-slim"}}, {flags: []string{testSrcDirFlag, "."}}, From e34ee4a2b4c1327e9031f439f25082bdb0c31586 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Mon, 28 Sep 2026 09:48:32 +0100 Subject: [PATCH 2/3] fix(serverless): reject a padded deploy name before upload --- internal/cmd/serverless/apps_rename_test.go | 23 ++++++++++++++++++--- internal/cmd/serverless/deploy.go | 5 +++++ internal/cmd/serverless/deploy_test.go | 11 ++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/cmd/serverless/apps_rename_test.go b/internal/cmd/serverless/apps_rename_test.go index fa1dac3..904bb5c 100644 --- a/internal/cmd/serverless/apps_rename_test.go +++ b/internal/cmd/serverless/apps_rename_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -65,16 +66,32 @@ func TestAppsRename_PatchesNameOnly(t *testing.T) { t.Setenv("RUNWARE_API_KEY", "test-key") t.Setenv("RUNWARE_SERVERLESS_BASE_URL", srv.URL) + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + cmd := newAppsRenameCmd(log.New(io.Discard)) - cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) cmd.SetArgs([]string{testAppID, wantName}) - if err := cmd.Execute(); err != nil { - t.Fatalf("rename: %v", err) + execErr := cmd.Execute() + + _ = w.Close() + os.Stdout = old + var out bytes.Buffer + _, _ = out.ReadFrom(r) + + if execErr != nil { + t.Fatalf("rename: %v", execErr) } if patches != 1 { t.Fatalf("patches = %d, want 1", patches) } + if !strings.Contains(out.String(), wantName) { + t.Fatalf("stdout = %q, want it to contain %q", out.String(), wantName) + } } func TestAppsRename_RejectsPaddedName(t *testing.T) { diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 874059f..f7d8301 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -258,6 +258,11 @@ paths and an invoke example once the application is active.`, return err } } + if cmd.Flags().Changed("name") { + if err := validateAppName(name); err != nil { + return err + } + } if name == "" { name = id } diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 5ac910e..c0300e0 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -271,6 +271,17 @@ func TestValidateGPUsPerWorker(t *testing.T) { } } +func TestDeploy_RejectsPaddedNameBeforeUpload(t *testing.T) { + cmd := newDeployCmd(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--name", " padded"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "non-whitespace") { + t.Fatalf("err = %v", err) + } +} + func TestDeploy_RejectsInvalidGPUsPerWorkerBeforeUpload(t *testing.T) { cmd := newDeployCmd(nil) cmd.SetOut(&bytes.Buffer{}) From bdbf86f09e05fd335cfb01faeb30ca37a0b71c80 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Mon, 28 Sep 2026 10:04:01 +0100 Subject: [PATCH 3/3] test: share the --id flag literal --- internal/cmd/serverless/deploy_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index c0300e0..8387e8a 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -24,6 +24,7 @@ const ( testPipPackage = "torch" testSourceID = "019c7654-8b21-7abc-9123-abcdef123456" testSrcDirFlag = "--src-dir" + testIDFlag = "--id" ) func TestValidateDeployArgs(t *testing.T) { @@ -275,7 +276,7 @@ func TestDeploy_RejectsPaddedNameBeforeUpload(t *testing.T) { cmd := newDeployCmd(nil) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--name", " padded"}) + cmd.SetArgs([]string{testModelFile, testIDFlag, testAppID, "--name", " padded"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "non-whitespace") { t.Fatalf("err = %v", err) @@ -286,7 +287,7 @@ func TestDeploy_RejectsInvalidGPUsPerWorkerBeforeUpload(t *testing.T) { cmd := newDeployCmd(nil) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--gpus-per-worker", "3"}) + cmd.SetArgs([]string{testModelFile, testIDFlag, testAppID, "--gpus-per-worker", "3"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), gpusPerWorkerValuesText()) { t.Fatalf("err = %v", err) @@ -311,7 +312,7 @@ func TestDeployCreate_RejectsAvailableWorkersPctBeforeUpload(t *testing.T) { cmd := newDeployCmd(nil) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--available-workers-pct", "101"}) + cmd.SetArgs([]string{testModelFile, testIDFlag, testAppID, "--available-workers-pct", "101"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "0 and 100") { t.Fatalf("err = %v", err) @@ -370,7 +371,7 @@ func TestDeployCreate_SendsSecretsAndMinAvailableWorkers(t *testing.T) { cmd.SetArgs([]string{ testModelFile, "--src-dir", dir, - "--id", testAppID, + testIDFlag, testAppID, testGPUTypeFlag, testGPUType, "--secret", "API_KEY=INFERENCE_KEY", "--min-available-workers", "1",