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
17 changes: 13 additions & 4 deletions docs/runware_serverless_apps_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,26 @@ runware serverless apps list [flags]
# filter by status
runware serverless apps list --status active

# filter by name or ID substring
runware serverless apps list --query demo --sort name

# filter by GPU type
runware serverless apps list --gpu-type h100 --status active

# page through results
runware serverless apps list --limit 20 --cursor <nextCursor>
```

### Options

```
--cursor string Pagination cursor from a previous nextCursor
-h, --help help for list
--limit int Maximum number of applications to return (1-100)
--status string Filter by status (active, initializing, stopped, …)
--cursor string Pagination cursor from a previous nextCursor (reuse the same --query/--gpu-type/--sort/--status)
--gpu-type string Filter by GPU type (see 'serverless gpus')
-h, --help help for list
--limit int Maximum number of applications to return (1-100)
--query string Filter by substring on name or ID
--sort string Sort order (createdAt (default), name, activity, or errorRate)
--status string Filter by status (active, initializing, stopped, …)
```

### Options inherited from parent commands
Expand Down
7 changes: 7 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ type Worker = gen.Worker
// DeploymentStatus is a deployment lifecycle status.
type DeploymentStatus = gen.DeploymentStatus

// DeploymentSort is a listDeployments ordering.
type DeploymentSort = gen.DeploymentSort

// WorkerStatus is a worker lifecycle status.
type WorkerStatus = gen.WorkerStatus

Expand Down Expand Up @@ -278,10 +281,14 @@ func (c *Client) ListDeployments(ctx context.Context, params *ListDeploymentsPar
return pageOf[Deployment](nil, nil), nil
}
return pageOf(resp.JSON200.Data, resp.JSON200.NextCursor), nil
case http.StatusBadRequest:
return Page[Deployment]{}, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest)
case http.StatusUnauthorized:
return Page[Deployment]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized)
case http.StatusForbidden:
return Page[Deployment]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden)
case http.StatusUnprocessableEntity:
return Page[Deployment]{}, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity)
default:
return Page[Deployment]{}, problemFromBody(resp.Body, resp.StatusCode())
}
Expand Down
75 changes: 72 additions & 3 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/google/uuid"
Expand Down Expand Up @@ -262,6 +263,15 @@ func TestListDeployments(t *testing.T) {
if got := r.URL.Query().Get("cursor"); got != testCursorPage2 {
t.Errorf("cursor query = %q, want %s", got, testCursorPage2)
}
if got := r.URL.Query().Get("q"); got != "demo" {
t.Errorf("q query = %q, want demo", got)
}
if got := r.URL.Query().Get("gpuType"); got != "h100" {
t.Errorf("gpuType query = %q, want h100", got)
}
if got := r.URL.Query().Get("sort"); got != "name" {
t.Errorf("sort query = %q, want name", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{
"deploymentId":"my-app",
Expand All @@ -279,11 +289,17 @@ func TestListDeployments(t *testing.T) {
limit := Limit(10)
cursor := Cursor(testCursorPage2)
status := DeploymentStatus("active")
q := "demo"
gpuType := "h100"
sort := DeploymentSort("name")
c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
page, err := c.ListDeployments(context.Background(), &ListDeploymentsParams{
Limit: &limit,
Cursor: &cursor,
Status: &status,
Limit: &limit,
Cursor: &cursor,
Status: &status,
Q: &q,
GpuType: &gpuType,
Sort: &sort,
})
if err != nil {
t.Fatalf("ListDeployments: %v", err)
Expand All @@ -296,6 +312,59 @@ func TestListDeployments(t *testing.T) {
}
}

func TestListDeployments_BadCursor(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.StatusBadRequest)
_, _ = w.Write([]byte(`{"type":"about:blank","title":"Bad Request","status":400,"detail":"cursor does not match the current sort and filters"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.ListDeployments(context.Background(), nil)
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.StatusCode != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", re.StatusCode)
}
if re.Message != "cursor does not match the current sort and filters" {
t.Errorf("unexpected message: %q", re.Message)
}
}

func TestListDeployments_UnimplementedSort(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.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{
"type":"about:blank",
"title":"Unprocessable Entity",
"status":422,
"detail":"sort activity is not available yet",
"errors":[{"detail":"traffic metrics are not collected yet","pointer":"/sort"}]
}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.ListDeployments(context.Background(), nil)
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("expected status 422, got %d", re.StatusCode)
}
if !strings.Contains(re.Message, "sort activity is not available yet") {
t.Errorf("missing detail: %q", re.Message)
}
if !strings.Contains(re.Message, "/sort: traffic metrics are not collected yet") {
t.Errorf("missing field error: %q", re.Message)
}
}

func TestGetDeployment(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/deployments/"+testDeploymentID {
Expand Down
127 changes: 106 additions & 21 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package serverless
import (
"fmt"
"log/slog"
"strconv"
"strings"

"github.com/charmbracelet/log"
serverlessapi "github.com/runware/runware-cli/internal/api/serverless"
Expand Down Expand Up @@ -36,9 +38,12 @@ func newAppsCmd(logger *log.Logger) *cobra.Command {

func newAppsListCmd(logger *log.Logger) *cobra.Command {
var (
limit int
cursor string
status string
limit int
cursor string
status string
query string
gpuType string
sort string
)

cmd := &cobra.Command{
Expand All @@ -50,24 +55,36 @@ func newAppsListCmd(logger *log.Logger) *cobra.Command {
# filter by status
runware serverless apps list --status active

# filter by name or ID substring
runware serverless apps list --query demo --sort name

# filter by GPU type
runware serverless apps list --gpu-type h100 --status active

# page through results
runware serverless apps list --limit 20 --cursor <nextCursor>`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateListLimit(limit); err != nil {
return err
}
sortVal, err := parseDeploymentSort(sort)
if err != nil {
return err
}
statusVal, err := parseDeploymentStatus(status)
if err != nil {
return err
}

var params *serverlessapi.ListDeploymentsParams
if limit > 0 || cursor != "" || status != "" {
if limit > 0 || cursor != "" || status != "" || query != "" || gpuType != "" || sort != "" {
params = &serverlessapi.ListDeploymentsParams{}
params.Limit, params.Cursor = listPageParams(limit, cursor)
if status != "" {
s := serverlessapi.DeploymentStatus(status)
if !s.Valid() {
return fmt.Errorf("invalid --status %q (want active, initializing, stopping, stopped, deleting, deleted, or failed)", status)
}
params.Status = &s
}
params.Status = statusVal
params.Sort = sortVal
params.Q = optionalStringPtr(query)
params.GpuType = optionalStringPtr(gpuType)
}

spin := cmdutil.NewSpinner("Fetching applications...")
Expand All @@ -81,13 +98,16 @@ func newAppsListCmd(logger *log.Logger) *cobra.Command {
}
spin.Stop()

return printPage(cmdutil.FormatFor(cmd), page, deploymentsResult(page.Data), cmd.ErrOrStderr(), "")
return printPage(cmdutil.FormatFor(cmd), page, deploymentsResult(page.Data), cmd.ErrOrStderr(), extraListCursorFlags(query, gpuType, sort, status))
},
}

cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of applications to return (1-100)")
cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor")
cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor (reuse the same --query/--gpu-type/--sort/--status)")
cmd.Flags().StringVar(&status, "status", "", "Filter by status (active, initializing, stopped, …)")
cmd.Flags().StringVar(&query, "query", "", "Filter by substring on name or ID")
cmd.Flags().StringVar(&gpuType, "gpu-type", "", "Filter by GPU type (see 'serverless gpus')")
cmd.Flags().StringVar(&sort, "sort", "", "Sort order (createdAt (default), name, activity, or errorRate)")

return cmd
}
Expand Down Expand Up @@ -244,17 +264,15 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command {
return err
}
id := args[0]
statusVal, err := parseWorkerStatus(status)
if err != nil {
return err
}
var params *serverlessapi.ListWorkersParams
if limit > 0 || cursor != "" || status != "" {
params = &serverlessapi.ListWorkersParams{}
params.Limit, params.Cursor = listPageParams(limit, cursor)
if status != "" {
s := serverlessapi.WorkerStatus(status)
if !s.Valid() {
return fmt.Errorf("invalid --status %q (want pending, pulling, loading, ready, busy, draining, stopping, or stopped)", status)
}
params.Status = &s
}
params.Status = statusVal
}

spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching workers for %s...", id))
Expand All @@ -268,7 +286,7 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command {
}
spin.Stop()

return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), "")
return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraCursorFlag("--status", status))
},
}

Expand All @@ -289,6 +307,73 @@ func validateListLimit(limit int) error {
return nil
}

// validListFlag is a generated enum used as a list-command filter.
type validListFlag interface {
~string
Valid() bool
}

func parseValidFlag[T validListFlag](flag, value, want string) (*T, error) {
if value == "" {
return nil, nil
}
v := T(value)
if !v.Valid() {
return nil, fmt.Errorf("invalid %s %q (want %s)", flag, value, want)
}
return &v, nil
}

func parseDeploymentSort(sort string) (*serverlessapi.DeploymentSort, error) {
return parseValidFlag[serverlessapi.DeploymentSort]("--sort", sort, "createdAt, name, activity, or errorRate")
}

func parseDeploymentStatus(status string) (*serverlessapi.DeploymentStatus, error) {
return parseValidFlag[serverlessapi.DeploymentStatus]("--status", status, "active, initializing, stopping, stopped, deleting, deleted, or failed")
}

func parseWorkerStatus(status string) (*serverlessapi.WorkerStatus, error) {
return parseValidFlag[serverlessapi.WorkerStatus]("--status", status, "pending, pulling, loading, ready, busy, draining, stopping, or stopped")
}

// extraListCursorFlags repeats the apps-list filter flags a next-page --cursor is bound to.
func extraListCursorFlags(query, gpuType, sort, status string) string {
parts := make([]string, 0, 4)
parts = appendFlag(parts, "--query", query)
parts = appendFlag(parts, "--gpu-type", gpuType)
parts = appendFlag(parts, "--sort", sort)
parts = appendFlag(parts, "--status", status)
return strings.Join(parts, " ")
}

// extraCursorFlag formats a single filter flag for a next-page --cursor hint.
func extraCursorFlag(name, value string) string {
return strings.Join(appendFlag(nil, name, value), " ")
}

func appendFlag(parts []string, name, value string) []string {
if value == "" {
return parts
}
if !isBareFlagValue(value) {
return append(parts, name+" "+strconv.Quote(value))
}
return append(parts, name+" "+value)
}

func isBareFlagValue(value string) bool {
for i := range len(value) {
c := value[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '-' || c == '_' || c == '.' || c == ':' || c == '/':
default:
return false
}
}
return true
}

// listPageParams builds shared limit/cursor query values for cursor-paginated list commands.
func listPageParams(limit int, cursor string) (*serverlessapi.Limit, *serverlessapi.Cursor) {
var (
Expand Down
Loading