Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/runware_serverless_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/runware_serverless_apps_builds.md
Original file line number Diff line number Diff line change
@@ -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

45 changes: 45 additions & 0 deletions docs/runware_serverless_apps_builds_list.md
Original file line number Diff line number Diff line change
@@ -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 <appId> [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 <nextCursor>
```

### 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

41 changes: 41 additions & 0 deletions docs/runware_serverless_apps_builds_show.md
Original file line number Diff line number Diff line change
@@ -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 <appId> <buildId> [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

82 changes: 82 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: a path literal here will be brittle to upstream changes (e.g. like the big deployments/app rename)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this PR as-is so we don't rebase the stack.

Follow-up at the tip: #93logResponse now takes the generated client's HTTPResponse and logs Request.URL.Path, so a deployments/apps rename tracks OpenAPI regen instead of duplicated literals.

"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 == "" {
Expand Down
108 changes: 108 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command {
newAppsShowCmd(logger),
newAppsEndpointsCmd(logger),
newAppsVersionsCmd(logger),
newAppsBuildsCmd(logger),
newAppsLogsCmd(),
newAppsWorkersCmd(logger),
newAppsScaleCmd(),
Expand Down
Loading