Skip to content
Open
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
1 change: 1 addition & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions internal/ui/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -858,20 +866,23 @@ type appCreatedMsg struct {
type zipUploadedMsg struct{}

type buildStatusUpdateMsg struct {
buildID string
status string
buildID string
status string
initError string
}

type buildStatusPollErrorMsg struct {
err error
}

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 {
Expand Down Expand Up @@ -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,
}
}

Expand Down
81 changes: 81 additions & 0 deletions internal/ui/commands/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"context"
"errors"
"strings"
"testing"

"github.com/cerebriumai/cerebrium/internal/api"
Expand Down Expand Up @@ -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)
})
}
Loading