From f57b212b8ade73e280e104ca9ad68f8956ed403c Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Sun, 9 Aug 2026 15:00:40 -0400 Subject: [PATCH 1/3] feat(cli): add --output json to list commands Agents driving `cerebrium deploy` have to scrape fixed-width text tables to learn anything about a deployment. Only `status` supported `--output json`. Add a shared output-format helper in internal/ui and wire it through apps list/get, containers list, runs list, projects list, secrets list and files ls. JSON emits the api structs as-is, including the fields the tables drop. `status` is refactored onto the helper rather than keeping its own copy. `secrets list --output json` omits values unless --show-values is passed, so it leaks no more than the table does. The spinner is skipped for JSON output so no frames land in the payload stream. Co-Authored-By: Claude Opus 5 (1M context) --- internal/api/client.go | 6 ++-- internal/commands/apps/get.go | 38 ++++++++++++++++++++++++ internal/commands/apps/list.go | 18 ++++++++++-- internal/commands/containers/list.go | 16 +++++++++-- internal/commands/files/ls.go | 15 ++++++++-- internal/commands/projects/list.go | 18 ++++++++++-- internal/commands/runs/list.go | 15 ++++++++-- internal/commands/secrets/list.go | 43 ++++++++++++++++++++++------ internal/commands/status.go | 28 ++++++------------ internal/ui/output.go | 43 ++++++++++++++++++++++++++++ internal/ui/simplespinner.go | 17 +++++++++++ 11 files changed, 214 insertions(+), 43 deletions(-) create mode 100644 internal/ui/output.go diff --git a/internal/api/client.go b/internal/api/client.go index 987108b..9f2dc46 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -383,9 +383,9 @@ func (c *client) GetApp(ctx context.Context, projectID, appID string) (*AppDetai return &appDetails, nil } -// ListContainers retrieves recent containers for an app from the v2 endpoint, -// which is the same one the dashboard uses. The response includes pods that are -// being torn down — distinguished by the IsTerminating field on each record. +// ListContainers retrieves recent containers for an app. The response includes +// containers that are being torn down — distinguished by the IsTerminating field +// on each record. func (c *client) ListContainers(ctx context.Context, projectID, appID string) ([]Container, error) { path := fmt.Sprintf("v2/projects/%s/apps/%s/containers", projectID, appID) body, err := c.request(ctx, "GET", path, nil, true) diff --git a/internal/commands/apps/get.go b/internal/commands/apps/get.go index b5df90c..381c20e 100644 --- a/internal/commands/apps/get.go +++ b/internal/commands/apps/get.go @@ -19,11 +19,14 @@ func newGetCmd() *cobra.Command { Example: cerebrium apps get p-abc123 + cerebrium apps get p-abc123 --output json cerebrium apps get p-abc123 --no-ansi # Disable animations and colors`, Args: cobra.ExactArgs(1), RunE: runGet, } + ui.AddOutputFlag(cmd) + return cmd } @@ -34,6 +37,15 @@ func runGet(cmd *cobra.Command, args []string) error { appID := args[0] + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + + if outputFormat == ui.OutputJSON { + return runGetJSON(cmd, appID) + } + // Get display options from context (loaded once in root command) displayOpts, err := ui.GetDisplayConfigFromContext(cmd) if err != nil { @@ -99,3 +111,29 @@ func runGet(cmd *cobra.Command, args []string) error { return nil } + +// runGetJSON bypasses the Bubbletea view and fetches the app directly, so the +// payload is the only thing written to stdout. +func runGetJSON(cmd *cobra.Command, appID string) error { + cfg, err := config.GetConfigFromContext(cmd) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to get config: %w", err)) + } + + projectID, err := cfg.GetCurrentProject() + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to get current project: %w", err)) + } + + client, err := api.NewClient(cfg) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to create API client: %w", err)) + } + + app, err := client.GetApp(cmd.Context(), projectID, appID) + if err != nil { + return ui.NewAPIError(err) + } + + return ui.PrintJSON(app) +} diff --git a/internal/commands/apps/list.go b/internal/commands/apps/list.go index 87aedfa..06d4eb5 100644 --- a/internal/commands/apps/list.go +++ b/internal/commands/apps/list.go @@ -15,17 +15,25 @@ func newListCmd() *cobra.Command { Short: "List all apps", Long: `List all apps under your current context. -Example: - cerebrium apps list`, +Examples: + cerebrium apps list + cerebrium apps list --output json`, RunE: runList, } + ui.AddOutputFlag(cmd) + return cmd } func runList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + // Get config from context cfg, err := config.GetConfigFromContext(cmd) if err != nil { @@ -45,7 +53,7 @@ func runList(cmd *cobra.Command, args []string) error { } // Show spinner while fetching - spinner := ui.NewSimpleSpinner("Loading apps...") + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading apps...") spinner.Start() // Fetch apps @@ -55,6 +63,10 @@ func runList(cmd *cobra.Command, args []string) error { return ui.NewAPIError(err) } + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(apps) + } + // Print results if len(apps) == 0 { fmt.Printf("No apps found for project %s\n", projectID) diff --git a/internal/commands/containers/list.go b/internal/commands/containers/list.go index fa0689e..c28181d 100644 --- a/internal/commands/containers/list.go +++ b/internal/commands/containers/list.go @@ -19,19 +19,27 @@ that are currently being torn down (shown as TERMINATING). Example: cerebrium containers list my-app - cerebrium containers list p-abc12345-my-app`, + cerebrium containers list p-abc12345-my-app + cerebrium containers list my-app --output json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runList(cmd, args[0]) }, } + ui.AddOutputFlag(cmd) + return cmd } func runList(cmd *cobra.Command, appName string) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + cfg, err := config.GetConfigFromContext(cmd) if err != nil { return ui.NewValidationError(fmt.Errorf("failed to get config: %w", err)) @@ -49,7 +57,7 @@ func runList(cmd *cobra.Command, appName string) error { return ui.NewValidationError(fmt.Errorf("failed to create API client: %w", err)) } - spinner := ui.NewSimpleSpinner("Loading containers...") + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading containers...") spinner.Start() containers, err := client.ListContainers(cmd.Context(), projectID, appID) @@ -58,6 +66,10 @@ func runList(cmd *cobra.Command, appName string) error { return ui.NewAPIError(err) } + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(containers) + } + if len(containers) == 0 { fmt.Printf("No containers found for app: %s\n", appName) return nil diff --git a/internal/commands/files/ls.go b/internal/commands/files/ls.go index a35fdf7..5551ca1 100644 --- a/internal/commands/files/ls.go +++ b/internal/commands/files/ls.go @@ -22,7 +22,8 @@ func NewLsCmd() *cobra.Command { Examples: cerebrium ls # List all files in the root directory cerebrium ls sub_folder/ # List all files in a specific directory - cerebrium ls --region us-west-2 # List files in a specific region`, + cerebrium ls --region us-west-2 # List files in a specific region + cerebrium ls --output json`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runLs(cmd, args, region) @@ -30,6 +31,7 @@ Examples: } cmd.Flags().StringVarP(®ion, "region", "r", "", "Region for the storage volume") + ui.AddOutputFlag(cmd) return cmd } @@ -37,6 +39,11 @@ Examples: func runLs(cmd *cobra.Command, args []string, region string) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + // Default path is root path := "/" if len(args) > 0 { @@ -67,7 +74,7 @@ func runLs(cmd *cobra.Command, args []string, region string) error { } // Show spinner while fetching - spinner := ui.NewSimpleSpinner("Loading files...") + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading files...") spinner.Start() // Fetch files @@ -87,6 +94,10 @@ func runLs(cmd *cobra.Command, args []string, region string) error { return files[i].Name < files[j].Name }) + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(files) + } + // Print results if len(files) == 0 { fmt.Println("No files found") diff --git a/internal/commands/projects/list.go b/internal/commands/projects/list.go index 1042e4d..ba4856e 100644 --- a/internal/commands/projects/list.go +++ b/internal/commands/projects/list.go @@ -15,17 +15,25 @@ func newListCmd() *cobra.Command { Short: "List all projects", Long: `List all projects under your account. -Example: - cerebrium projects list`, +Examples: + cerebrium projects list + cerebrium projects list --output json`, RunE: runList, } + ui.AddOutputFlag(cmd) + return cmd } func runList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + // Get config from context cfg, err := config.GetConfigFromContext(cmd) if err != nil { @@ -39,7 +47,7 @@ func runList(cmd *cobra.Command, args []string) error { } // Show spinner while fetching - spinner := ui.NewSimpleSpinner("Loading projects...") + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading projects...") spinner.Start() // Fetch projects @@ -49,6 +57,10 @@ func runList(cmd *cobra.Command, args []string) error { return ui.NewAPIError(err) } + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(projects) + } + // Print results if len(projects) == 0 { fmt.Println("No projects found") diff --git a/internal/commands/runs/list.go b/internal/commands/runs/list.go index e5a06f0..e371342 100644 --- a/internal/commands/runs/list.go +++ b/internal/commands/runs/list.go @@ -21,7 +21,8 @@ func newListCmd() *cobra.Command { Examples: cerebrium runs list myapp - cerebrium runs list myapp --async # Only show async runs`, + cerebrium runs list myapp --async # Only show async runs + cerebrium runs list myapp --output json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runList(cmd, args[0], asyncOnly) @@ -29,6 +30,7 @@ Examples: } cmd.Flags().BoolVar(&asyncOnly, "async", false, "Only list runs that were executed asynchronously") + ui.AddOutputFlag(cmd) return cmd } @@ -36,6 +38,11 @@ Examples: func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + // Get config from context cfg, err := config.GetConfigFromContext(cmd) if err != nil { @@ -58,7 +65,7 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { appID := normalizeAppID(projectID, appName) // Show spinner while fetching - spinner := ui.NewSimpleSpinner("Loading runs...") + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading runs...") spinner.Start() // Fetch runs @@ -73,6 +80,10 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { return runs[i].CreatedAt.After(runs[j].CreatedAt) }) + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(runs) + } + // Print results if len(runs) == 0 { if asyncOnly { diff --git a/internal/commands/secrets/list.go b/internal/commands/secrets/list.go index 6eb3816..640639f 100644 --- a/internal/commands/secrets/list.go +++ b/internal/commands/secrets/list.go @@ -26,7 +26,8 @@ Examples: cerebrium secrets list # List project secrets (names only) cerebrium secrets list --show-values # List project secrets with values cerebrium secrets list --app my-app # List app-specific secrets - cerebrium secrets list --app my-app --show-values`, + cerebrium secrets list --app my-app --show-values + cerebrium secrets list --output json`, Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -36,13 +37,26 @@ Examples: cmd.Flags().BoolVar(&showValues, "show-values", false, "Show secret values (hidden by default)") cmd.Flags().StringVar(&appID, "app", "", "App ID to list secrets for (if not specified, lists project secrets)") + ui.AddOutputFlag(cmd) return cmd } +// jsonSecret omits Value unless --show-values was passed, so JSON output leaks no +// more than the table does. +type jsonSecret struct { + Name string `json:"name"` + Value *string `json:"value,omitempty"` +} + func runList(cmd *cobra.Command, showValues bool, appID string) error { cmd.SilenceUsage = true + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + // Load config cfg, err := config.GetConfigFromContext(cmd) if err != nil { @@ -66,7 +80,7 @@ func runList(cmd *cobra.Command, showValues bool, appID string) error { if appID != "" { spinnerMsg = fmt.Sprintf("Loading secrets for app %s...", appID) } - spinner := ui.NewSimpleSpinner(spinnerMsg) + spinner := ui.NewSimpleSpinnerFor(outputFormat, spinnerMsg) spinner.Start() // Fetch secrets (project or app level) @@ -82,12 +96,6 @@ func runList(cmd *cobra.Command, showValues bool, appID string) error { return ui.NewAPIError(err) } - // Handle empty secrets - if len(secrets) == 0 { - fmt.Println("No secrets found") - return nil - } - // Sort keys for consistent output keys := make([]string, 0, len(secrets)) for k := range secrets { @@ -95,6 +103,25 @@ func runList(cmd *cobra.Command, showValues bool, appID string) error { } sort.Strings(keys) + if outputFormat == ui.OutputJSON { + out := make([]jsonSecret, 0, len(keys)) + for _, key := range keys { + entry := jsonSecret{Name: key} + if showValues { + value := secrets[key] + entry.Value = &value + } + out = append(out, entry) + } + return ui.PrintJSON(out) + } + + // Handle empty secrets + if len(secrets) == 0 { + fmt.Println("No secrets found") + return nil + } + // Calculate max key width for alignment maxKeyWidth := len("NAME") for _, key := range keys { diff --git a/internal/commands/status.go b/internal/commands/status.go index 214b85c..c8a28f5 100644 --- a/internal/commands/status.go +++ b/internal/commands/status.go @@ -1,7 +1,6 @@ package commands import ( - "encoding/json" "fmt" "sort" "time" @@ -15,8 +14,6 @@ import ( // NewStatusCmd creates a status command func NewStatusCmd() *cobra.Command { - var outputFormat string - cmd := &cobra.Command{ Use: "status", Short: "Check Cerebrium service status", @@ -29,26 +26,24 @@ Example: cerebrium status cerebrium status --output json # Output as JSON for automation cerebrium status --no-color # Disable animations and colors`, - RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(cmd, outputFormat) - }, + RunE: runStatus, } - cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format: table, json") + ui.AddOutputFlag(cmd) return cmd } -func runStatus(cmd *cobra.Command, outputFormat string) error { +func runStatus(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true - // Validate output format - if outputFormat != "table" && outputFormat != "json" { - return ui.NewValidationError(fmt.Errorf("invalid output format: %s (supported: table, json)", outputFormat)) + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err } // For JSON output, bypass the UI and fetch data directly - if outputFormat == "json" { + if outputFormat == ui.OutputJSON { return runStatusJSON(cmd) } @@ -191,12 +186,5 @@ func runStatusJSON(cmd *cobra.Command) error { } } - // Output JSON - jsonBytes, err := json.MarshalIndent(output, "", " ") - if err != nil { - return ui.NewInternalError(fmt.Errorf("failed to marshal JSON: %w", err)) - } - - fmt.Println(string(jsonBytes)) - return nil + return ui.PrintJSON(output) } diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000..c2a2288 --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,43 @@ +package ui + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +const ( + OutputTable = "table" + OutputJSON = "json" +) + +// AddOutputFlag registers the --output/-o flag on a command. +func AddOutputFlag(cmd *cobra.Command) { + cmd.Flags().StringP("output", "o", OutputTable, "Output format: table, json") +} + +// ParseOutputFormat reads and validates the --output flag. +func ParseOutputFormat(cmd *cobra.Command) (string, error) { + format, err := cmd.Flags().GetString("output") + if err != nil { + return "", NewInternalError(fmt.Errorf("failed to read output flag: %w", err)) + } + + if format != OutputTable && format != OutputJSON { + return "", NewValidationError(fmt.Errorf("invalid output format: %s (supported: table, json)", format)) + } + + return format, nil +} + +// PrintJSON writes v to stdout as indented JSON. +func PrintJSON(v any) error { + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return NewInternalError(fmt.Errorf("failed to marshal JSON: %w", err)) + } + + fmt.Println(string(jsonBytes)) + return nil +} diff --git a/internal/ui/simplespinner.go b/internal/ui/simplespinner.go index 87c7f7d..eebbd10 100644 --- a/internal/ui/simplespinner.go +++ b/internal/ui/simplespinner.go @@ -28,8 +28,21 @@ func NewSimpleSpinner(message string) *SimpleSpinner { } } +// NewSimpleSpinnerFor creates a spinner for human-facing output. It returns nil for +// JSON output so no animation frames land in the stream carrying the payload. +func NewSimpleSpinnerFor(outputFormat, message string) *SimpleSpinner { + if outputFormat == OutputJSON { + return nil + } + return NewSimpleSpinner(message) +} + // Start begins the spinner animation func (s *SimpleSpinner) Start() { + if s == nil { + return + } + // Only show spinner if stdout is a TTY if !isatty.IsTerminal(os.Stdout.Fd()) { close(s.done) @@ -60,6 +73,10 @@ func (s *SimpleSpinner) Start() { // Stop stops the spinner animation func (s *SimpleSpinner) Stop() { + if s == nil { + return + } + close(s.stop) <-s.done } From aee5c94b2f2b007149057cac4ab638579b66c653 Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Mon, 10 Aug 2026 15:42:27 -0400 Subject: [PATCH 2/3] test(cli): cover the output format helper, secret hiding and nil spinner The output plumbing shipped with no tests, verified only by hand against a live account. That does not survive the next refactor, and one of the behaviours is security relevant. `secrets list --output json` omits values unless --show-values is passed. Nothing pinned that, so collapsing the payload back to the raw map would start printing every secret in the project while the table still hid them. Extracts the shaping into newJSONSecrets and covers it: values hidden by default, present when asked, an empty value kept distinguishable from a hidden one, key order preserved, and no secrets encoding as [] rather than null. Also covers ParseOutputFormat (defaults, both flag forms, unsupported values, and that the error names the accepted formats), PrintJSON (indentation, trailing newline, empty slice as [], nothing written when encoding fails, stable across runs), and the nil-receiver spinner guard the JSON paths depend on. Both behaviours were mutation checked: forcing values to always be included and removing the nil guard each fail the corresponding test. Co-Authored-By: Claude Opus 5 (1M context) --- internal/commands/secrets/list.go | 27 ++++-- internal/commands/secrets/list_test.go | 64 +++++++++++++ internal/ui/output_test.go | 127 +++++++++++++++++++++++++ internal/ui/spinner_test.go | 17 ++++ 4 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 internal/commands/secrets/list_test.go create mode 100644 internal/ui/output_test.go diff --git a/internal/commands/secrets/list.go b/internal/commands/secrets/list.go index 640639f..db17293 100644 --- a/internal/commands/secrets/list.go +++ b/internal/commands/secrets/list.go @@ -49,6 +49,22 @@ type jsonSecret struct { Value *string `json:"value,omitempty"` } +// newJSONSecrets builds the JSON payload in the given key order. Values are +// attached only when the caller asked to see them; without that, a name is all +// that leaves this function. +func newJSONSecrets(keys []string, secrets map[string]string, showValues bool) []jsonSecret { + out := make([]jsonSecret, 0, len(keys)) + for _, key := range keys { + entry := jsonSecret{Name: key} + if showValues { + value := secrets[key] + entry.Value = &value + } + out = append(out, entry) + } + return out +} + func runList(cmd *cobra.Command, showValues bool, appID string) error { cmd.SilenceUsage = true @@ -104,16 +120,7 @@ func runList(cmd *cobra.Command, showValues bool, appID string) error { sort.Strings(keys) if outputFormat == ui.OutputJSON { - out := make([]jsonSecret, 0, len(keys)) - for _, key := range keys { - entry := jsonSecret{Name: key} - if showValues { - value := secrets[key] - entry.Value = &value - } - out = append(out, entry) - } - return ui.PrintJSON(out) + return ui.PrintJSON(newJSONSecrets(keys, secrets, showValues)) } // Handle empty secrets diff --git a/internal/commands/secrets/list_test.go b/internal/commands/secrets/list_test.go new file mode 100644 index 0000000..3a65eff --- /dev/null +++ b/internal/commands/secrets/list_test.go @@ -0,0 +1,64 @@ +package secrets + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Hiding values is the whole reason this payload has its own shape. If someone +// later collapses jsonSecret into the raw map, JSON output starts printing every +// secret in the project while the table still hides them. +func TestNewJSONSecretsHidesValuesByDefault(t *testing.T) { + keys := []string{"API_KEY", "DB_PASSWORD"} + secrets := map[string]string{"API_KEY": "sk-live-abc123", "DB_PASSWORD": "hunter2"} + + out, err := json.Marshal(newJSONSecrets(keys, secrets, false)) + require.NoError(t, err) + + assert.JSONEq(t, `[{"name":"API_KEY"},{"name":"DB_PASSWORD"}]`, string(out)) + assert.NotContains(t, string(out), "sk-live-abc123") + assert.NotContains(t, string(out), "hunter2") +} + +func TestNewJSONSecretsIncludesValuesWhenAsked(t *testing.T) { + keys := []string{"API_KEY"} + secrets := map[string]string{"API_KEY": "sk-live-abc123"} + + out, err := json.Marshal(newJSONSecrets(keys, secrets, true)) + require.NoError(t, err) + + assert.JSONEq(t, `[{"name":"API_KEY","value":"sk-live-abc123"}]`, string(out)) +} + +// A secret genuinely set to "" is still a secret that exists. Encoding it through +// a pointer keeps it distinguishable from a hidden value rather than omitted. +func TestNewJSONSecretsKeepsEmptyValue(t *testing.T) { + out, err := json.Marshal(newJSONSecrets([]string{"EMPTY"}, map[string]string{"EMPTY": ""}, true)) + require.NoError(t, err) + + assert.JSONEq(t, `[{"name":"EMPTY","value":""}]`, string(out)) +} + +func TestNewJSONSecretsPreservesKeyOrder(t *testing.T) { + keys := []string{"A", "B", "C"} + secrets := map[string]string{"C": "3", "A": "1", "B": "2"} + + result := newJSONSecrets(keys, secrets, false) + + require.Len(t, result, 3) + assert.Equal(t, "A", result[0].Name) + assert.Equal(t, "B", result[1].Name) + assert.Equal(t, "C", result[2].Name) +} + +// No secrets must encode as [] rather than null, so a consumer can iterate the +// result without a nil check. +func TestNewJSONSecretsEncodesEmptyAsArray(t *testing.T) { + out, err := json.Marshal(newJSONSecrets(nil, map[string]string{}, false)) + require.NoError(t, err) + + assert.Equal(t, "[]", string(out)) +} diff --git a/internal/ui/output_test.go b/internal/ui/output_test.go new file mode 100644 index 0000000..8c2c576 --- /dev/null +++ b/internal/ui/output_test.go @@ -0,0 +1,127 @@ +package ui + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseOutputFormat(t *testing.T) { + tcs := []struct { + name string + args []string + want string + wantErr string + }{ + {name: "defaults to table", args: nil, want: OutputTable}, + {name: "long flag", args: []string{"--output", "json"}, want: OutputJSON}, + {name: "short flag", args: []string{"-o", "json"}, want: OutputJSON}, + {name: "explicit table", args: []string{"-o", "table"}, want: OutputTable}, + {name: "unsupported format", args: []string{"-o", "yaml"}, wantErr: "invalid output format: yaml"}, + {name: "empty format", args: []string{"-o", ""}, wantErr: "invalid output format"}, + {name: "casing is not coerced", args: []string{"-o", "JSON"}, wantErr: "invalid output format: JSON"}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }} + AddOutputFlag(cmd) + cmd.SetArgs(tc.args) + require.NoError(t, cmd.Execute()) + + got, err := ParseOutputFormat(cmd) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + // The message has to name the accepted values, or the caller is stuck guessing + assert.Contains(t, err.Error(), "table, json") + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// A command that never registered the flag should surface that as an error rather +// than silently reporting table. +func TestParseOutputFormatWithoutFlagRegistered(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + + _, err := ParseOutputFormat(cmd) + + assert.Error(t, err) +} + +func TestPrintJSON(t *testing.T) { + type payload struct { + Name string `json:"name"` + Count int `json:"count"` + } + + out := captureStdout(t, func() { + require.NoError(t, PrintJSON([]payload{{Name: "a", Count: 1}})) + }) + + assert.JSONEq(t, `[{"name":"a","count":1}]`, out) + // Indented, so a human reading raw output can follow it + assert.Contains(t, out, "\n {") + assert.True(t, len(out) > 0 && out[len(out)-1] == '\n', "should end with a newline") +} + +// An empty slice must not print as null — consumers iterate the result directly. +func TestPrintJSONEmptySlice(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintJSON([]string{})) + }) + + assert.Equal(t, "[]\n", out) +} + +func TestPrintJSONUnmarshalableValue(t *testing.T) { + out := captureStdout(t, func() { + assert.Error(t, PrintJSON(make(chan int))) + }) + + assert.Empty(t, out, "nothing should reach stdout when encoding fails") +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + original := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { os.Stdout = original }) + + fn() + + require.NoError(t, w.Close()) + captured, err := io.ReadAll(r) + require.NoError(t, err) + + return string(captured) +} + +// Guards against PrintJSON drifting to a non-deterministic encoder; agents diff +// this output between runs. +func TestPrintJSONIsStable(t *testing.T) { + value := map[string]int{"b": 2, "a": 1} + + first := captureStdout(t, func() { require.NoError(t, PrintJSON(value)) }) + second := captureStdout(t, func() { require.NoError(t, PrintJSON(value)) }) + + assert.Equal(t, first, second) + + var decoded map[string]int + require.NoError(t, json.Unmarshal([]byte(first), &decoded)) + assert.Equal(t, value, decoded) +} diff --git a/internal/ui/spinner_test.go b/internal/ui/spinner_test.go index 57f7f69..71f985b 100644 --- a/internal/ui/spinner_test.go +++ b/internal/ui/spinner_test.go @@ -74,3 +74,20 @@ func TestSpinnerModel_Update(t *testing.T) { assert.NotNil(t, updatedModel, "Update should return model") assert.NotNil(t, cmd, "Update should return next tick command") } + +// The JSON paths hold a nil spinner and call Start/Stop unconditionally, so a nil +// receiver has to be a no-op rather than a panic. +func TestSimpleSpinnerNilReceiverIsSafe(t *testing.T) { + var spinner *SimpleSpinner + + assert.NotPanics(t, func() { + spinner.Start() + spinner.Stop() + }) +} + +func TestNewSimpleSpinnerFor(t *testing.T) { + assert.Nil(t, NewSimpleSpinnerFor(OutputJSON, "loading..."), + "JSON output must not emit spinner frames into the payload stream") + assert.NotNil(t, NewSimpleSpinnerFor(OutputTable, "loading...")) +} From 8995535c111172537c5ee14bad7df0ccbd55d9f0 Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Sun, 9 Aug 2026 15:10:49 -0400 Subject: [PATCH 3/3] feat(cli): add metrics resources command for CPU, memory and GPU memory An agent that has just deployed an app has no way to tell whether the hardware in cerebrium.toml is the right size. Add `cerebrium metrics resources APP`, backed by the resource-metrics endpoint. Reports peak usage over a window: CPU in cores, memory and GPU memory (VRAM) in GB. Defaults to the last hour; --since, --start/--end, --container-id and --resolution narrow it. Table output is a summary rather than a thousand datapoints; --output json adds the raw series alongside it. The endpoint relays raw query values, which arrive as JSON strings, and pads gaps with null, so MetricValue decodes from either form and marshals back out as a number. NaN and infinities decode as absent. A metric that never reported prints "-" rather than 0.00, which would read as a measurement rather than the absence of one. Scoped to a single container the API returns one series per metric, named after the metric itself, so summary cells are matched to columns by position and the header collapses to a single PEAK column. Also lifts normalizeAppID, which had been copied into three files, into api.NormalizeAppID, moving its test along with it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/api/appid.go | 16 ++ internal/api/appid_test.go | 66 +++++ internal/api/client.go | 28 ++ internal/api/interface.go | 1 + internal/api/metrics_test.go | 83 ++++++ internal/api/mock/client_gen.go | 80 ++++++ internal/api/types.go | 90 +++++++ internal/commands/containers/list.go | 12 +- internal/commands/metrics/resources.go | 270 ++++++++++++++++++++ internal/commands/metrics/resources_test.go | 132 ++++++++++ internal/commands/metrics/root.go | 17 ++ internal/commands/root.go | 2 + internal/commands/runs/list.go | 12 +- internal/ui/commands/runs/list.go | 15 +- internal/ui/commands/runs/list_test.go | 59 ----- 15 files changed, 788 insertions(+), 95 deletions(-) create mode 100644 internal/api/appid.go create mode 100644 internal/api/appid_test.go create mode 100644 internal/api/metrics_test.go create mode 100644 internal/commands/metrics/resources.go create mode 100644 internal/commands/metrics/resources_test.go create mode 100644 internal/commands/metrics/root.go diff --git a/internal/api/appid.go b/internal/api/appid.go new file mode 100644 index 0000000..5fa17df --- /dev/null +++ b/internal/api/appid.go @@ -0,0 +1,16 @@ +package api + +import ( + "fmt" + "strings" +) + +// NormalizeAppID ensures an app ID carries its project ID prefix, so commands can +// accept either the bare app name or the fully qualified ID. +func NormalizeAppID(projectID, appName string) string { + expectedPrefix := projectID + "-" + if strings.HasPrefix(appName, expectedPrefix) { + return appName + } + return fmt.Sprintf("%s-%s", projectID, appName) +} diff --git a/internal/api/appid_test.go b/internal/api/appid_test.go new file mode 100644 index 0000000..b1ed13a --- /dev/null +++ b/internal/api/appid_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeAppID(t *testing.T) { + tcs := []struct { + name string + projectID string + appName string + expected string + }{ + { + name: "app name without project prefix", + projectID: "dev-p-0780791d", + appName: "5-dockerfile", + expected: "dev-p-0780791d-5-dockerfile", + }, + { + name: "app name with project prefix", + projectID: "dev-p-0780791d", + appName: "dev-p-0780791d-5-dockerfile", + expected: "dev-p-0780791d-5-dockerfile", + }, + { + name: "app name with partial match", + projectID: "dev-p-0780791d", + appName: "dev-p-123-myapp", + expected: "dev-p-0780791d-dev-p-123-myapp", + }, + { + name: "simple app name", + projectID: "project-123", + appName: "myapp", + expected: "project-123-myapp", + }, + { + name: "app name already has full ID", + projectID: "project-123", + appName: "project-123-myapp-v2", + expected: "project-123-myapp-v2", + }, + { + name: "edge case - empty app name", + projectID: "project-123", + appName: "", + expected: "project-123-", + }, + { + name: "edge case - app name with only dash", + projectID: "project-123", + appName: "-test", + expected: "project-123--test", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + result := NormalizeAppID(tc.projectID, tc.appName) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 9f2dc46..e85f3b2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1067,6 +1067,34 @@ func (c *client) GetRuns(ctx context.Context, projectID, appID string, asyncOnly return response.Items, nil } +// GetResourceMetrics retrieves CPU, memory and GPU memory utilisation for an app +// over a time range. +func (c *client) GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error) { + params := url.Values{} + params.Set("start", opts.Start.UTC().Format(time.RFC3339)) + params.Set("end", opts.End.UTC().Format(time.RFC3339)) + if opts.ContainerID != "" { + params.Set("container_id", opts.ContainerID) + } + if opts.Resolution != "" { + params.Set("resolution", opts.Resolution) + } + + path := fmt.Sprintf("v2/projects/%s/apps/%s/resource-metrics?%s", projectID, appID, params.Encode()) + + body, err := c.request(ctx, "GET", path, nil, true) + if err != nil { + return nil, err + } + + var metrics ResourceMetrics + if err := json.Unmarshal(body, &metrics); err != nil { + return nil, fmt.Errorf("failed to parse resource metrics response: %w", err) + } + + return &metrics, nil +} + // ListSecrets retrieves the secrets for a project func (c *client) ListSecrets(ctx context.Context, projectID string) (map[string]string, error) { path := fmt.Sprintf("v2/projects/%s/secrets", projectID) diff --git a/internal/api/interface.go b/internal/api/interface.go index 7416db7..0c66310 100644 --- a/internal/api/interface.go +++ b/internal/api/interface.go @@ -10,6 +10,7 @@ type Client interface { GetProjects(ctx context.Context) ([]Project, error) GetRuns(ctx context.Context, projectID, appID string, asyncOnly bool) ([]Run, error) ListContainers(ctx context.Context, projectID, appID string) ([]Container, error) + GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error) FetchAppLogs(ctx context.Context, projectID, appID string, opts AppLogOptions) (*AppLogsResponse, error) // Deploy methods diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go new file mode 100644 index 0000000..494a1a1 --- /dev/null +++ b/internal/api/metrics_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetricValueUnmarshal(t *testing.T) { + tcs := []struct { + name string + input string + wantValid bool + wantValue float64 + wantErr bool + }{ + {name: "quoted value, as the API sends it", input: `"1.0432586960842736"`, wantValid: true, wantValue: 1.0432586960842736}, + {name: "padded gap", input: `null`, wantValid: false}, + {name: "empty string", input: `""`, wantValid: false}, + {name: "bare number", input: `2.5`, wantValid: true, wantValue: 2.5}, + {name: "quoted zero is a real measurement", input: `"0"`, wantValid: true, wantValue: 0}, + {name: "quoted NaN counts as no sample", input: `"NaN"`, wantValid: false}, + {name: "quoted infinity counts as no sample", input: `"+Inf"`, wantValid: false}, + {name: "unparseable string", input: `"not-a-number"`, wantErr: true}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + var value MetricValue + err := json.Unmarshal([]byte(tc.input), &value) + + if tc.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValid, value.Valid) + if tc.wantValid { + assert.Equal(t, tc.wantValue, value.Value) + } + }) + } +} + +// Values go out as numbers regardless of how they came in, so consumers of our +// JSON can compare them without unquoting first. +func TestMetricValueMarshalsAsNumber(t *testing.T) { + var series ChartSeries + require.NoError(t, json.Unmarshal([]byte(`{"name":"Max","data":["1.5",null,"2"]}`), &series)) + + out, err := json.Marshal(series) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Max","data":[1.5,null,2]}`, string(out)) +} + +func TestResourceMetricsUnmarshal(t *testing.T) { + body := `{ + "cpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": ["0.5", "1.5"]}]}, + "memory": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, "8"]}]}, + "gpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, null]}]}, + "containers": {"timestamps": [1], "series": []}, + "requests": {"timestamps": [1], "series": []} + }` + + var metrics ResourceMetrics + require.NoError(t, json.Unmarshal([]byte(body), &metrics)) + + assert.Equal(t, []int64{1, 2}, metrics.CPU.Timestamps) + require.Len(t, metrics.CPU.Series, 1) + assert.Equal(t, "Max", metrics.CPU.Series[0].Name) + assert.True(t, metrics.CPU.Series[0].Data[1].Valid) + assert.Equal(t, 1.5, metrics.CPU.Series[0].Data[1].Value) + + assert.False(t, metrics.Memory.Series[0].Data[0].Valid) + assert.True(t, metrics.Memory.Series[0].Data[1].Valid) + + // A GPU series that never reported stays absent rather than reading as zero. + assert.False(t, metrics.GPU.Series[0].Data[0].Valid) + assert.False(t, metrics.GPU.Series[0].Data[1].Valid) +} diff --git a/internal/api/mock/client_gen.go b/internal/api/mock/client_gen.go index bf42122..3029a74 100644 --- a/internal/api/mock/client_gen.go +++ b/internal/api/mock/client_gen.go @@ -1271,6 +1271,86 @@ func (_c *MockClient_GetProjects_Call) RunAndReturn(run func(ctx context.Context return _c } +// GetResourceMetrics provides a mock function for the type MockClient +func (_mock *MockClient) GetResourceMetrics(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions) (*api.ResourceMetrics, error) { + ret := _mock.Called(ctx, projectID, appID, opts) + + if len(ret) == 0 { + panic("no return value specified for GetResourceMetrics") + } + + var r0 *api.ResourceMetrics + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, api.ResourceMetricsOptions) (*api.ResourceMetrics, error)); ok { + return returnFunc(ctx, projectID, appID, opts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, api.ResourceMetricsOptions) *api.ResourceMetrics); ok { + r0 = returnFunc(ctx, projectID, appID, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.ResourceMetrics) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, api.ResourceMetricsOptions) error); ok { + r1 = returnFunc(ctx, projectID, appID, opts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetResourceMetrics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetResourceMetrics' +type MockClient_GetResourceMetrics_Call struct { + *mock.Call +} + +// GetResourceMetrics is a helper method to define mock.On call +// - ctx context.Context +// - projectID string +// - appID string +// - opts api.ResourceMetricsOptions +func (_e *MockClient_Expecter) GetResourceMetrics(ctx interface{}, projectID interface{}, appID interface{}, opts interface{}) *MockClient_GetResourceMetrics_Call { + return &MockClient_GetResourceMetrics_Call{Call: _e.mock.On("GetResourceMetrics", ctx, projectID, appID, opts)} +} + +func (_c *MockClient_GetResourceMetrics_Call) Run(run func(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions)) *MockClient_GetResourceMetrics_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 api.ResourceMetricsOptions + if args[3] != nil { + arg3 = args[3].(api.ResourceMetricsOptions) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockClient_GetResourceMetrics_Call) Return(resourceMetrics *api.ResourceMetrics, err error) *MockClient_GetResourceMetrics_Call { + _c.Call.Return(resourceMetrics, err) + return _c +} + +func (_c *MockClient_GetResourceMetrics_Call) RunAndReturn(run func(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions) (*api.ResourceMetrics, error)) *MockClient_GetResourceMetrics_Call { + _c.Call.Return(run) + return _c +} + // GetRunStatus provides a mock function for the type MockClient func (_mock *MockClient) GetRunStatus(ctx context.Context, projectID string, appName string, runID string) (*api.RunStatus, error) { ret := _mock.Called(ctx, projectID, appName, runID) diff --git a/internal/api/types.go b/internal/api/types.go index a53b959..2a06e5d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -377,6 +379,94 @@ type Container struct { IsTerminating bool `json:"isTerminating"` } +// MetricValue is a single sample in a metric series. The API relays raw query +// values, which arrive as JSON strings, and pads gaps with null — so it decodes +// from either form. It marshals back out as a plain number (or null) so our own +// JSON output is directly comparable. +type MetricValue struct { + Value float64 + Valid bool +} + +func (m *MetricValue) UnmarshalJSON(b []byte) error { + s := string(b) + if s == "null" { + return nil + } + + if strings.HasPrefix(s, `"`) { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + if str == "" { + return nil + } + value, err := strconv.ParseFloat(str, 64) + if err != nil { + return fmt.Errorf("failed to parse metric value %q: %w", str, err) + } + m.set(value) + return nil + } + + var value float64 + if err := json.Unmarshal(b, &value); err != nil { + return err + } + m.set(value) + return nil +} + +// set records a sample, treating NaN and infinities as absent — they mean the +// query had nothing to report, and they are not representable in JSON. +func (m *MetricValue) set(value float64) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return + } + m.Value = value + m.Valid = true +} + +func (m MetricValue) MarshalJSON() ([]byte, error) { + if !m.Valid { + return []byte("null"), nil + } + return json.Marshal(m.Value) +} + +// ChartSeries is one named line of a resource metric. Data is parallel to the +// enclosing ChartData's Timestamps; entries are invalid where the range was +// padded because no sample existed. +type ChartSeries struct { + Name string `json:"name"` + Data []MetricValue `json:"data"` +} + +// ChartData is a time series for a single resource metric. At app level it carries +// a P50, P90 and Max series; scoped to one container it carries a single series. +type ChartData struct { + Timestamps []int64 `json:"timestamps"` + Series []ChartSeries `json:"series"` +} + +// ResourceMetrics holds utilisation time series for an app. The endpoint also +// returns container-count and request-concurrency charts, which the CLI does not +// surface yet. +type ResourceMetrics struct { + CPU ChartData `json:"cpu"` // cores + Memory ChartData `json:"memory"` // GB of RAM + GPU ChartData `json:"gpu"` // GB of GPU memory (VRAM) +} + +// ResourceMetricsOptions contains parameters for fetching resource metrics +type ResourceMetricsOptions struct { + Start time.Time + End time.Time + ContainerID string // Scope to a single container (optional) + Resolution string // "medium" or "high" for more data points (optional) +} + // AppBuild represents a build for a Cerebrium application type AppBuild struct { Id string `json:"id"` diff --git a/internal/commands/containers/list.go b/internal/commands/containers/list.go index c28181d..d494e4b 100644 --- a/internal/commands/containers/list.go +++ b/internal/commands/containers/list.go @@ -2,7 +2,6 @@ package containers import ( "fmt" - "strings" "github.com/cerebriumai/cerebrium/internal/api" "github.com/cerebriumai/cerebrium/internal/ui" @@ -50,7 +49,7 @@ func runList(cmd *cobra.Command, appName string) error { return ui.NewValidationError(fmt.Errorf("no project selected: %w", err)) } - appID := normalizeAppID(projectID, appName) + appID := api.NormalizeAppID(projectID, appName) client, err := api.NewClient(cfg) if err != nil { @@ -92,12 +91,3 @@ func runList(cmd *cobra.Command, appName string) error { return nil } - -// normalizeAppID ensures the app ID has the project ID prefix. -func normalizeAppID(projectID, appName string) string { - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - return fmt.Sprintf("%s-%s", projectID, appName) -} diff --git a/internal/commands/metrics/resources.go b/internal/commands/metrics/resources.go new file mode 100644 index 0000000..479cac5 --- /dev/null +++ b/internal/commands/metrics/resources.go @@ -0,0 +1,270 @@ +package metrics + +import ( + "fmt" + "time" + + "github.com/cerebriumai/cerebrium/internal/api" + "github.com/cerebriumai/cerebrium/internal/ui" + "github.com/cerebriumai/cerebrium/pkg/config" + "github.com/spf13/cobra" +) + +type resourceFlags struct { + since time.Duration + start string + end string + containerID string + resolution string +} + +// seriesPeak is the highest value a single series reached over the window. Peak is +// nil when the series carried no samples at all. +type seriesPeak struct { + Name string `json:"name"` + Peak *float64 `json:"peak"` +} + +type metricSummary struct { + Unit string `json:"unit"` + Series []seriesPeak `json:"series"` +} + +type metricsOutput struct { + AppID string `json:"appId"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + ContainerID string `json:"containerId,omitempty"` + Summary map[string]metricSummary `json:"summary"` + Metrics *api.ResourceMetrics `json:"metrics"` +} + +func newResourcesCmd() *cobra.Command { + var flags resourceFlags + + cmd := &cobra.Command{ + Use: "resources APP_NAME", + Short: "Show CPU, memory and GPU memory usage for an app", + Long: `Show how much CPU, memory and GPU memory (VRAM) an app actually used over a +time window, so you can right-size the hardware in your cerebrium.toml. + +Values are peaks over the window: CPU in cores, memory and GPU memory in GB. + +Examples: + cerebrium metrics resources my-app + cerebrium metrics resources my-app --since 24h + cerebrium metrics resources my-app --start 2026-08-01T00:00:00Z --end 2026-08-02T00:00:00Z + cerebrium metrics resources my-app --container-id + cerebrium metrics resources my-app --output json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runResources(cmd, args[0], flags) + }, + } + + cmd.Flags().DurationVar(&flags.since, "since", time.Hour, "Window ending now (ignored if --start is set)") + cmd.Flags().StringVar(&flags.start, "start", "", "Start of the window as an RFC3339 timestamp") + cmd.Flags().StringVar(&flags.end, "end", "", "End of the window as an RFC3339 timestamp (defaults to now)") + cmd.Flags().StringVar(&flags.containerID, "container-id", "", "Scope metrics to a single container") + cmd.Flags().StringVar(&flags.resolution, "resolution", "", "Data resolution: medium, high") + ui.AddOutputFlag(cmd) + + return cmd +} + +func runResources(cmd *cobra.Command, appName string, flags resourceFlags) error { + cmd.SilenceUsage = true + + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + + if flags.resolution != "" && flags.resolution != "medium" && flags.resolution != "high" { + return ui.NewValidationError(fmt.Errorf("invalid resolution: %s (supported: medium, high)", flags.resolution)) + } + + start, end, err := resolveWindow(flags) + if err != nil { + return ui.NewValidationError(err) + } + + cfg, err := config.GetConfigFromContext(cmd) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to get config: %w", err)) + } + + projectID, err := cfg.GetCurrentProject() + if err != nil { + return ui.NewValidationError(fmt.Errorf("no project selected: %w", err)) + } + + client, err := api.NewClient(cfg) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to create API client: %w", err)) + } + + appID := api.NormalizeAppID(projectID, appName) + + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading metrics...") + spinner.Start() + + metrics, err := client.GetResourceMetrics(cmd.Context(), projectID, appID, api.ResourceMetricsOptions{ + Start: start, + End: end, + ContainerID: flags.containerID, + Resolution: flags.resolution, + }) + spinner.Stop() + if err != nil { + return ui.NewAPIError(err) + } + + summary := map[string]metricSummary{ + "cpu": summarise(metrics.CPU, "cores"), + "memory": summarise(metrics.Memory, "GB"), + "gpu": summarise(metrics.GPU, "GB"), + } + + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(metricsOutput{ + AppID: appID, + Start: start, + End: end, + ContainerID: flags.containerID, + Summary: summary, + Metrics: metrics, + }) + } + + printSummary(appID, start, end, flags.containerID, summary) + return nil +} + +// resolveWindow turns the time flags into a concrete range. --start wins over +// --since; --end defaults to now. +func resolveWindow(flags resourceFlags) (time.Time, time.Time, error) { + end := time.Now().UTC() + if flags.end != "" { + parsed, err := time.Parse(time.RFC3339, flags.end) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --end timestamp %q: expected RFC3339 (e.g. 2026-08-01T00:00:00Z)", flags.end) + } + end = parsed.UTC() + } + + var start time.Time + if flags.start != "" { + parsed, err := time.Parse(time.RFC3339, flags.start) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --start timestamp %q: expected RFC3339 (e.g. 2026-08-01T00:00:00Z)", flags.start) + } + start = parsed.UTC() + } else { + if flags.since <= 0 { + return time.Time{}, time.Time{}, fmt.Errorf("--since must be positive, got %s", flags.since) + } + start = end.Add(-flags.since) + } + + if !end.After(start) { + return time.Time{}, time.Time{}, fmt.Errorf("end of window (%s) must be after its start (%s)", end.Format(time.RFC3339), start.Format(time.RFC3339)) + } + + return start, end, nil +} + +// summarise reduces each series to its peak over the window. Padded gaps are nil +// and are skipped, so a series that never reported stays nil rather than becoming 0. +func summarise(data api.ChartData, unit string) metricSummary { + out := metricSummary{Unit: unit, Series: make([]seriesPeak, 0, len(data.Series))} + + for _, series := range data.Series { + entry := seriesPeak{Name: series.Name} + for _, sample := range series.Data { + if !sample.Valid { + continue + } + if entry.Peak == nil || sample.Value > *entry.Peak { + peak := sample.Value + entry.Peak = &peak + } + } + out.Series = append(out.Series, entry) + } + + return out +} + +var metricRows = []struct { + key string + label string +}{ + {"cpu", "CPU"}, + {"memory", "Memory"}, + {"gpu", "GPU Memory"}, +} + +func printSummary(appID string, start, end time.Time, containerID string, summary map[string]metricSummary) { + scope := "app" + if containerID != "" { + scope = "container " + containerID + } + fmt.Printf("Peak usage for %s (%s) from %s to %s\n\n", + appID, scope, start.Format(time.RFC3339), end.Format(time.RFC3339)) + + columns := summaryColumns(summary) + if len(columns) == 0 { + fmt.Println("No metrics reported for this window. The app may not have run during it.") + return + } + + fmt.Printf("%-14s", "METRIC") + for _, column := range columns { + fmt.Printf("%10s", column) + } + fmt.Printf(" %s\n", "UNIT") + + for _, row := range metricRows { + metric := summary[row.key] + fmt.Printf("%-14s", row.label) + for i := range columns { + fmt.Printf("%10s", formatPeak(metric, i)) + } + fmt.Printf(" %s\n", metric.Unit) + } +} + +// summaryColumns derives the header from the first metric that reported anything. +// Every metric carries the same series in the same order for a given scope, so +// cells are matched to columns by position. +func summaryColumns(summary map[string]metricSummary) []string { + for _, row := range metricRows { + series := summary[row.key].Series + if len(series) == 0 { + continue + } + + // Scoped to a single container the API returns one series per metric, named + // after the metric itself — a poor shared header, so label the column PEAK. + if len(series) == 1 { + return []string{"PEAK"} + } + + columns := make([]string, 0, len(series)) + for _, entry := range series { + columns = append(columns, entry.Name) + } + return columns + } + return nil +} + +// formatPeak renders one cell. A metric that never reported shows "-" rather than +// 0.00, which would read as a measurement rather than an absence of one. +func formatPeak(metric metricSummary, column int) string { + if column >= len(metric.Series) || metric.Series[column].Peak == nil { + return "-" + } + return fmt.Sprintf("%.2f", *metric.Series[column].Peak) +} diff --git a/internal/commands/metrics/resources_test.go b/internal/commands/metrics/resources_test.go new file mode 100644 index 0000000..c413b19 --- /dev/null +++ b/internal/commands/metrics/resources_test.go @@ -0,0 +1,132 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/cerebriumai/cerebrium/internal/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sample(value float64) api.MetricValue { + return api.MetricValue{Value: value, Valid: true} +} + +func TestSummarise(t *testing.T) { + data := api.ChartData{ + Timestamps: []int64{1, 2, 3}, + Series: []api.ChartSeries{ + {Name: "Max", Data: []api.MetricValue{sample(1.5), {}, sample(3.25)}}, + {Name: "P50", Data: []api.MetricValue{{}, {}, {}}}, + }, + } + + got := summarise(data, "cores") + + assert.Equal(t, "cores", got.Unit) + require.Len(t, got.Series, 2) + + assert.Equal(t, "Max", got.Series[0].Name) + require.NotNil(t, got.Series[0].Peak) + assert.Equal(t, 3.25, *got.Series[0].Peak) + + // A series of nothing but padding has no peak — not a peak of zero. + assert.Equal(t, "P50", got.Series[1].Name) + assert.Nil(t, got.Series[1].Peak) +} + +func TestSummariseKeepsRealZero(t *testing.T) { + data := api.ChartData{ + Series: []api.ChartSeries{{Name: "Max", Data: []api.MetricValue{sample(0)}}}, + } + + got := summarise(data, "GB") + + require.NotNil(t, got.Series[0].Peak) + assert.Equal(t, float64(0), *got.Series[0].Peak) +} + +func TestFormatPeak(t *testing.T) { + peak := 2.5 + metric := metricSummary{Series: []seriesPeak{ + {Name: "Max", Peak: &peak}, + {Name: "P50", Peak: nil}, + }} + + assert.Equal(t, "2.50", formatPeak(metric, 0)) + assert.Equal(t, "-", formatPeak(metric, 1)) + assert.Equal(t, "-", formatPeak(metric, 2), "a metric with fewer series than columns") +} + +func TestSummaryColumns(t *testing.T) { + t.Run("app scope uses the percentile series names", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: []seriesPeak{{Name: "Max"}, {Name: "P50"}, {Name: "P90"}}}, + } + assert.Equal(t, []string{"Max", "P50", "P90"}, summaryColumns(summary)) + }) + + // Scoped to a container the API names each metric's sole series after the + // metric, so the header must not be borrowed from whichever metric came first. + t.Run("container scope collapses to one PEAK column", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: []seriesPeak{{Name: "CPU"}}}, + "memory": {Series: []seriesPeak{{Name: "Memory"}}}, + } + assert.Equal(t, []string{"PEAK"}, summaryColumns(summary)) + }) + + t.Run("skips metrics that reported nothing", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: nil}, + "memory": {Series: []seriesPeak{{Name: "Max"}, {Name: "P50"}}}, + } + assert.Equal(t, []string{"Max", "P50"}, summaryColumns(summary)) + }) + + t.Run("no data at all yields no columns", func(t *testing.T) { + assert.Empty(t, summaryColumns(map[string]metricSummary{"cpu": {}})) + }) +} + +func TestResolveWindow(t *testing.T) { + t.Run("since is measured back from now", func(t *testing.T) { + before := time.Now().UTC() + start, end, err := resolveWindow(resourceFlags{since: 2 * time.Hour}) + require.NoError(t, err) + + assert.WithinDuration(t, before, end, time.Minute) + assert.WithinDuration(t, before.Add(-2*time.Hour), start, time.Minute) + }) + + t.Run("explicit start wins over since", func(t *testing.T) { + start, end, err := resolveWindow(resourceFlags{ + since: time.Hour, + start: "2026-08-01T00:00:00Z", + end: "2026-08-02T00:00:00Z", + }) + require.NoError(t, err) + + assert.Equal(t, "2026-08-01T00:00:00Z", start.Format(time.RFC3339)) + assert.Equal(t, "2026-08-02T00:00:00Z", end.Format(time.RFC3339)) + }) + + t.Run("rejects a backwards window", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{ + start: "2026-08-02T00:00:00Z", + end: "2026-08-01T00:00:00Z", + }) + assert.ErrorContains(t, err, "must be after") + }) + + t.Run("rejects a non-positive since", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{since: 0}) + assert.ErrorContains(t, err, "must be positive") + }) + + t.Run("rejects a malformed timestamp", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{start: "yesterday"}) + assert.ErrorContains(t, err, "RFC3339") + }) +} diff --git a/internal/commands/metrics/root.go b/internal/commands/metrics/root.go new file mode 100644 index 0000000..a3cfe4d --- /dev/null +++ b/internal/commands/metrics/root.go @@ -0,0 +1,17 @@ +package metrics + +import ( + "github.com/spf13/cobra" +) + +func NewMetricsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics", + Short: "Inspect resource usage for an app", + Long: "Commands for inspecting how much hardware your Cerebrium apps are actually using", + } + + cmd.AddCommand(newResourcesCmd()) + + return cmd +} diff --git a/internal/commands/root.go b/internal/commands/root.go index f1f5375..656a140 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -10,6 +10,7 @@ import ( configCmd "github.com/cerebriumai/cerebrium/internal/commands/config" containersCmd "github.com/cerebriumai/cerebrium/internal/commands/containers" filesCmd "github.com/cerebriumai/cerebrium/internal/commands/files" + metricsCmd "github.com/cerebriumai/cerebrium/internal/commands/metrics" projectCmd "github.com/cerebriumai/cerebrium/internal/commands/projects" regionCmd "github.com/cerebriumai/cerebrium/internal/commands/region" runsCmd "github.com/cerebriumai/cerebrium/internal/commands/runs" @@ -111,6 +112,7 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(NewStatusCmd()) rootCmd.AddCommand(NewLogsCmd()) rootCmd.AddCommand(containersCmd.NewContainersCmd()) + rootCmd.AddCommand(metricsCmd.NewMetricsCmd()) rootCmd.AddCommand(NewVersionCmd()) rootCmd.AddCommand(configCmd.NewConfigCmd()) rootCmd.AddCommand(appCmd.NewAppsCmd()) diff --git a/internal/commands/runs/list.go b/internal/commands/runs/list.go index e371342..4a5b7d9 100644 --- a/internal/commands/runs/list.go +++ b/internal/commands/runs/list.go @@ -3,7 +3,6 @@ package runs import ( "fmt" "sort" - "strings" "github.com/cerebriumai/cerebrium/internal/api" "github.com/cerebriumai/cerebrium/internal/ui" @@ -62,7 +61,7 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { } // Construct the app ID - appID := normalizeAppID(projectID, appName) + appID := api.NormalizeAppID(projectID, appName) // Show spinner while fetching spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading runs...") @@ -112,12 +111,3 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { return nil } - -// normalizeAppID ensures the app ID has the correct format. -func normalizeAppID(projectID, appName string) string { - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - return fmt.Sprintf("%s-%s", projectID, appName) -} diff --git a/internal/ui/commands/runs/list.go b/internal/ui/commands/runs/list.go index 578a09c..f195449 100644 --- a/internal/ui/commands/runs/list.go +++ b/internal/ui/commands/runs/list.go @@ -271,7 +271,7 @@ func (m *ListView) fetchRuns() tea.Msg { // Construct the app ID, handling both formats: // - "5-dockerfile" -> "dev-p-0780791d-5-dockerfile" // - "dev-p-0780791d-5-dockerfile" -> "dev-p-0780791d-5-dockerfile" - appID := normalizeAppID(m.conf.ProjectID, m.conf.AppName) + appID := api.NormalizeAppID(m.conf.ProjectID, m.conf.AppName) // Call the API to fetch runs runs, err := m.conf.Client.GetRuns(m.ctx, m.conf.ProjectID, appID, m.conf.AsyncOnly) @@ -283,19 +283,6 @@ func (m *ListView) fetchRuns() tea.Msg { // Utils -// normalizeAppID ensures the app ID has the correct format. -// If the appName already starts with the projectID prefix, use it as-is. -// Otherwise, prepend the projectID. -func normalizeAppID(projectID, appName string) string { - // Check if appName already has the project ID prefix - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - // Prepend the project ID - return fmt.Sprintf("%s-%s", projectID, appName) -} - func newTable(rows []table.Row) table.Model { // Calculate dynamic column widths based on content // Add padding for potential ANSI codes and better spacing diff --git a/internal/ui/commands/runs/list_test.go b/internal/ui/commands/runs/list_test.go index fb61060..3842678 100644 --- a/internal/ui/commands/runs/list_test.go +++ b/internal/ui/commands/runs/list_test.go @@ -354,62 +354,3 @@ func TestRunsListView(t *testing.T) { Run(t) }) } - -func Test_normalizeAppID(t *testing.T) { - tcs := []struct { - name string - projectID string - appName string - expected string - }{ - { - name: "app name without project prefix", - projectID: "dev-p-0780791d", - appName: "5-dockerfile", - expected: "dev-p-0780791d-5-dockerfile", - }, - { - name: "app name with project prefix", - projectID: "dev-p-0780791d", - appName: "dev-p-0780791d-5-dockerfile", - expected: "dev-p-0780791d-5-dockerfile", - }, - { - name: "app name with partial match", - projectID: "dev-p-0780791d", - appName: "dev-p-123-myapp", - expected: "dev-p-0780791d-dev-p-123-myapp", - }, - { - name: "simple app name", - projectID: "project-123", - appName: "myapp", - expected: "project-123-myapp", - }, - { - name: "app name already has full ID", - projectID: "project-123", - appName: "project-123-myapp-v2", - expected: "project-123-myapp-v2", - }, - { - name: "edge case - empty app name", - projectID: "project-123", - appName: "", - expected: "project-123-", - }, - { - name: "edge case - app name with only dash", - projectID: "project-123", - appName: "-test", - expected: "project-123--test", - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - result := normalizeAppID(tc.projectID, tc.appName) - assert.Equal(t, tc.expected, result) - }) - } -}