From 9b705e3086525178c22d2bafca87ad2ae15bc0c5 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 4 Sep 2026 23:55:52 +0100 Subject: [PATCH] feat(serverless): list app deploy, scaling, audit, and error events Surface GET /v1/apps/{appId}/events so operators can inspect the control-plane trail without a logs API. Co-authored-by: Cursor --- docs/runware_serverless_apps.md | 1 + docs/runware_serverless_apps_events.md | 50 ++++++++++++++ internal/api/serverless/client.go | 39 +++++++++++ internal/api/serverless/client_test.go | 91 +++++++++++++++++++++++++ internal/cmd/serverless/apps.go | 69 +++++++++++++++++++ internal/cmd/serverless/display.go | 26 +++++++ internal/cmd/serverless/display_test.go | 88 ++++++++++++++++++++++-- 7 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 docs/runware_serverless_apps_events.md diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index df6ffda..c09c547 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -32,6 +32,7 @@ runware serverless apps [flags] * [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 env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application +* [runware serverless apps events](runware_serverless_apps_events.md) - List events for a serverless application * [runware serverless apps invoke](runware_serverless_apps_invoke.md) - Invoke an application endpoint * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications * [runware serverless apps logs](runware_serverless_apps_logs.md) - Show logs for a serverless application diff --git a/docs/runware_serverless_apps_events.md b/docs/runware_serverless_apps_events.md new file mode 100644 index 0000000..7a541db --- /dev/null +++ b/docs/runware_serverless_apps_events.md @@ -0,0 +1,50 @@ +## runware serverless apps events + +List events for a serverless application + +### Synopsis + +List deploy, scaling, audit, and error events for an application. + +Events are the control-plane audit trail, not worker stdout. Live log +streaming is not available (apps logs is not implemented). + +``` +runware serverless apps events [flags] +``` + +### Examples + +``` + # list events for an application + runware serverless apps events my-app + + # errors only + runware serverless apps events my-app --type error --limit 20 + + # page through results + runware serverless apps events my-app --type error --limit 20 --cursor +``` + +### Options + +``` + --cursor string Pagination cursor from a previous nextCursor + -h, --help help for events + --limit int Maximum number of events to return (1-100) + --type string Filter by type (deploy, scaling, audit, or error) +``` + +### 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 + diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 3b72b13..7f91caf 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -97,6 +97,15 @@ type BuildStatus = gen.BuildStatus // ListWorkersParams are optional filters for ListWorkers. type ListWorkersParams = gen.ListWorkersParams +// AppEvent is a deploy, scaling, audit, or error event on an app. +type AppEvent = gen.AppEvent + +// AppEventType is an AppEvent.type value. +type AppEventType = gen.AppEventType + +// ListAppEventsParams are optional filters for ListAppEvents. +type ListAppEventsParams = gen.ListAppEventsParams + // Task is a serverless invocation. type Task = gen.Task @@ -698,6 +707,36 @@ func (c *Client) ListWorkers(ctx context.Context, appID string, params *ListWork } } +// ListAppEvents returns a page of deploy, scaling, audit, and error events for an app. +func (c *Client) ListAppEvents(ctx context.Context, appID string, params *ListAppEventsParams) (Page[AppEvent], error) { + if c.apiKey == "" { + return Page[AppEvent]{}, transport.ErrNoAPIKey + } + + resp, err := c.inner.ListAppEventsWithResponse(ctx, appID, params) + if err != nil { + return Page[AppEvent]{}, fmt.Errorf("list app events: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return pageOf[AppEvent](nil, nil), nil + } + return pageOf(resp.JSON200.Data, resp.JSON200.NextCursor), nil + case http.StatusUnauthorized: + return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + default: + return Page[AppEvent]{}, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // NewCodeAppSource builds an appSource for a code-based create. func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { var source gen.AppSourceUpsert_Source diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index fa2e9ae..af695a4 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -1106,3 +1106,94 @@ func TestListWorkers(t *testing.T) { t.Fatalf("unexpected workers: %+v", page.Data) } } + +const ( + testEventID = "55555555-5555-5555-5555-555555555555" + testWorkerID = "44444444-4444-4444-4444-444444444444" + testEndpointID = "66666666-6666-6666-6666-666666666666" + testEventType = "error" +) + +func TestListAppEvents(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/apps/" + testAppID + "/events" + 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("type"); got != testEventType { + t.Errorf("type query = %q, want %s", got, testEventType) + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Errorf("limit query = %q, want 20", 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":"` + testEventID + `", + "appId":"my-app", + "type":"` + testEventType + `", + "message":"worker failed to start", + "workerId":"` + testWorkerID + `", + "endpointId":"` + testEndpointID + `", + "createdAt":"2026-07-30T12:00:00Z" + }],"nextCursor":"` + testCursorPage3 + `"}`)) + })) + defer srv.Close() + + eventType := AppEventType(testEventType) + limit := Limit(20) + cursor := Cursor(testCursorPage2) + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + page, err := c.ListAppEvents(context.Background(), testAppID, &ListAppEventsParams{ + Type: &eventType, + Limit: &limit, + Cursor: &cursor, + }) + if err != nil { + t.Fatalf("ListAppEvents: %v", err) + } + if len(page.Data) != 1 { + t.Fatalf("unexpected events: %+v", page.Data) + } + ev := page.Data[0] + if ev.Id.String() != testEventID || string(ev.Type) != testEventType || ev.Message != "worker failed to start" { + t.Errorf("unexpected event: %+v", ev) + } + if ev.WorkerId == nil || ev.WorkerId.String() != testWorkerID { + t.Errorf("unexpected workerId: %+v", ev.WorkerId) + } + if ev.EndpointId == nil || ev.EndpointId.String() != testEndpointID { + t.Errorf("unexpected endpointId: %+v", ev.EndpointId) + } + if page.NextCursor == nil || *page.NextCursor != testCursorPage3 { + t.Errorf("unexpected nextCursor: %+v", page.NextCursor) + } +} + +func TestListAppEvents_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 app exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.ListAppEvents(context.Background(), testAppID, nil) + 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 TestListAppEvents_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.ListAppEvents(context.Background(), testAppID, nil); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 36285ba..e16abf0 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -29,6 +29,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsVersionsCmd(logger), newAppsBuildsCmd(logger), newAppsLogsCmd(), + newAppsEventsCmd(logger), newAppsWorkersCmd(logger), newAppsScaleCmd(logger), newAppsUsageCmd(), @@ -197,6 +198,66 @@ func newAppsLogsCmd() *cobra.Command { ) } +func newAppsEventsCmd(logger *log.Logger) *cobra.Command { + var ( + limit int + cursor string + eventType string + ) + + cmd := &cobra.Command{ + Use: "events ", + Short: "List events for a serverless application", + Long: `List deploy, scaling, audit, and error events for an application. + +Events are the control-plane audit trail, not worker stdout. Live log +streaming is not available (apps logs is not implemented).`, + Example: ` # list events for an application + runware serverless apps events my-app + + # errors only + runware serverless apps events my-app --type error --limit 20 + + # page through results + runware serverless apps events my-app --type error --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] + typeVal, err := parseAppEventType(eventType) + if err != nil { + return err + } + var params *serverlessapi.ListAppEventsParams + if limit > 0 || cursor != "" || eventType != "" { + params = &serverlessapi.ListAppEventsParams{} + params.Limit, params.Cursor = listPageParams(limit, cursor) + params.Type = typeVal + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching events for %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.ListAppEvents(cmd.Context(), id, params) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printPage(cmdutil.FormatFor(cmd), page, eventsResult(page.Data), cmd.ErrOrStderr(), extraTypeCursorFlag(eventType)) + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of events to return (1-100)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + cmd.Flags().StringVar(&eventType, "type", "", "Filter by type (deploy, scaling, audit, or error)") + return cmd +} + func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { var ( limit int @@ -293,6 +354,10 @@ func parseWorkerStatus(status string) (*serverlessapi.WorkerStatus, error) { return parseValidFlag[serverlessapi.WorkerStatus]("--status", status, "pending, pulling, loading, ready, busy, unhealthy, draining, stopping, or stopped") } +func parseAppEventType(value string) (*serverlessapi.AppEventType, error) { + return parseValidFlag[serverlessapi.AppEventType]("--type", value, "deploy, scaling, audit, or error") +} + // 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) @@ -308,6 +373,10 @@ func extraStatusCursorFlag(value string) string { return strings.Join(appendFlag(nil, "--status", value), " ") } +func extraTypeCursorFlag(value string) string { + return strings.Join(appendFlag(nil, "--type", value), " ") +} + func appendFlag(parts []string, name, value string) []string { if value == "" { return parts diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index eaa9524..bd547dd 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -26,6 +26,10 @@ const ( colEnvVar = "Env var" colError = "Error" colCompleted = "Completed" + colTime = "Time" + colMessage = "Message" + colWorker = "Worker" + colEndpoint = "Endpoint" colComputeType = "Compute type" colGPUType = "GPU type" @@ -149,6 +153,28 @@ func (r versionResult) Rows() [][]any { } } +// eventsResult wraps app event lists for table display. +type eventsResult []serverlessapi.AppEvent + +func (r eventsResult) Headers() []string { + return []string{colTime, colType, colMessage, colWorker, colEndpoint} +} + +func (r eventsResult) Rows() [][]any { + rows := make([][]any, len(r)) + for i := range r { + ev := &r[i] + rows[i] = []any{ + formatOptionalTime(ev.CreatedAt), + string(ev.Type), + ev.Message, + formatOptionalUUID(ev.WorkerId), + formatOptionalUUID(ev.EndpointId), + } + } + return rows +} + // 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 9b1dd00..17e995e 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -12,10 +12,11 @@ import ( ) const ( - testAppID = "my-app" - testEnvKey = "MY_KEY" - testEnvValue = "hello" - testGPUType = "h100" + testAppID = "my-app" + testEnvKey = "MY_KEY" + testEnvValue = "hello" + testGPUType = "h100" + testEventType = "error" ) func TestListPageParams(t *testing.T) { @@ -156,6 +157,85 @@ func TestExtraStatusCursorFlag(t *testing.T) { } } +func TestParseAppEventType(t *testing.T) { + got, err := parseAppEventType("") + if err != nil || got != nil { + t.Fatalf("unset type: got=%v err=%v", got, err) + } + + got, err = parseAppEventType(testEventType) + if err != nil || got == nil || *got != testEventType { + t.Fatalf("%s: got=%v err=%v", testEventType, got, err) + } + + got, err = parseAppEventType("deploy") + if err != nil || got == nil || *got != "deploy" { + t.Fatalf("deploy: got=%v err=%v", got, err) + } + + _, err = parseAppEventType("nope") + if err == nil { + t.Fatal("expected error for type nope") + } + if !strings.Contains(err.Error(), "invalid --type") { + t.Fatalf("error %q should mention invalid --type", err) + } +} + +func TestExtraTypeCursorFlag(t *testing.T) { + if got := extraTypeCursorFlag(testEventType); got != "--type "+testEventType { + t.Fatalf("got %q", got) + } + if got := extraTypeCursorFlag(""); got != "" { + t.Fatalf("empty: got %q", got) + } +} + +func TestEventsResult_Rows(t *testing.T) { + created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + workerID := uuid.MustParse("44444444-4444-4444-4444-444444444444") + endpointID := uuid.MustParse("66666666-6666-6666-6666-666666666666") + r := eventsResult{ + { + Id: uuid.MustParse("55555555-5555-5555-5555-555555555555"), + AppId: testAppID, + Type: testEventType, + Message: "worker failed to start", + WorkerId: &workerID, + EndpointId: &endpointID, + CreatedAt: &created, + }, + { + Id: uuid.MustParse("77777777-7777-7777-7777-777777777777"), + AppId: testAppID, + Type: "audit", + Message: "scale updated", + }, + } + + wantHeaders := []string{colTime, colType, colMessage, colWorker, colEndpoint} + if strings.Join(r.Headers(), ",") != strings.Join(wantHeaders, ",") { + t.Fatalf("headers = %v, want %v", r.Headers(), wantHeaders) + } + + rows := r.Rows() + if len(rows) != 2 { + t.Fatalf("row count %d, want 2", len(rows)) + } + if rows[0][0] != "2026-07-30T12:00:00Z" || rows[0][1] != testEventType || rows[0][2] != "worker failed to start" { + t.Errorf("first row = %v", rows[0]) + } + if rows[0][3] != workerID.String() || rows[0][4] != endpointID.String() { + t.Errorf("first row ids = %v", rows[0]) + } + if rows[1][0] != "" || rows[1][3] != "" || rows[1][4] != "" { + t.Errorf("optional fields should be blank when absent: %v", rows[1]) + } + if rows[1][1] != "audit" || rows[1][2] != "scale updated" { + t.Errorf("second row = %v", rows[1]) + } +} + func TestAppResult_IncludesConfiguration(t *testing.T) { gpu := testGPUType created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)