From 843fb0b5be2354b470732fdbb24e2492347a101c Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 18 Aug 2026 14:18:49 +0100 Subject: [PATCH 1/3] feat: apps list filters --- docs/runware_serverless_apps_list.md | 17 +++- docs/runware_serverless_apps_workers.md | 2 +- internal/api/serverless/client.go | 7 ++ internal/api/serverless/client_test.go | 75 +++++++++++++- internal/cmd/serverless/apps.go | 129 ++++++++++++++++++++---- internal/cmd/serverless/display_test.go | 105 +++++++++++++++++++ 6 files changed, 305 insertions(+), 30 deletions(-) diff --git a/docs/runware_serverless_apps_list.md b/docs/runware_serverless_apps_list.md index b8ca40c..80d6a0a 100644 --- a/docs/runware_serverless_apps_list.md +++ b/docs/runware_serverless_apps_list.md @@ -15,6 +15,12 @@ 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 ``` @@ -22,10 +28,13 @@ runware serverless apps list [flags] ### 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 diff --git a/docs/runware_serverless_apps_workers.md b/docs/runware_serverless_apps_workers.md index 7a2cb55..fb7a826 100644 --- a/docs/runware_serverless_apps_workers.md +++ b/docs/runware_serverless_apps_workers.md @@ -22,7 +22,7 @@ runware serverless apps workers [flags] ### Options ``` - --cursor string Pagination cursor from a previous nextCursor + --cursor string Pagination cursor from a previous nextCursor (reuse the same --status) -h, --help help for workers --limit int Maximum number of workers to return (1-100) --status string Filter by status (ready, busy, pending, …) diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index fc2e028..38f445b 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -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 @@ -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()) } diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index bd8c784..e94d916 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "github.com/google/uuid" @@ -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", @@ -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) @@ -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 { diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 2b1fde3..5dac383 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -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" @@ -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{ @@ -50,6 +55,12 @@ 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 `, Args: cobra.NoArgs, @@ -57,17 +68,23 @@ func newAppsListCmd(logger *log.Logger) *cobra.Command { 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...") @@ -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 } @@ -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)) @@ -268,12 +286,12 @@ 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)) }, } cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of workers 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 --status)") cmd.Flags().StringVar(&status, "status", "", "Filter by status (ready, busy, pending, …)") return cmd } @@ -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 ( diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index b9a4d68..c21bf3a 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -44,6 +44,111 @@ func TestValidateListLimit(t *testing.T) { } } +func TestParseDeploymentSort(t *testing.T) { + got, err := parseDeploymentSort("") + if err != nil || got != nil { + t.Fatalf("unset sort: got=%v err=%v", got, err) + } + + got, err = parseDeploymentSort("activity") + if err != nil { + t.Fatalf("activity: %v", err) + } + if got == nil || *got != "activity" { + t.Fatalf("activity: got %+v", got) + } + + got, err = parseDeploymentSort("createdAt") + if err != nil || got == nil || *got != "createdAt" { + t.Fatalf("createdAt: got=%v err=%v", got, err) + } + + _, err = parseDeploymentSort("nope") + if err == nil { + t.Fatal("expected error for sort nope") + } + if !strings.Contains(err.Error(), "invalid --sort") { + t.Fatalf("error %q should mention invalid --sort", err) + } +} + +func TestParseDeploymentStatus(t *testing.T) { + got, err := parseDeploymentStatus("") + if err != nil || got != nil { + t.Fatalf("unset status: got=%v err=%v", got, err) + } + + got, err = parseDeploymentStatus("active") + if err != nil || got == nil || *got != "active" { + t.Fatalf("active: got=%v err=%v", got, err) + } + + _, err = parseDeploymentStatus("nope") + if err == nil { + t.Fatal("expected error for status nope") + } + if !strings.Contains(err.Error(), "invalid --status") { + t.Fatalf("error %q should mention invalid --status", err) + } +} + +func TestParseWorkerStatus(t *testing.T) { + got, err := parseWorkerStatus("") + if err != nil || got != nil { + t.Fatalf("unset status: got=%v err=%v", got, err) + } + + got, err = parseWorkerStatus("ready") + if err != nil || got == nil || *got != "ready" { + t.Fatalf("ready: got=%v err=%v", got, err) + } + + _, err = parseWorkerStatus("nope") + if err == nil { + t.Fatal("expected error for status nope") + } + if !strings.Contains(err.Error(), "invalid --status") { + t.Fatalf("error %q should mention invalid --status", err) + } +} + +func TestExtraListCursorFlags(t *testing.T) { + got := extraListCursorFlags("demo", "h100", "name", "active") + want := "--query demo --gpu-type h100 --sort name --status active" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + + got = extraListCursorFlags("", "", "", "active") + if got != "--status active" { + t.Fatalf("status only: got %q", got) + } + + got = extraListCursorFlags("", "", "", "") + if got != "" { + t.Fatalf("empty: got %q", got) + } + + got = extraListCursorFlags("my app", "", "", "") + if got != `--query "my app"` { + t.Fatalf("quoted query: got %q", got) + } + + got = extraListCursorFlags("foo;bar", "", "", "") + if got != `--query "foo;bar"` { + t.Fatalf("quoted metacharacters: got %q", got) + } +} + +func TestExtraCursorFlag(t *testing.T) { + if got := extraCursorFlag("--status", "ready"); got != "--status ready" { + t.Fatalf("got %q", got) + } + if got := extraCursorFlag("--status", ""); got != "" { + t.Fatalf("empty: got %q", got) + } +} + func TestPrintPage_TableWritesNextCursorToErrOut(t *testing.T) { next := "page-2" page := serverlessapi.Page[serverlessapi.Deployment]{ From 18aebee2b38a4f04b779232e5140b0c1a07a1c06 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 18 Aug 2026 14:24:09 +0100 Subject: [PATCH 2/3] fix: docs --- internal/cmd/serverless/apps.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 5dac383..c63871a 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -291,7 +291,7 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { } cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of workers to return (1-100)") - cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor (reuse the same --status)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") cmd.Flags().StringVar(&status, "status", "", "Filter by status (ready, busy, pending, …)") return cmd } From 44843379fb0bc718e70ba23736b334c59a053e61 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 18 Aug 2026 14:26:30 +0100 Subject: [PATCH 3/3] fix: docs --- docs/runware_serverless_apps_workers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runware_serverless_apps_workers.md b/docs/runware_serverless_apps_workers.md index fb7a826..7a2cb55 100644 --- a/docs/runware_serverless_apps_workers.md +++ b/docs/runware_serverless_apps_workers.md @@ -22,7 +22,7 @@ runware serverless apps workers [flags] ### Options ``` - --cursor string Pagination cursor from a previous nextCursor (reuse the same --status) + --cursor string Pagination cursor from a previous nextCursor -h, --help help for workers --limit int Maximum number of workers to return (1-100) --status string Filter by status (ready, busy, pending, …)