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 78f314c..3396c3f 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..904bb5c --- /dev/null +++ b/internal/cmd/serverless/apps_rename_test.go @@ -0,0 +1,106 @@ +package serverless + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "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) + + 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.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testAppID, wantName}) + 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) { + 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 9dfb42c..ead86ab 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -151,8 +151,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 @@ -262,6 +263,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 } @@ -534,6 +540,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 12ba48e..05ae716 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -277,6 +277,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, testIDFlag, 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{}) @@ -444,7 +455,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, "."}},