From 66db731459a9875b2a802d25007990c56ecfb88a Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 18 Aug 2026 19:24:54 +0100 Subject: [PATCH] feat(versions): include versions to CLI methods --- docs/runware_serverless_apps.md | 2 +- docs/runware_serverless_apps_versions.md | 20 ++-- docs/runware_serverless_apps_versions_list.md | 45 +++++++ docs/runware_serverless_apps_versions_show.md | 38 ++++++ internal/api/serverless/client.go | 36 ++++++ internal/api/serverless/client_test.go | 71 ++++++++++- internal/cmd/serverless/apps.go | 46 ------- internal/cmd/serverless/apps_versions.go | 113 ++++++++++++++++++ internal/cmd/serverless/display.go | 17 +++ internal/cmd/serverless/display_test.go | 44 ++++++- 10 files changed, 364 insertions(+), 68 deletions(-) create mode 100644 docs/runware_serverless_apps_versions_list.md create mode 100644 docs/runware_serverless_apps_versions_show.md create mode 100644 internal/cmd/serverless/apps_versions.go diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 724d980..3549222 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -38,6 +38,6 @@ runware serverless apps [flags] * [runware serverless apps show](runware_serverless_apps_show.md) - Show details for a serverless application * [runware serverless apps stop](runware_serverless_apps_stop.md) - Stop a serverless application * [runware serverless apps usage](runware_serverless_apps_usage.md) - Show usage for a serverless application -* [runware serverless apps versions](runware_serverless_apps_versions.md) - List versions of a serverless application +* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect 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 aec31a5..37712c1 100644 --- a/docs/runware_serverless_apps_versions.md +++ b/docs/runware_serverless_apps_versions.md @@ -1,27 +1,19 @@ ## runware serverless apps versions -List versions of a serverless application +Inspect application versions -``` -runware serverless apps versions [flags] -``` +### Synopsis -### Examples +List and inspect immutable versions of a serverless application. ``` - # list deployed versions - runware serverless apps versions my-app - - # page through results - runware serverless apps versions my-app --limit 20 --cursor +runware serverless apps versions [flags] ``` ### Options ``` - --cursor string Pagination cursor from a previous nextCursor - -h, --help help for versions - --limit int Maximum number of versions to return (1-100) + -h, --help help for versions ``` ### Options inherited from parent commands @@ -36,4 +28,6 @@ runware serverless apps versions [flags] ### SEE ALSO * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications +* [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_list.md b/docs/runware_serverless_apps_versions_list.md new file mode 100644 index 0000000..1acb8e7 --- /dev/null +++ b/docs/runware_serverless_apps_versions_list.md @@ -0,0 +1,45 @@ +## runware serverless apps versions list + +List versions of a serverless application + +### Synopsis + +List immutable versions of an application. + +The Build column is empty for container-sourced versions. + +``` +runware serverless apps versions list [flags] +``` + +### Examples + +``` + # list deployed versions + runware serverless apps versions list my-app + + # page through results + runware serverless apps versions list my-app --limit 20 --cursor +``` + +### Options + +``` + --cursor string Pagination cursor from a previous nextCursor + -h, --help help for list + --limit int Maximum number of versions to return (1-100) +``` + +### 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) - Inspect application versions + diff --git a/docs/runware_serverless_apps_versions_show.md b/docs/runware_serverless_apps_versions_show.md new file mode 100644 index 0000000..bbcc90c --- /dev/null +++ b/docs/runware_serverless_apps_versions_show.md @@ -0,0 +1,38 @@ +## runware serverless apps versions show + +Show a version of a serverless application + +### Synopsis + +Show a single immutable version by number. + +``` +runware serverless apps versions show [flags] +``` + +### Examples + +``` + # show a version + runware serverless apps versions show my-app 1 +``` + +### Options + +``` + -h, --help help for show +``` + +### 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) - Inspect application versions + diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 38f445b..42f990f 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -402,6 +402,42 @@ func (c *Client) ListVersions(ctx context.Context, deploymentID string, params * } } +// GetVersion returns a single version by number. +func (c *Client) GetVersion(ctx context.Context, deploymentID string, versionNumber int32) (*Version, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.GetVersionWithResponse(ctx, deploymentID, versionNumber) + if err != nil { + return nil, fmt.Errorf("get version: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", fmt.Sprintf("/v1/deployments/%s/versions/%d", deploymentID, versionNumber), + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("get version: empty 200 response") + } + return resp.JSON200, nil + case http.StatusUnauthorized: + return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + default: + return nil, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // ListBuilds returns a page of builds for a deployment. func (c *Client) ListBuilds(ctx context.Context, deploymentID string, params *ListBuildsParams) (Page[Build], error) { if c.apiKey == "" { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index e94d916..df7b83c 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -3,6 +3,7 @@ package serverless import ( "context" "errors" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -14,10 +15,12 @@ import ( ) const ( - testDeploymentID = "my-app" - testBuildID = "33333333-3333-3333-3333-333333333333" - testCursorPage2 = "page-2" - testCursorPage3 = "page-3" + testDeploymentID = "my-app" + testBuildID = "33333333-3333-3333-3333-333333333333" + testVersionID = "22222222-2222-2222-2222-222222222222" + testVersionNumber = int32(1) + testCursorPage2 = "page-2" + testCursorPage3 = "page-3" ) func TestListGpuTypes(t *testing.T) { @@ -556,9 +559,9 @@ func TestListVersions(t *testing.T) { } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{ - "id":"22222222-2222-2222-2222-222222222222", + "id":"` + testVersionID + `", "deploymentId":"my-app", - "buildId":"33333333-3333-3333-3333-333333333333", + "buildId":"` + testBuildID + `", "versionNumber":1, "createdAt":"2026-07-30T12:00:00Z" }]}`)) @@ -575,6 +578,62 @@ func TestListVersions(t *testing.T) { } } +func TestGetVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := fmt.Sprintf("/v1/deployments/%s/versions/%d", testDeploymentID, testVersionNumber) + if r.Method != http.MethodGet || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"` + testVersionID + `", + "deploymentId":"` + testDeploymentID + `", + "buildId":"` + testBuildID + `", + "versionNumber":1, + "createdAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + v, err := c.GetVersion(context.Background(), testDeploymentID, testVersionNumber) + if err != nil { + t.Fatalf("GetVersion: %v", err) + } + if v.Id.String() != testVersionID || v.VersionNumber != testVersionNumber { + t.Errorf("unexpected version: %+v", v) + } + if v.BuildId == nil || v.BuildId.String() != testBuildID { + t.Errorf("unexpected buildId: %+v", v.BuildId) + } +} + +func TestGetVersion_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.GetVersion(context.Background(), testDeploymentID, 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 TestGetVersion_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.GetVersion(context.Background(), testDeploymentID, 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/deployments/" + testDeploymentID + "/workers" diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index c63871a..1e0527b 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -184,52 +184,6 @@ func newAppsEndpointsCmd(logger *log.Logger) *cobra.Command { return cmd } -func newAppsVersionsCmd(logger *log.Logger) *cobra.Command { - var ( - limit int - cursor string - ) - - cmd := &cobra.Command{ - Use: "versions ", - Short: "List versions of a serverless application", - Example: ` # list deployed versions - runware serverless apps versions my-app - - # page through results - runware serverless apps versions my-app --limit 20 --cursor `, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if err := validateListLimit(limit); err != nil { - return err - } - id := args[0] - var params *serverlessapi.ListVersionsParams - if limit > 0 || cursor != "" { - params = &serverlessapi.ListVersionsParams{} - params.Limit, params.Cursor = listPageParams(limit, cursor) - } - - spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching versions for %s...", id)) - spin.Start() - - client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) - page, err := client.ListVersions(cmd.Context(), id, params) - if err != nil { - spin.Stop() - return err - } - spin.Stop() - - return printPage(cmdutil.FormatFor(cmd), page, versionsResult(page.Data), cmd.ErrOrStderr(), "") - }, - } - - cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return (1-100)") - cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") - return cmd -} - func newAppsLogsCmd() *cobra.Command { return stubLeaf( "logs ", diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go new file mode 100644 index 0000000..ba53b51 --- /dev/null +++ b/internal/cmd/serverless/apps_versions.go @@ -0,0 +1,113 @@ +package serverless + +import ( + "fmt" + "log/slog" + "strconv" + + "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 newAppsVersionsCmd(logger *log.Logger) *cobra.Command { + cmd := stubGroup("versions", "Inspect application versions") + cmd.Long = "List and inspect immutable versions of a serverless application." + cmd.AddCommand( + newAppsVersionsListCmd(logger), + newAppsVersionsShowCmd(logger), + ) + return cmd +} + +func newAppsVersionsListCmd(logger *log.Logger) *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List versions of a serverless application", + Long: `List immutable versions of an application. + +The Build column is empty for container-sourced versions.`, + Example: ` # list deployed versions + runware serverless apps versions list my-app + + # page through results + runware serverless apps versions list my-app --limit 20 --cursor `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateListLimit(limit); err != nil { + return err + } + id := args[0] + var params *serverlessapi.ListVersionsParams + if limit > 0 || cursor != "" { + params = &serverlessapi.ListVersionsParams{} + params.Limit, params.Cursor = listPageParams(limit, cursor) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching versions for %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.ListVersions(cmd.Context(), id, params) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printPage(cmdutil.FormatFor(cmd), page, versionsResult(page.Data), cmd.ErrOrStderr(), "") + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return (1-100)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + return cmd +} + +func newAppsVersionsShowCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a version of a serverless application", + Long: "Show a single immutable version by number.", + Example: ` # show a version + runware serverless apps versions show my-app 1`, + 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("Fetching version %d...", n)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + v, err := client.GetVersion(cmd.Context(), appID, n) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), versionResult(*v)) + }, + } + return cmd +} + +func parseVersionNumber(s string) (int32, error) { + n, err := strconv.ParseInt(s, 10, 32) + if err != nil || n < 1 { + return 0, fmt.Errorf("invalid versionNumber %q", s) + } + return int32(n), nil +} diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index db558fc..e869988 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -100,6 +100,23 @@ func (r versionsResult) Rows() [][]any { return rows } +// versionResult wraps a single version for table/json/yaml display. +type versionResult serverlessapi.Version + +func (r versionResult) Headers() []string { + return []string{colField, colValue} +} + +func (r versionResult) Rows() [][]any { + return [][]any{ + {"Version", r.VersionNumber}, + {colID, r.Id.String()}, + {colApp, r.DeploymentId}, + {"Build", formatOptionalUUID(r.BuildId)}, + {colCreated, r.CreatedAt.Format(time.RFC3339)}, + } +} + // workersResult wraps worker lists for table display. type workersResult []serverlessapi.Worker diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index c21bf3a..fd52e21 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -11,6 +11,8 @@ import ( "github.com/runware/runware-cli/internal/output" ) +const testAppID = "my-app" + func TestListPageParams(t *testing.T) { limit, cursor := listPageParams(0, "") if limit != nil || cursor != nil { @@ -187,8 +189,8 @@ func TestSecretsResult_NoValueColumn(t *testing.T) { secretAttachmentsResult{}, secretResult{}, secretRemovedResult{Name: testSecretName}, - secretAttachResult{DeploymentID: "my-app", Name: testSecretName, EnvVarName: "BAR"}, - secretDetachResult{DeploymentID: "my-app", Name: testSecretName}, + secretAttachResult{DeploymentID: testAppID, Name: testSecretName, EnvVarName: "BAR"}, + secretDetachResult{DeploymentID: testAppID, Name: testSecretName}, } for _, table := range tables { headers := table.Headers() @@ -214,6 +216,44 @@ func TestVersionsResult_NilBuildID(t *testing.T) { } } +func TestVersionResult_NilBuildID(t *testing.T) { + rows := (versionResult{ + Id: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + DeploymentId: testAppID, + VersionNumber: 1, + CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC), + }).Rows() + if len(rows) != 5 { + t.Fatalf("expected 5 rows, got %d", len(rows)) + } + if rows[3][1] != "" { + t.Fatalf("nil BuildId should render empty, got %#v", rows[3][1]) + } + if rows[0][1] != int32(1) { + t.Fatalf("version number: got %#v", rows[0][1]) + } + if rows[2][1] != testAppID { + t.Fatalf("deploymentId: got %#v", rows[2][1]) + } +} + +func TestParseVersionNumber(t *testing.T) { + got, err := parseVersionNumber("1") + if err != nil || got != 1 { + t.Fatalf("1: got=%d err=%v", got, err) + } + + for _, in := range []string{"foo", "1.5", "0", "-1"} { + _, err = parseVersionNumber(in) + if err == nil { + t.Fatalf("expected error for %q", in) + } + if !strings.Contains(err.Error(), "invalid versionNumber") { + t.Fatalf("%q: error %q should mention invalid versionNumber", in, err) + } + } +} + func TestBuildsResult_OmitsLogTailColumn(t *testing.T) { tables := []output.Tabular{ buildsResult{},