diff --git a/internal/api/types.go b/internal/api/types.go index d0ab6ce..d92591b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -382,6 +382,7 @@ type AppBuild struct { 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 ff2d561..3693089 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -463,7 +463,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} } } @@ -541,15 +541,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 != "" { + 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 != "" { + cmds = append(cmds, tea.Println(""), tea.Println(msg.initError)) + } + cmds = append(cmds, tea.Quit) + return m, tea.Sequence(cmds...) } case confirmationResponseMsg: @@ -858,8 +866,9 @@ type appCreatedMsg struct { type zipUploadedMsg struct{} type buildStatusUpdateMsg struct { - buildID string - status string + buildID string + status string + initError string } type buildStatusPollErrorMsg struct { @@ -867,11 +876,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 { @@ -1220,8 +1231,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, } } 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) + }) +}