From 4f2ebab7b029950df386c757e579007462ea79b2 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Sun, 6 Sep 2026 09:34:43 +0100 Subject: [PATCH 1/2] feat(serverless): wire versions delete and builds delete Unused versions and leftover builds can now be removed through the control-plane delete routes, with the same confirmation gate as app delete. Co-authored-by: Cursor --- docs/runware_serverless_apps_builds.md | 3 +- docs/runware_serverless_apps_builds_delete.md | 50 +++++++ docs/runware_serverless_apps_versions.md | 3 +- ...runware_serverless_apps_versions_delete.md | 51 +++++++ internal/api/serverless/client.go | 64 +++++++++ internal/api/serverless/client_test.go | 134 ++++++++++++++++++ internal/cmd/serverless/apps_builds.go | 64 ++++++++- internal/cmd/serverless/apps_lifecycle.go | 14 +- .../cmd/serverless/apps_lifecycle_test.go | 43 ++++-- internal/cmd/serverless/apps_versions.go | 65 ++++++++- internal/cmd/serverless/display.go | 28 ++++ internal/cmd/serverless/display_test.go | 23 +++ 12 files changed, 518 insertions(+), 24 deletions(-) create mode 100644 docs/runware_serverless_apps_builds_delete.md create mode 100644 docs/runware_serverless_apps_versions_delete.md diff --git a/docs/runware_serverless_apps_builds.md b/docs/runware_serverless_apps_builds.md index 7326a98..72b5ab6 100644 --- a/docs/runware_serverless_apps_builds.md +++ b/docs/runware_serverless_apps_builds.md @@ -4,7 +4,7 @@ Inspect application builds ### Synopsis -List and inspect code builds and container validations for a serverless application. +List, inspect, and delete code builds and container validations for a serverless application. ``` runware serverless apps builds [flags] @@ -28,6 +28,7 @@ runware serverless apps builds [flags] ### SEE ALSO * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications +* [runware serverless apps builds delete](runware_serverless_apps_builds_delete.md) - Delete or cancel an application build * [runware serverless apps builds list](runware_serverless_apps_builds_list.md) - List builds for a serverless application * [runware serverless apps builds show](runware_serverless_apps_builds_show.md) - Show a build for a serverless application diff --git a/docs/runware_serverless_apps_builds_delete.md b/docs/runware_serverless_apps_builds_delete.md new file mode 100644 index 0000000..f2a3a82 --- /dev/null +++ b/docs/runware_serverless_apps_builds_delete.md @@ -0,0 +1,50 @@ +## runware serverless apps builds delete + +Delete or cancel an application build + +### Synopsis + +Cancel a queued or running build, or delete a terminal build. + +Cancelling a queued or running build records it as superseded and ends its +current rollout without activating it, so any previous version keeps serving. +A terminal build can be deleted once no live rollout still needs it. Ready +builds remain while a version references them (409). + +Confirmation is required unless --yes or --force is passed. + +``` +runware serverless apps builds delete [flags] +``` + +### Examples + +``` + # cancel or delete a build (prompts for confirmation) + runware serverless apps builds delete my-app 33333333-3333-3333-3333-333333333333 + + # skip the confirmation prompt + runware serverless apps builds delete my-app 33333333-3333-3333-3333-333333333333 --yes +``` + +### Options + +``` + --force Skip the confirmation prompt + -h, --help help for delete + -y, --yes Skip the confirmation prompt +``` + +### 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 builds](runware_serverless_apps_builds.md) - Inspect application builds + diff --git a/docs/runware_serverless_apps_versions.md b/docs/runware_serverless_apps_versions.md index d3a29d4..7ab267c 100644 --- a/docs/runware_serverless_apps_versions.md +++ b/docs/runware_serverless_apps_versions.md @@ -4,7 +4,7 @@ Manage application versions ### Synopsis -List, inspect, and activate immutable versions of a serverless application. +List, inspect, activate, and delete immutable versions of a serverless application. ``` runware serverless apps versions [flags] @@ -29,6 +29,7 @@ runware serverless apps versions [flags] * [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 delete](runware_serverless_apps_versions_delete.md) - Delete an unused 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_delete.md b/docs/runware_serverless_apps_versions_delete.md new file mode 100644 index 0000000..7452cf8 --- /dev/null +++ b/docs/runware_serverless_apps_versions_delete.md @@ -0,0 +1,51 @@ +## runware serverless apps versions delete + +Delete an unused application version + +### Synopsis + +Delete an unused version while retaining its immutable history. + +Deleted versions are omitted from version lists, return 404 from version +reads, and cannot be activated. Returns 409 while the app is deleting, or +when the version is active, is the app's only remaining version, has a +non-stopped worker, or is targeted by a live rollout. This does not remove +the version's OCI image. + +Confirmation is required unless --yes or --force is passed. + +``` +runware serverless apps versions delete [flags] +``` + +### Examples + +``` + # delete an unused version (prompts for confirmation) + runware serverless apps versions delete my-app 2 + + # skip the confirmation prompt + runware serverless apps versions delete my-app 2 --yes +``` + +### Options + +``` + --force Skip the confirmation prompt + -h, --help help for delete + -y, --yes Skip the confirmation prompt +``` + +### 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/internal/api/serverless/client.go b/internal/api/serverless/client.go index 3b72b13..77d6ef4 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -608,6 +608,37 @@ func (c *Client) GetVersion(ctx context.Context, appID string, versionNumber int } } +// DeleteVersion deletes an unused version. Returns 409 while the version is +// active, is the app's only remaining version, has a non-stopped worker, is +// targeted by a live rollout, or the app is deleting. +func (c *Client) DeleteVersion(ctx context.Context, appID string, versionNumber int32) error { + if c.apiKey == "" { + return transport.ErrNoAPIKey + } + + resp, err := c.inner.DeleteVersionWithResponse(ctx, appID, versionNumber) + if err != nil { + return fmt.Errorf("delete version: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, nil) + + switch resp.StatusCode() { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusConflict: + return problemToError(resp.ApplicationproblemJSON409, http.StatusConflict) + default: + return problemFromBody(resp.Body, resp.StatusCode()) + } +} + // ListBuilds returns a page of builds for an app. func (c *Client) ListBuilds(ctx context.Context, appID string, params *ListBuildsParams) (Page[Build], error) { if c.apiKey == "" { @@ -668,6 +699,39 @@ func (c *Client) GetBuild(ctx context.Context, appID string, buildID uuid.UUID) } } +// DeleteBuild cancels a queued or running build (recorded as superseded) or +// deletes a terminal build that no live version still needs. Returns 409 while +// a live version still references a ready build. +func (c *Client) DeleteBuild(ctx context.Context, appID string, buildID uuid.UUID) error { + if c.apiKey == "" { + return transport.ErrNoAPIKey + } + + resp, err := c.inner.DeleteBuildWithResponse(ctx, appID, buildID) + if err != nil { + return fmt.Errorf("delete build: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, nil) + + switch resp.StatusCode() { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusConflict: + return problemToError(resp.ApplicationproblemJSON409, http.StatusConflict) + case http.StatusUnprocessableEntity: + return problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + default: + return problemFromBody(resp.Body, resp.StatusCode()) + } +} + // ListWorkers returns a page of workers for an app. func (c *Client) ListWorkers(ctx context.Context, appID string, params *ListWorkersParams) (Page[Worker], error) { if c.apiKey == "" { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index fa2e9ae..3084f44 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -888,6 +888,73 @@ func TestGetBuild_NoAPIKey(t *testing.T) { } } +func TestDeleteBuild(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/apps/" + testAppID + "/builds/" + testBuildID + if r.Method != http.MethodDelete || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + if err := c.DeleteBuild(context.Background(), testAppID, uuid.MustParse(testBuildID)); err != nil { + t.Fatalf("DeleteBuild: %v", err) + } +} + +func TestDeleteBuild_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 build exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.DeleteBuild(context.Background(), testAppID, uuid.MustParse(testBuildID)) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", re.StatusCode) + } +} + +func TestDeleteBuild_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":"Build is still referenced by a live version"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.DeleteBuild(context.Background(), testAppID, uuid.MustParse(testBuildID)) + 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 != "Build is still referenced by a live version" { + t.Errorf("unexpected message: %q", re.Message) + } +} + +func TestDeleteBuild_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if err := c.DeleteBuild(context.Background(), testAppID, uuid.MustParse(testBuildID)); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + func TestListVersions(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { want := "/v1/apps/" + testAppID + "/versions" @@ -971,6 +1038,73 @@ func TestGetVersion_NoAPIKey(t *testing.T) { } } +func TestDeleteVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := fmt.Sprintf("/v1/apps/%s/versions/%d", testAppID, testVersionNumber) + if r.Method != http.MethodDelete || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + if err := c.DeleteVersion(context.Background(), testAppID, testVersionNumber); err != nil { + t.Fatalf("DeleteVersion: %v", err) + } +} + +func TestDeleteVersion_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 version exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.DeleteVersion(context.Background(), testAppID, 99999) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", re.StatusCode) + } +} + +func TestDeleteVersion_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 active"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.DeleteVersion(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 active" { + t.Errorf("unexpected message: %q", re.Message) + } +} + +func TestDeleteVersion_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if err := c.DeleteVersion(context.Background(), testAppID, testVersionNumber); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + func TestDeployVersion(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { want := "/v1/apps/" + testAppID + "/deploy" diff --git a/internal/cmd/serverless/apps_builds.go b/internal/cmd/serverless/apps_builds.go index 5cc356f..b7d7253 100644 --- a/internal/cmd/serverless/apps_builds.go +++ b/internal/cmd/serverless/apps_builds.go @@ -15,10 +15,11 @@ import ( func newAppsBuildsCmd(logger *log.Logger) *cobra.Command { cmd := stubGroup("builds", "Inspect application builds") - cmd.Long = "List and inspect code builds and container validations for a serverless application." + cmd.Long = "List, inspect, and delete code builds and container validations for a serverless application." cmd.AddCommand( newAppsBuildsListCmd(logger), newAppsBuildsShowCmd(logger), + newAppsBuildsDeleteCmd(logger), ) return cmd } @@ -107,6 +108,67 @@ supported.`, return cmd } +func newAppsBuildsDeleteCmd(logger *log.Logger) *cobra.Command { + var ( + yes bool + force bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete or cancel an application build", + Long: `Cancel a queued or running build, or delete a terminal build. + +Cancelling a queued or running build records it as superseded and ends its +current rollout without activating it, so any previous version keeps serving. +A terminal build can be deleted once no live rollout still needs it. Ready +builds remain while a version references them (409). + +Confirmation is required unless --yes or --force is passed.`, + Example: ` # cancel or delete a build (prompts for confirmation) + runware serverless apps builds delete my-app 33333333-3333-3333-3333-333333333333 + + # skip the confirmation prompt + runware serverless apps builds delete my-app 33333333-3333-3333-3333-333333333333 --yes`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + buildID, err := uuid.Parse(args[1]) + if err != nil { + return fmt.Errorf("invalid buildId %q: %w", args[1], err) + } + if err := confirmDelete( + fmt.Sprintf("build %s of application %s", buildID, appID), + yes || force, + cmd.InOrStdin(), + cmd.ErrOrStderr(), + stdinIsTerminal(cmd.InOrStdin()), + config.GetAPIKey(), + ); err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Deleting build %s...", buildID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + if err := client.DeleteBuild(cmd.Context(), appID, buildID); err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), buildDeletedResult{ + AppID: appID, + BuildID: buildID.String(), + }) + }, + } + + addDeleteConfirmFlags(cmd, &yes, &force) + return cmd +} + func printBuild(cmd *cobra.Command, b serverlessapi.Build) error { format := cmdutil.FormatFor(cmd) if err := output.Print(format, buildResult(b)); err != nil { diff --git a/internal/cmd/serverless/apps_lifecycle.go b/internal/cmd/serverless/apps_lifecycle.go index 6913fad..1ee75b3 100644 --- a/internal/cmd/serverless/apps_lifecycle.go +++ b/internal/cmd/serverless/apps_lifecycle.go @@ -85,15 +85,14 @@ Confirmation is required unless --yes or --force is passed.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := args[0] - if err := confirmDelete(id, yes || force, cmd.InOrStdin(), cmd.ErrOrStderr(), stdinIsTerminal(cmd.InOrStdin()), config.GetAPIKey()); err != nil { + if err := confirmDelete("application "+id, yes || force, cmd.InOrStdin(), cmd.ErrOrStderr(), stdinIsTerminal(cmd.InOrStdin()), config.GetAPIKey()); err != nil { return err } return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteApp) }, } - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") - cmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt") + addDeleteConfirmFlags(cmd, &yes, &force) return cmd } @@ -114,9 +113,14 @@ func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, actio return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) } +func addDeleteConfirmFlags(cmd *cobra.Command, yes, force *bool) { + cmd.Flags().BoolVarP(yes, "yes", "y", false, "Skip the confirmation prompt") + cmd.Flags().BoolVar(force, "force", false, "Skip the confirmation prompt") +} + // confirmDelete fails closed without an API key so a prompt cannot succeed // and then fail with ErrNoAPIKey. skip (--yes/--force) bypasses the prompt. -func confirmDelete(appID string, skip bool, in io.Reader, out io.Writer, isTTY bool, apiKey string) error { +func confirmDelete(subject string, skip bool, in io.Reader, out io.Writer, isTTY bool, apiKey string) error { if apiKey == "" { return transport.ErrNoAPIKey } @@ -127,7 +131,7 @@ func confirmDelete(appID string, skip bool, in io.Reader, out io.Writer, isTTY b return errDeleteNeedsConfirm } - _, _ = fmt.Fprintf(out, "Delete application %s? [y/N] ", appID) + _, _ = fmt.Fprintf(out, "Delete %s? [y/N] ", subject) scanner := bufio.NewScanner(in) if !scanner.Scan() { if err := scanner.Err(); err != nil { diff --git a/internal/cmd/serverless/apps_lifecycle_test.go b/internal/cmd/serverless/apps_lifecycle_test.go index d02120d..7cdac06 100644 --- a/internal/cmd/serverless/apps_lifecycle_test.go +++ b/internal/cmd/serverless/apps_lifecycle_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/runware/runware-cli/internal/api/transport" + "github.com/spf13/cobra" ) const testDeleteKey = "k" @@ -77,6 +78,11 @@ func TestStdinIsTerminal_NonFile(t *testing.T) { } func TestDeleteCmd_SkipFlags(t *testing.T) { + cmds := []*cobra.Command{ + newAppsDeleteCmd(nil), + newAppsVersionsDeleteCmd(nil), + newAppsBuildsDeleteCmd(nil), + } cases := []struct { args []string yes bool @@ -95,21 +101,28 @@ func TestDeleteCmd_SkipFlags(t *testing.T) { force: true, }, } - for _, tc := range cases { - cmd := newAppsDeleteCmd(nil) - if err := cmd.ParseFlags(tc.args); err != nil { - t.Fatalf("%v: ParseFlags: %v", tc.args, err) - } - yes, err := cmd.Flags().GetBool("yes") - if err != nil { - t.Fatalf("%v: yes: %v", tc.args, err) - } - force, err := cmd.Flags().GetBool("force") - if err != nil { - t.Fatalf("%v: force: %v", tc.args, err) - } - if yes != tc.yes || force != tc.force { - t.Fatalf("%v: yes=%v force=%v, want yes=%v force=%v", tc.args, yes, force, tc.yes, tc.force) + for _, cmd := range cmds { + for _, tc := range cases { + if err := cmd.ParseFlags(tc.args); err != nil { + t.Fatalf("%s %v: ParseFlags: %v", cmd.Name(), tc.args, err) + } + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + t.Fatalf("%s %v: yes: %v", cmd.Name(), tc.args, err) + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + t.Fatalf("%s %v: force: %v", cmd.Name(), tc.args, err) + } + if yes != tc.yes || force != tc.force { + t.Fatalf("%s %v: yes=%v force=%v, want yes=%v force=%v", cmd.Name(), tc.args, yes, force, tc.yes, tc.force) + } + if err := cmd.Flags().Set("yes", "false"); err != nil { + t.Fatalf("reset yes: %v", err) + } + if err := cmd.Flags().Set("force", "false"); err != nil { + t.Fatalf("reset force: %v", err) + } } } } diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go index 8ff9bd5..6d0b179 100644 --- a/internal/cmd/serverless/apps_versions.go +++ b/internal/cmd/serverless/apps_versions.go @@ -15,11 +15,12 @@ import ( func newAppsVersionsCmd(logger *log.Logger) *cobra.Command { cmd := stubGroup("versions", "Manage application versions") - cmd.Long = "List, inspect, and activate immutable versions of a serverless application." + cmd.Long = "List, inspect, activate, and delete immutable versions of a serverless application." cmd.AddCommand( newAppsVersionsListCmd(logger), newAppsVersionsShowCmd(logger), newAppsVersionsActivateCmd(logger), + newAppsVersionsDeleteCmd(logger), ) return cmd } @@ -148,6 +149,68 @@ app that is deleting is 409.`, } } +func newAppsVersionsDeleteCmd(logger *log.Logger) *cobra.Command { + var ( + yes bool + force bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an unused application version", + Long: `Delete an unused version while retaining its immutable history. + +Deleted versions are omitted from version lists, return 404 from version +reads, and cannot be activated. Returns 409 while the app is deleting, or +when the version is active, is the app's only remaining version, has a +non-stopped worker, or is targeted by a live rollout. This does not remove +the version's OCI image. + +Confirmation is required unless --yes or --force is passed.`, + Example: ` # delete an unused version (prompts for confirmation) + runware serverless apps versions delete my-app 2 + + # skip the confirmation prompt + runware serverless apps versions delete my-app 2 --yes`, + 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 + } + if err := confirmDelete( + fmt.Sprintf("version %d of application %s", n, appID), + yes || force, + cmd.InOrStdin(), + cmd.ErrOrStderr(), + stdinIsTerminal(cmd.InOrStdin()), + config.GetAPIKey(), + ); err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Deleting version %d on %s...", n, appID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + if err := client.DeleteVersion(cmd.Context(), appID, n); err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), versionDeletedResult{ + AppID: appID, + VersionNumber: n, + }) + }, + } + + addDeleteConfirmFlags(cmd, &yes, &force) + return cmd +} + 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 eaa9524..58b1065 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -149,6 +149,20 @@ func (r versionResult) Rows() [][]any { } } +// versionDeletedResult is the success payload for deleting a version. +type versionDeletedResult struct { + AppID string `json:"appId" yaml:"appId"` + VersionNumber int32 `json:"versionNumber" yaml:"versionNumber"` +} + +func (r versionDeletedResult) Headers() []string { + return []string{colApp, "Version"} +} + +func (r versionDeletedResult) Rows() [][]any { + return [][]any{{r.AppID, r.VersionNumber}} +} + // workersResult wraps worker lists for table display. type workersResult []serverlessapi.Worker @@ -279,6 +293,20 @@ func (r buildResult) Rows() [][]any { } } +// buildDeletedResult is the success payload for deleting or cancelling a build. +type buildDeletedResult struct { + AppID string `json:"appId" yaml:"appId"` + BuildID string `json:"buildId" yaml:"buildId"` +} + +func (r buildDeletedResult) Headers() []string { + return []string{colApp, colID} +} + +func (r buildDeletedResult) Rows() [][]any { + return [][]any{{r.AppID, r.BuildID}} +} + func formatOptionalTime(t *time.Time) string { if t == nil { return "" diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 9b1dd00..0c4961e 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -347,6 +347,17 @@ func TestVersionResult_NilBuildID(t *testing.T) { } } +func TestVersionDeletedResult(t *testing.T) { + r := versionDeletedResult{AppID: testAppID, VersionNumber: 2} + if got := r.Headers(); len(got) != 2 || got[0] != colApp || got[1] != "Version" { + t.Fatalf("headers: %v", got) + } + rows := r.Rows() + if len(rows) != 1 || rows[0][0] != testAppID || rows[0][1] != int32(2) { + t.Fatalf("unexpected row %#v", rows) + } +} + func TestParseVersionNumber(t *testing.T) { got, err := parseVersionNumber("1") if err != nil || got != 1 { @@ -394,6 +405,18 @@ func TestBuildResult_NilOptionalFields(t *testing.T) { } } +func TestBuildDeletedResult(t *testing.T) { + id := "33333333-3333-3333-3333-333333333333" + r := buildDeletedResult{AppID: testAppID, BuildID: id} + if got := r.Headers(); len(got) != 2 || got[0] != colApp || got[1] != colID { + t.Fatalf("headers: %v", got) + } + rows := r.Rows() + if len(rows) != 1 || rows[0][0] != testAppID || rows[0][1] != id { + t.Fatalf("unexpected row %#v", rows) + } +} + func TestWorkersResult_NilNodeName(t *testing.T) { rows := (workersResult{{ Id: uuid.MustParse("44444444-4444-4444-4444-444444444444"), From b2c7f8c7cb1df5dc8e223921c871bd9d315382ac Mon Sep 17 00:00:00 2001 From: ryank90 Date: Sun, 6 Sep 2026 09:42:11 +0100 Subject: [PATCH 2/2] fix(serverless): extract Version column header constant goconst on CI (golangci-lint v2.12) flags four "Version" literals in the display package. Co-authored-by: Cursor --- internal/cmd/serverless/display.go | 7 ++++--- internal/cmd/serverless/display_test.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 58b1065..9207850 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -16,6 +16,7 @@ const ( colName = "Name" colStatus = "Status" colActiveVersion = "Active version ID" + colVersion = "Version" colCreated = "Created" colUpdated = "Updated" colType = "Type" @@ -115,7 +116,7 @@ func (r endpointsResult) Rows() [][]any { type versionsResult []serverlessapi.Version func (r versionsResult) Headers() []string { - return []string{"Version", colID, "Build", colCreated} + return []string{colVersion, colID, "Build", colCreated} } func (r versionsResult) Rows() [][]any { @@ -141,7 +142,7 @@ func (r versionResult) Headers() []string { func (r versionResult) Rows() [][]any { return [][]any{ - {"Version", r.VersionNumber}, + {colVersion, r.VersionNumber}, {colID, r.Id.String()}, {colApp, r.AppId}, {"Build", formatOptionalUUID(r.BuildId)}, @@ -156,7 +157,7 @@ type versionDeletedResult struct { } func (r versionDeletedResult) Headers() []string { - return []string{colApp, "Version"} + return []string{colApp, colVersion} } func (r versionDeletedResult) Rows() [][]any { diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 0c4961e..fda7b1e 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -349,7 +349,7 @@ func TestVersionResult_NilBuildID(t *testing.T) { func TestVersionDeletedResult(t *testing.T) { r := versionDeletedResult{AppID: testAppID, VersionNumber: 2} - if got := r.Headers(); len(got) != 2 || got[0] != colApp || got[1] != "Version" { + if got := r.Headers(); len(got) != 2 || got[0] != colApp || got[1] != colVersion { t.Fatalf("headers: %v", got) } rows := r.Rows()