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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,52 @@ const exampleMain = `def run(prompt: str):
# cerebrium deploy
`

// agentsTemplate is written to AGENTS.md. Coding agents read this file at the
// start of a session without being asked, which is the only reliable way they
// learn the CLI has commands worth running after a deploy — they do not go
// looking through --help for capabilities they have no reason to expect.
const agentsTemplate = `# %[1]s

A Cerebrium app. ` + "`main.py`" + ` holds the functions that get served; ` + "`cerebrium.toml`" + ` declares
hardware, scaling and dependencies.

## Deploying

cerebrium deploy

A deploy builds a new image and rolls it out. It takes minutes, not seconds, and
the app is not serving the new code until it finishes.

## Checking a deploy worked

Do not assume a deploy is live because the command exited 0. Verify it:

cerebrium containers list %[1]s # what is running right now
cerebrium logs %[1]s # runtime logs
cerebrium runs list %[1]s # recent invocations and their status
cerebrium apps get %[1]s # configured hardware, scaling, build status

Every command above accepts ` + "`--output json`" + ` for machine-readable output. Prefer it
over parsing the table format.

## Right-sizing the hardware

cerebrium metrics resources %[1]s --since 24h

Reports peak CPU (cores), memory (GB) and GPU memory (GB) over the window, so you
can compare against ` + "`[cerebrium.hardware]`" + ` in cerebrium.toml and adjust. A metric
that reports ` + "`-`" + ` had no samples in the window, which is not the same as zero.

## Things that surprise people

- ` + "`min_replicas = 0`" + ` scales the app to zero when idle, so the first request after a
quiet period pays a cold start. ` + "`cerebrium containers list`" + ` returning nothing is
normal for an idle app, not a failed deploy.
- Secrets belong in ` + "`cerebrium secrets`" + `, never in cerebrium.toml — that file is
committed and uploaded with the build.
- Changing ` + "`[cerebrium.hardware]`" + ` or dependencies requires a redeploy to take effect.
`

// NewInitCmd creates a new init command
func NewInitCmd() *cobra.Command {
var dir string
Expand Down Expand Up @@ -101,6 +147,7 @@ func runInit(cmd *cobra.Command, name string, dir string) error {
projectPath := filepath.Join(dir, name)
tomlPath := filepath.Join(projectPath, "cerebrium.toml")
mainPath := filepath.Join(projectPath, "main.py")
agentsPath := filepath.Join(projectPath, "AGENTS.md")

// Verify the resulting path is safe (no path traversal)
absDir, err := filepath.Abs(dir)
Expand Down Expand Up @@ -145,6 +192,13 @@ func runInit(cmd *cobra.Command, name string, dir string) error {
return ui.NewFileSystemError(fmt.Errorf("failed to create cerebrium.toml: %w", err))
}

// Create AGENTS.md so coding agents working in this project know how to
// verify a deploy and size the hardware
agentsContent := fmt.Sprintf(agentsTemplate, name)
if err := os.WriteFile(agentsPath, []byte(agentsContent), 0644); err != nil { //nolint:gosec // Project files need to be readable
return ui.NewFileSystemError(fmt.Errorf("failed to create AGENTS.md: %w", err))
}

fmt.Println("Cerebrium Cortex project initialized successfully!")
fmt.Printf("cd %s && cerebrium deploy to get started\n", projectPath)

Expand Down
11 changes: 11 additions & 0 deletions internal/commands/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ func TestRunInit(t *testing.T) {
assert.Contains(t, string(mainContent), "Running on Cerebrium")
assert.Contains(t, string(mainContent), "cerebrium deploy")

// Check AGENTS.md exists and names the commands an agent needs after a deploy
agentsPath := filepath.Join(projectPath, "AGENTS.md")
agentsContent, err := os.ReadFile(agentsPath)
require.NoError(t, err)
assert.Contains(t, string(agentsContent), "# "+projectName)
assert.Contains(t, string(agentsContent), "cerebrium containers list "+projectName)
assert.Contains(t, string(agentsContent), "cerebrium metrics resources "+projectName)
assert.Contains(t, string(agentsContent), "--output json")
// A stray format verb would ship a broken file to every new project
assert.NotContains(t, string(agentsContent), "%!")

// Check requirements.txt does NOT exist (dependencies are in cerebrium.toml)
requirementsPath := filepath.Join(projectPath, "requirements.txt")
_, err = os.Stat(requirementsPath)
Expand Down
59 changes: 53 additions & 6 deletions internal/ui/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ type DeployView struct {
}

// NewDeployView creates a new deploy view
// nextStepLines renders the commands worth running once an app is live. A deploy
// exiting 0 only means the build succeeded, so the useful next move is to check
// what is actually running and how much hardware it is using.
func nextStepLines(appName string, colorize bool) []string {
steps := []struct {
command string
description string
}{
{fmt.Sprintf("cerebrium logs %s", appName), "stream runtime logs"},
{fmt.Sprintf("cerebrium containers list %s", appName), "see what is running"},
{fmt.Sprintf("cerebrium metrics resources %s", appName), "check CPU, memory and GPU usage"},
}

width := 0
for _, step := range steps {
if len(step.command) > width {
width = len(step.command)
}
}

lines := make([]string, 0, len(steps))
for _, step := range steps {
// Pad against the uncoloured length; escape codes would skew a %-*s width
padding := strings.Repeat(" ", width-len(step.command))
command := step.command
if colorize {
command = ui.CyanStyle.Render(command)
}
lines = append(lines, " "+command+padding+" "+step.description)
}

return lines
}

func NewDeployView(ctx context.Context, conf DeployConfig) *DeployView {
initialState := StateConfirmation
isPartnerDeploy := conf.Config.PartnerService != nil
Expand Down Expand Up @@ -518,30 +552,43 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.status == "success" || msg.status == "ready" {
m.state = StateDeploySuccess

appName := m.conf.Config.Deployment.Name

if m.conf.SimpleOutput() {
fmt.Println("✓ Build complete!")
fmt.Println()
fmt.Printf("✓ %s is now live!\n", m.conf.Config.Deployment.Name)
fmt.Printf("✓ %s is now live!\n", appName)
fmt.Println()
fmt.Printf("App Dashboard: %s\n", m.appResponse.DashboardURL)
fmt.Println("\nEndpoint:")
fmt.Printf("POST %s/{function_name}\n", m.appResponse.InternalEndpoint)
fmt.Println("\nNext steps:")
for _, line := range nextStepLines(appName, false) {
fmt.Println(line)
}
return m, tea.Quit
}

// Print success message to scrollback in interactive mode
return m, tea.Sequence(
lines := []tea.Cmd{
tea.Println(""),
tea.Println(ui.SuccessStyle.Render("✓ Built app")),
tea.Println(""),
tea.Println(ui.GreenStyle.Render(fmt.Sprintf("✓ %s is now live!", m.conf.Config.Deployment.Name))),
tea.Println(ui.GreenStyle.Render(fmt.Sprintf("✓ %s is now live!", appName))),
tea.Println(""),
tea.Println(fmt.Sprintf("App Dashboard: %s", m.appResponse.DashboardURL)),
tea.Println(""),
tea.Println("Endpoint:"),
tea.Println(ui.CyanStyle.Render("POST")+" "+m.appResponse.InternalEndpoint+"/{function_name}"),
tea.Quit,
)
tea.Println(ui.CyanStyle.Render("POST") + " " + m.appResponse.InternalEndpoint + "/{function_name}"),
tea.Println(""),
tea.Println("Next steps:"),
}
for _, line := range nextStepLines(appName, true) {
lines = append(lines, tea.Println(line))
}
lines = append(lines, tea.Quit)

return m, tea.Sequence(lines...)
} else {
m.state = StateDeployError
err := ui.NewAPIError(fmt.Errorf("build failed with status: %s", msg.status))
Expand Down
63 changes: 63 additions & 0 deletions internal/ui/commands/deploy_nextsteps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package commands

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNextStepLines(t *testing.T) {
lines := nextStepLines("my-app", false)
require.Len(t, lines, 3)

joined := strings.Join(lines, "\n")
assert.Contains(t, joined, "cerebrium logs my-app")
assert.Contains(t, joined, "cerebrium containers list my-app")
assert.Contains(t, joined, "cerebrium metrics resources my-app")
}

// Descriptions line up because padding is measured on the uncoloured command.
// Padding a string that already carries escape codes would skew every row, so
// the coloured variant has to align identically to the plain one.
func TestNextStepLinesAlignDescriptions(t *testing.T) {
descriptions := []string{
"stream runtime logs",
"see what is running",
"check CPU, memory and GPU usage",
}

for _, colorize := range []bool{false, true} {
lines := nextStepLines("my-app", colorize)
require.Len(t, lines, len(descriptions))

var columns []int
for i, line := range lines {
column := strings.Index(stripANSI(line), descriptions[i])
require.NotEqual(t, -1, column, "description %q missing from %q", descriptions[i], line)
columns = append(columns, column)
}

for _, column := range columns {
assert.Equal(t, columns[0], column,
"descriptions should start at the same column (colorize=%v)", colorize)
}
}
}

func stripANSI(s string) string {
var out strings.Builder
inEscape := false
for _, r := range s {
switch {
case r == '\x1b':
inEscape = true
case inEscape && r == 'm':
inEscape = false
case !inEscape:
out.WriteRune(r)
}
}
return out.String()
}
Loading