From 442036954fd221049582745f129d4e2aca2793d3 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 14 Aug 2026 18:08:29 +0100 Subject: [PATCH] feat(builds): implement builds list and show methods --- docs/runware_serverless_apps.md | 1 + docs/runware_serverless_apps_builds.md | 33 ++++++ docs/runware_serverless_apps_builds_list.md | 45 +++++++ docs/runware_serverless_apps_builds_show.md | 41 +++++++ internal/api/serverless/client.go | 82 +++++++++++++ internal/api/serverless/client_test.go | 108 +++++++++++++++++ internal/cmd/serverless/apps.go | 1 + internal/cmd/serverless/apps_builds.go | 123 ++++++++++++++++++++ internal/cmd/serverless/display.go | 46 ++++++++ internal/cmd/serverless/display_test.go | 30 +++++ 10 files changed, 510 insertions(+) create mode 100644 docs/runware_serverless_apps_builds.md create mode 100644 docs/runware_serverless_apps_builds_list.md create mode 100644 docs/runware_serverless_apps_builds_show.md create mode 100644 internal/cmd/serverless/apps_builds.go diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index d3c75bc..724d980 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -28,6 +28,7 @@ runware serverless apps [flags] ### SEE ALSO * [runware serverless](runware_serverless.md) - Manage Runware serverless applications +* [runware serverless apps builds](runware_serverless_apps_builds.md) - Inspect application builds * [runware serverless apps delete](runware_serverless_apps_delete.md) - Delete a serverless application * [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List endpoints for a serverless application * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications diff --git a/docs/runware_serverless_apps_builds.md b/docs/runware_serverless_apps_builds.md new file mode 100644 index 0000000..7326a98 --- /dev/null +++ b/docs/runware_serverless_apps_builds.md @@ -0,0 +1,33 @@ +## runware serverless apps builds + +Inspect application builds + +### Synopsis + +List and inspect code builds and container validations for a serverless application. + +``` +runware serverless apps builds [flags] +``` + +### Options + +``` + -h, --help help for builds +``` + +### 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 +* [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_list.md b/docs/runware_serverless_apps_builds_list.md new file mode 100644 index 0000000..3a2dcb3 --- /dev/null +++ b/docs/runware_serverless_apps_builds_list.md @@ -0,0 +1,45 @@ +## runware serverless apps builds list + +List builds for a serverless application + +### Synopsis + +List code builds and container validations for an application. + +The table omits log tail; use 'builds show' for error detail and log tail. + +``` +runware serverless apps builds list [flags] +``` + +### Examples + +``` + # list builds for an application + runware serverless apps builds list my-app + + # page through results + runware serverless apps builds 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 builds 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 builds](runware_serverless_apps_builds.md) - Inspect application builds + diff --git a/docs/runware_serverless_apps_builds_show.md b/docs/runware_serverless_apps_builds_show.md new file mode 100644 index 0000000..4fda1c2 --- /dev/null +++ b/docs/runware_serverless_apps_builds_show.md @@ -0,0 +1,41 @@ +## runware serverless apps builds show + +Show a build for a serverless application + +### Synopsis + +Show a single build, including status, error, and log tail. + +Log tail is the trailing snapshot returned by the API; live streaming is not +supported. + +``` +runware serverless apps builds show [flags] +``` + +### Examples + +``` + # show a build + runware serverless apps builds show my-app 33333333-3333-3333-3333-333333333333 +``` + +### 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 builds](runware_serverless_apps_builds.md) - Inspect application builds + diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 648203e..fc2e028 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/runware/runware-cli/internal/agents" "github.com/runware/runware-cli/internal/api/serverless/gen" "github.com/runware/runware-cli/internal/api/transport" @@ -57,6 +58,15 @@ type ListEndpointsParams = gen.ListEndpointsParams // ListVersionsParams are optional filters for ListVersions. type ListVersionsParams = gen.ListVersionsParams +// ListBuildsParams are optional filters for ListBuilds. +type ListBuildsParams = gen.ListBuildsParams + +// Build is a code build or container validation for a deployment. +type Build = gen.Build + +// BuildStatus is a build lifecycle status. +type BuildStatus = gen.BuildStatus + // ListWorkersParams are optional filters for ListWorkers. type ListWorkersParams = gen.ListWorkersParams @@ -385,6 +395,78 @@ func (c *Client) ListVersions(ctx context.Context, deploymentID string, params * } } +// 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 == "" { + return Page[Build]{}, transport.ErrNoAPIKey + } + + resp, err := c.inner.ListBuildsWithResponse(ctx, deploymentID, params) + if err != nil { + return Page[Build]{}, fmt.Errorf("list builds: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID+"/builds", + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return pageOf[Build](nil, nil), nil + } + return pageOf(resp.JSON200.Data, resp.JSON200.NextCursor), nil + case http.StatusUnauthorized: + return Page[Build]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return Page[Build]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return Page[Build]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + default: + return Page[Build]{}, problemFromBody(resp.Body, resp.StatusCode()) + } +} + +// GetBuild returns a single build by ID. +func (c *Client) GetBuild(ctx context.Context, deploymentID string, buildID uuid.UUID) (*Build, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.GetBuildWithResponse(ctx, deploymentID, buildID) + if err != nil { + return nil, fmt.Errorf("get build: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID+"/builds/"+buildID.String(), + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("get build: 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()) + } +} + // ListWorkers returns a page of workers for a deployment. func (c *Client) ListWorkers(ctx context.Context, deploymentID 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 e917fa5..bd8c784 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -8,11 +8,13 @@ import ( "net/http/httptest" "testing" + "github.com/google/uuid" "github.com/runware/runware-cli/internal/api/transport" ) const ( testDeploymentID = "my-app" + testBuildID = "33333333-3333-3333-3333-333333333333" testCursorPage2 = "page-2" testCursorPage3 = "page-3" ) @@ -371,6 +373,112 @@ func TestListEndpoints(t *testing.T) { } } +func TestListBuilds(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/deployments/" + testDeploymentID + "/builds" + if r.Method != http.MethodGet || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("limit query = %q, want 10", got) + } + if got := r.URL.Query().Get("cursor"); got != testCursorPage2 { + t.Errorf("cursor query = %q, want %s", got, testCursorPage2) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{ + "id":"` + testBuildID + `", + "status":"failed", + "error":"pip install failed", + "exitCode":1, + "logTail":"ERROR: Could not find a version", + "createdAt":"2026-07-30T12:00:00Z" + }],"nextCursor":"` + testCursorPage3 + `"}`)) + })) + defer srv.Close() + + limit := Limit(10) + cursor := Cursor(testCursorPage2) + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + page, err := c.ListBuilds(context.Background(), testDeploymentID, &ListBuildsParams{ + Limit: &limit, + Cursor: &cursor, + }) + if err != nil { + t.Fatalf("ListBuilds: %v", err) + } + if len(page.Data) != 1 || page.Data[0].Id.String() != testBuildID { + t.Fatalf("unexpected builds: %+v", page.Data) + } + if string(page.Data[0].Status) != "failed" { + t.Errorf("unexpected status: %s", page.Data[0].Status) + } + if page.Data[0].Error == nil || *page.Data[0].Error != "pip install failed" { + t.Errorf("unexpected error: %+v", page.Data[0].Error) + } + if page.NextCursor == nil || *page.NextCursor != testCursorPage3 { + t.Fatalf("unexpected nextCursor: %+v", page.NextCursor) + } +} + +func TestListBuilds_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.ListBuilds(context.Background(), testDeploymentID, nil); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + +func TestGetBuild(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/deployments/" + testDeploymentID + "/builds/" + testBuildID + 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":"` + testBuildID + `", + "status":"ready", + "createdAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + b, err := c.GetBuild(context.Background(), testDeploymentID, uuid.MustParse(testBuildID)) + if err != nil { + t.Fatalf("GetBuild: %v", err) + } + if b.Id.String() != testBuildID || string(b.Status) != "ready" { + t.Errorf("unexpected build: %+v", b) + } +} + +func TestGetBuild_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.GetBuild(context.Background(), testDeploymentID, 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 TestGetBuild_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.GetBuild(context.Background(), testDeploymentID, 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/deployments/" + testDeploymentID + "/versions" diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 83a1503..2b1fde3 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -22,6 +22,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsShowCmd(logger), newAppsEndpointsCmd(logger), newAppsVersionsCmd(logger), + newAppsBuildsCmd(logger), newAppsLogsCmd(), newAppsWorkersCmd(logger), newAppsScaleCmd(), diff --git a/internal/cmd/serverless/apps_builds.go b/internal/cmd/serverless/apps_builds.go new file mode 100644 index 0000000..5cc356f --- /dev/null +++ b/internal/cmd/serverless/apps_builds.go @@ -0,0 +1,123 @@ +package serverless + +import ( + "fmt" + "log/slog" + + "github.com/charmbracelet/log" + "github.com/google/uuid" + 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 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.AddCommand( + newAppsBuildsListCmd(logger), + newAppsBuildsShowCmd(logger), + ) + return cmd +} + +func newAppsBuildsListCmd(logger *log.Logger) *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List builds for a serverless application", + Long: `List code builds and container validations for an application. + +The table omits log tail; use 'builds show' for error detail and log tail.`, + Example: ` # list builds for an application + runware serverless apps builds list my-app + + # page through results + runware serverless apps builds 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.ListBuildsParams + if limit > 0 || cursor != "" { + params = &serverlessapi.ListBuildsParams{} + params.Limit, params.Cursor = listPageParams(limit, cursor) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching builds for %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.ListBuilds(cmd.Context(), id, params) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printPage(cmdutil.FormatFor(cmd), page, buildsResult(page.Data), cmd.ErrOrStderr(), "") + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of builds to return (1-100)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + return cmd +} + +func newAppsBuildsShowCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a build for a serverless application", + Long: `Show a single build, including status, error, and log tail. + +Log tail is the trailing snapshot returned by the API; live streaming is not +supported.`, + Example: ` # show a build + runware serverless apps builds show my-app 33333333-3333-3333-3333-333333333333`, + 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) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching build %s...", buildID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + b, err := client.GetBuild(cmd.Context(), appID, buildID) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printBuild(cmd, *b) + }, + } + return cmd +} + +func printBuild(cmd *cobra.Command, b serverlessapi.Build) error { + format := cmdutil.FormatFor(cmd) + if err := output.Print(format, buildResult(b)); err != nil { + return err + } + if format != output.FormatTable { + return nil + } + if b.LogTail == nil || *b.LogTail == "" { + return nil + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "\nLog tail:\n%s\n", *b.LogTail) + return err +} diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index ae16c6f..db558fc 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -122,6 +122,52 @@ func (r workersResult) Rows() [][]any { return rows } +func formatOptionalInt32(v *int32) string { + if v == nil { + return "" + } + return fmt.Sprintf("%d", *v) +} + +// buildsResult wraps build lists for table display. Log tail is omitted. +type buildsResult []serverlessapi.Build + +func (r buildsResult) Headers() []string { + return []string{colID, colStatus, "Error", colCreated} +} + +func (r buildsResult) Rows() [][]any { + rows := make([][]any, len(r)) + for i := range r { + b := &r[i] + rows[i] = []any{ + b.Id.String(), + string(b.Status), + formatOptionalString(b.Error), + formatOptionalTime(b.CreatedAt), + } + } + return rows +} + +// buildResult wraps a single build for table/json/yaml display. +// Table omits logTail; printBuild appends it as a block in table format. +type buildResult serverlessapi.Build + +func (r buildResult) Headers() []string { + return []string{colField, colValue} +} + +func (r buildResult) Rows() [][]any { + return [][]any{ + {colID, r.Id.String()}, + {colStatus, string(r.Status)}, + {"Error", formatOptionalString(r.Error)}, + {"Exit code", formatOptionalInt32(r.ExitCode)}, + {colCreated, formatOptionalTime(r.CreatedAt)}, + } +} + 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 a647bde..b9a4d68 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -109,6 +109,36 @@ func TestVersionsResult_NilBuildID(t *testing.T) { } } +func TestBuildsResult_OmitsLogTailColumn(t *testing.T) { + tables := []output.Tabular{ + buildsResult{}, + buildResult{}, + } + for _, table := range tables { + for _, h := range table.Headers() { + if strings.Contains(strings.ToLower(h), "log") { + t.Fatalf("%T table must not include a log column: %v", table, table.Headers()) + } + } + } +} + +func TestBuildResult_NilOptionalFields(t *testing.T) { + rows := (buildResult{ + Id: uuid.MustParse("33333333-3333-3333-3333-333333333333"), + Status: "queued", + }).Rows() + if len(rows) != 5 { + t.Fatalf("expected 5 rows, got %d", len(rows)) + } + if rows[2][1] != "" { + t.Fatalf("nil Error should render empty, got %#v", rows[2][1]) + } + if rows[3][1] != "" { + t.Fatalf("nil ExitCode should render empty, got %#v", rows[3][1]) + } +} + func TestWorkersResult_NilNodeName(t *testing.T) { rows := (workersResult{{ Id: uuid.MustParse("44444444-4444-4444-4444-444444444444"),