From d756f521d4cad48e089db7fa64e0711d9d577f03 Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Thu, 7 May 2026 15:18:38 -0400 Subject: [PATCH 1/4] feat: surface initError on the build when deploy fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a deploy hits a terminal failure (e.g. capacity unavailable, init_failure), the backend now writes a user-facing message to the build's initError field synchronously. The CLI was polling the build status but discarding initError, so the user only saw "✗ Build failed with status: init_failure" with no actionable detail. This wires initError through buildStatusUpdateMsg → buildCompleteMsg → logDrainCompleteMsg and prints it after the failure line in both SimpleOutput and interactive modes. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/types.go | 9 +++++---- internal/ui/commands/deploy.go | 34 +++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index a53b959..856389b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -379,10 +379,11 @@ type Container struct { // AppBuild represents a build for a Cerebrium application type AppBuild struct { - Id string `json:"id"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Id string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + InitError *string `json:"initError,omitempty"` } // BaseImagePayload represents the payload for creating a base image diff --git a/internal/ui/commands/deploy.go b/internal/ui/commands/deploy.go index d506a3a..e7da15f 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -485,7 +485,7 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ui.IsTerminalStatus(msg.status) { // Terminal status detected, trigger completion return m, func() tea.Msg { - return buildCompleteMsg{status: msg.status} + return buildCompleteMsg{status: msg.status, initError: msg.initError} } } @@ -506,7 +506,7 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Wait 2 seconds to allow remaining logs to arrive return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { - return logDrainCompleteMsg(msg) + return logDrainCompleteMsg{status: msg.status, initError: msg.initError} }) case logDrainCompleteMsg: @@ -563,15 +563,23 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.conf.SimpleOutput() { fmt.Printf("✗ Build failed with status: %s\n", msg.status) + if msg.initError != nil && *msg.initError != "" { + fmt.Println() + fmt.Println(*msg.initError) + } return m, tea.Quit } // Print error message to scrollback in interactive mode - return m, tea.Sequence( + cmds := []tea.Cmd{ tea.Println(""), tea.Println(ui.ErrorStyle.Render(fmt.Sprintf("✗ Build failed with status: %s", msg.status))), - tea.Quit, - ) + } + if msg.initError != nil && *msg.initError != "" { + cmds = append(cmds, tea.Println(""), tea.Println(*msg.initError)) + } + cmds = append(cmds, tea.Quit) + return m, tea.Sequence(cmds...) } case confirmationResponseMsg: @@ -880,8 +888,9 @@ type appCreatedMsg struct { type zipUploadedMsg struct{} type buildStatusUpdateMsg struct { - buildID string - status string + buildID string + status string + initError *string } type buildStatusPollErrorMsg struct { @@ -889,11 +898,13 @@ type buildStatusPollErrorMsg struct { } type buildCompleteMsg struct { - status string + status string + initError *string } type logDrainCompleteMsg struct { - status string // Final build status to use for completion + status string // Final build status to use for completion + initError *string // User-facing error message persisted on the build } type buildCancelledMsg struct { @@ -1213,8 +1224,9 @@ func (m *DeployView) pollBuildStatus() tea.Msg { // Return status update message return buildStatusUpdateMsg{ - buildID: build.Id, - status: build.Status, + buildID: build.Id, + status: build.Status, + initError: build.InitError, } } From a7d78099add54efb2a8194f67ab471d464c32ecc Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Fri, 8 May 2026 14:43:22 -0400 Subject: [PATCH 2/4] review: use string instead of *string for InitError No functional difference between nil and empty in this case, so the non-pointer type is simpler to work with. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/types.go | 10 +++++----- internal/ui/commands/deploy.go | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index 856389b..5d25733 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -379,11 +379,11 @@ type Container struct { // AppBuild represents a build for a Cerebrium application type AppBuild struct { - Id string `json:"id"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - InitError *string `json:"initError,omitempty"` + Id string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + InitError string `json:"initError,omitempty"` } // BaseImagePayload represents the payload for creating a base image diff --git a/internal/ui/commands/deploy.go b/internal/ui/commands/deploy.go index e7da15f..228f873 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -563,9 +563,9 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.conf.SimpleOutput() { fmt.Printf("✗ Build failed with status: %s\n", msg.status) - if msg.initError != nil && *msg.initError != "" { + if msg.initError != "" { fmt.Println() - fmt.Println(*msg.initError) + fmt.Println(msg.initError) } return m, tea.Quit } @@ -575,8 +575,8 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Println(""), tea.Println(ui.ErrorStyle.Render(fmt.Sprintf("✗ Build failed with status: %s", msg.status))), } - if msg.initError != nil && *msg.initError != "" { - cmds = append(cmds, tea.Println(""), tea.Println(*msg.initError)) + if msg.initError != "" { + cmds = append(cmds, tea.Println(""), tea.Println(msg.initError)) } cmds = append(cmds, tea.Quit) return m, tea.Sequence(cmds...) @@ -890,7 +890,7 @@ type zipUploadedMsg struct{} type buildStatusUpdateMsg struct { buildID string status string - initError *string + initError string } type buildStatusPollErrorMsg struct { @@ -899,12 +899,12 @@ type buildStatusPollErrorMsg struct { type buildCompleteMsg struct { status string - initError *string + initError string } type logDrainCompleteMsg struct { - status string // Final build status to use for completion - initError *string // User-facing error message persisted on the build + status string // Final build status to use for completion + initError string // User-facing error message persisted on the build } type buildCancelledMsg struct { From 4f7133afb2e8591625ea7339cd72c4827aabf1cd Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Fri, 8 May 2026 14:46:43 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20convert=20buildCompleteMsg=20?= =?UTF-8?q?=E2=86=92=20logDrainCompleteMsg=20via=20type=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both struct types share the same fields, so use Go's type conversion as the original code did rather than a struct literal (staticcheck S1016). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/ui/commands/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/commands/deploy.go b/internal/ui/commands/deploy.go index 228f873..7e2285e 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -506,7 +506,7 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Wait 2 seconds to allow remaining logs to arrive return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { - return logDrainCompleteMsg{status: msg.status, initError: msg.initError} + return logDrainCompleteMsg(msg) }) case logDrainCompleteMsg: From 3fcf12265f437e3feb9d6ff3490f209f063e021a Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Tue, 11 Aug 2026 17:27:48 -0400 Subject: [PATCH 4/4] test(deploy): cover initError reaching the failure output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message is only carried by the poll → complete → drain messages and printed to scrollback, so a dropped field is invisible to the existing tests. Drive a failing build from GetBuild through to stdout in simple output mode, and pin that an empty initError prints nothing extra. Co-Authored-By: Claude Opus 5 (1M context) --- internal/ui/commands/deploy_test.go | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/internal/ui/commands/deploy_test.go b/internal/ui/commands/deploy_test.go index 96258f0..c594eec 100644 --- a/internal/ui/commands/deploy_test.go +++ b/internal/ui/commands/deploy_test.go @@ -3,6 +3,7 @@ package commands import ( "context" "errors" + "strings" "testing" "github.com/cerebriumai/cerebrium/internal/api" @@ -1179,3 +1180,83 @@ func TestDeployView_View(t *testing.T) { assert.Contains(t, view, "cancelled") }) } + +// TestDeployView_initErrorReachesOutput covers the initError plumbing from the +// build status response through to the failure output. The message is only +// carried by the poll → complete → drain messages, so a dropped field is +// otherwise invisible. +func TestDeployView_initErrorReachesOutput(t *testing.T) { + newFailingDeploy := func(t *testing.T, initError string) *DeployView { + t.Helper() + + mockClient := apimock.NewMockClient(t) + mockClient.EXPECT().GetBuild(mock.Anything, "test-project", "test-project-fail-app", "build-fail"). + Return(&api.AppBuild{ + Id: "build-fail", + Status: "init_failure", + InitError: initError, + }, nil) + + model := NewDeployView(t.Context(), DeployConfig{ + DisplayConfig: ui.DisplayConfig{ + IsInteractive: false, + DisableAnimation: true, + }, + Config: &projectconfig.ProjectConfig{ + Deployment: projectconfig.DeploymentConfig{Name: "fail-app"}, + }, + ProjectID: "test-project", + Client: mockClient, + }) + model.state = StateBuildingApp + model.buildID = "build-fail" + + return model + } + + t.Run("initError is printed after the failure line", func(t *testing.T) { + const initError = "No capacity available for the requested compute. Try a different region." + + model := newFailingDeploy(t, initError) + + statusMsg, ok := model.pollBuildStatus().(buildStatusUpdateMsg) + require.True(t, ok, "pollBuildStatus should report a status update") + require.Equal(t, initError, statusMsg.initError, "initError dropped between GetBuild and the status message") + + output := captureStdout(t, func() { + uitesting.NewTestHarness(t, model). + Step(uitesting.TestStep[*DeployView]{ + Name: "build_failed_with_init_error", + Msg: statusMsg, + ModelAssert: func(t *testing.T, m *DeployView) { + assert.Equal(t, StateDeployError, m.state) + }, + }). + Run(t) + }) + + failureLine := strings.Index(output, "Build failed with status: init_failure") + detail := strings.Index(output, initError) + require.NotEqual(t, -1, failureLine, "failure line missing from output:\n%s", output) + require.NotEqual(t, -1, detail, "initError missing from output:\n%s", output) + assert.Less(t, failureLine, detail, "initError should follow the failure line") + }) + + t.Run("no initError prints only the failure line", func(t *testing.T) { + model := newFailingDeploy(t, "") + + statusMsg, ok := model.pollBuildStatus().(buildStatusUpdateMsg) + require.True(t, ok, "pollBuildStatus should report a status update") + + output := captureStdout(t, func() { + uitesting.NewTestHarness(t, model). + Step(uitesting.TestStep[*DeployView]{ + Name: "build_failed_without_init_error", + Msg: statusMsg, + }). + Run(t) + }) + + assert.Equal(t, "✗ Build failed with status: init_failure\n", output) + }) +}