fix(cli): preserve deploy error chains
- commit
- 243a5c1bd192710f76a753f680b8c1ef502f685b
- author
- brice · · 2026-04-06 14:28 UTC
- parent
- f231d9c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +49 -15
M internal/adapters/in/cli/deploy.go +11 -1
| @@ -15,6 +15,8 @@ type deployer interface { | |||
| 15 | 15 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | var sendDeploySignal = app.SendDeploySignal | |
| 19 | + | ||
| 18 | 20 | // newDeployCmd creates the deploy command. | |
| 19 | 21 | func newDeployCmd() *cobra.Command { | |
| 20 | 22 | var jsonOut bool | |
| @@ -50,8 +52,16 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma | |||
| 50 | 52 | result, err := deployer.Deploy(ctx, deployDomain) | |
| 51 | 53 | if err != nil { | |
| 52 | 54 | if isRemote && shouldFallbackToLocal(err) { | |
| 53 | - | domain, localErr := app.SendDeploySignal(deployDomain) | |
| 55 | + | domain, localErr := sendDeploySignal(deployDomain) | |
| 54 | 56 | if localErr == nil { | |
| 57 | + | warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err) | |
| 58 | + | if jsonOut { | |
| 59 | + | return writeJSON(out, map[string]string{ | |
| 60 | + | "warning": warning, | |
| 61 | + | "domain": domain, | |
| 62 | + | "status": "success", | |
| 63 | + | }) | |
| 64 | + | } | |
| 55 | 65 | if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil { | |
| 56 | 66 | return writeErr | |
| 57 | 67 | } | |
| @@ -15,6 +15,8 @@ type deployer interface { | |||||
| 15 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | 15 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | + | var sendDeploySignal = app.SendDeploySignal | |||
| 19 | + | ||||
| 18 | // newDeployCmd creates the deploy command. | 20 | // newDeployCmd creates the deploy command. | ||
| 19 | func newDeployCmd() *cobra.Command { | 21 | func newDeployCmd() *cobra.Command { | ||
| 20 | var jsonOut bool | 22 | var jsonOut bool | ||
| @@ -50,8 +52,16 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma | |||||
| 50 | result, err := deployer.Deploy(ctx, deployDomain) | 52 | result, err := deployer.Deploy(ctx, deployDomain) | ||
| 51 | if err != nil { | 53 | if err != nil { | ||
| 52 | if isRemote && shouldFallbackToLocal(err) { | 54 | if isRemote && shouldFallbackToLocal(err) { | ||
| 53 | - | domain, localErr := app.SendDeploySignal(deployDomain) | 55 | + | domain, localErr := sendDeploySignal(deployDomain) |
| 54 | if localErr == nil { | 56 | if localErr == nil { | ||
| 57 | + | warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err) | |||
| 58 | + | if jsonOut { | |||
| 59 | + | return writeJSON(out, map[string]string{ | |||
| 60 | + | "warning": warning, | |||
| 61 | + | "domain": domain, | |||
| 62 | + | "status": "success", | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 55 | if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil { | 65 | if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil { | ||
| 56 | return writeErr | 66 | return writeErr | ||
| 57 | } | 67 | } | ||
M internal/adapters/in/cli/deploy_error.go +2 -2
| @@ -16,12 +16,12 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) | |||
| 16 | 16 | func formatDeployFailure(err error) error { | |
| 17 | 17 | var deployErr *domain.DeployFailureError | |
| 18 | 18 | if errors.As(err, &deployErr) { | |
| 19 | - | return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs)) | |
| 19 | + | return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr) | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | var httpErr *remote.HTTPError | |
| 23 | 23 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | |
| 24 | - | return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs)) | |
| 24 | + | return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr) | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | return fmt.Errorf("failed to deploy: %w", err) | |
| @@ -16,12 +16,12 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) | |||||
| 16 | func formatDeployFailure(err error) error { | 16 | func formatDeployFailure(err error) error { | ||
| 17 | var deployErr *domain.DeployFailureError | 17 | var deployErr *domain.DeployFailureError | ||
| 18 | if errors.As(err, &deployErr) { | 18 | if errors.As(err, &deployErr) { | ||
| 19 | - | return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs)) | 19 | + | return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr) |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | var httpErr *remote.HTTPError | 22 | var httpErr *remote.HTTPError | ||
| 23 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | 23 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | ||
| 24 | - | return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs)) | 24 | + | return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr) |
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | return fmt.Errorf("failed to deploy: %w", err) | 27 | return fmt.Errorf("failed to deploy: %w", err) | ||
M internal/adapters/in/cli/deploy_error_test.go +16 -9
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | |
| 15 | - | err := formatDeployFailure(&remote.HTTPError{ | |
| 15 | + | root := &remote.HTTPError{ | |
| 16 | 16 | StatusCode: 500, | |
| 17 | 17 | Status: "500 Internal Server Error", | |
| 18 | 18 | Body: "failed to deploy", | |
| @@ -22,14 +22,16 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | |||
| 22 | 22 | "booting app", | |
| 23 | 23 | "connection refused", | |
| 24 | 24 | }, | |
| 25 | - | }) | |
| 25 | + | } | |
| 26 | + | err := formatDeployFailure(root) | |
| 26 | 27 | ||
| 27 | 28 | require.Error(t, err) | |
| 28 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | |
| 29 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") | |
| 30 | + | assert.ErrorIs(t, err, root) | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 31 | 33 | func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | |
| 32 | - | err := formatDeployFailure(&domain.DeployFailureError{ | |
| 34 | + | root := &domain.DeployFailureError{ | |
| 33 | 35 | Summary: "failed to deploy", | |
| 34 | 36 | Cause: "health check failed", | |
| 35 | 37 | Hint: "check DATABASE_URL", | |
| @@ -37,10 +39,12 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | |||
| 37 | 39 | "booting app", | |
| 38 | 40 | "connection refused", | |
| 39 | 41 | }, | |
| 40 | - | }) | |
| 42 | + | } | |
| 43 | + | err := formatDeployFailure(root) | |
| 41 | 44 | ||
| 42 | 45 | require.Error(t, err) | |
| 43 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | |
| 46 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") | |
| 47 | + | assert.ErrorIs(t, err, root) | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | func TestFormatDeployFailure_GenericFallback(t *testing.T) { | |
| @@ -53,12 +57,15 @@ func TestFormatDeployFailure_GenericFallback(t *testing.T) { | |||
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | 59 | func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) { | |
| 56 | - | err := formatDeployFailure(&domain.DeployFailureError{ | |
| 60 | + | root := &domain.DeployFailureError{ | |
| 57 | 61 | Summary: "failed to deploy", | |
| 58 | 62 | Cause: "health check failed", | |
| 59 | 63 | Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"}, | |
| 60 | - | }) | |
| 64 | + | } | |
| 65 | + | err := formatDeployFailure(root) | |
| 61 | 66 | ||
| 62 | 67 | require.Error(t, err) | |
| 63 | - | assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error()) | |
| 68 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed") | |
| 69 | + | assert.NotContains(t, err.Error(), "Recent container logs:") | |
| 70 | + | assert.ErrorIs(t, err, root) | |
| 64 | 71 | } | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | 14 | func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | ||
| 15 | - | err := formatDeployFailure(&remote.HTTPError{ | 15 | + | root := &remote.HTTPError{ |
| 16 | StatusCode: 500, | 16 | StatusCode: 500, | ||
| 17 | Status: "500 Internal Server Error", | 17 | Status: "500 Internal Server Error", | ||
| 18 | Body: "failed to deploy", | 18 | Body: "failed to deploy", | ||
| @@ -22,14 +22,16 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | |||||
| 22 | "booting app", | 22 | "booting app", | ||
| 23 | "connection refused", | 23 | "connection refused", | ||
| 24 | }, | 24 | }, | ||
| 25 | - | }) | 25 | + | } |
| 26 | + | err := formatDeployFailure(root) | |||
| 26 | 27 | ||||
| 27 | require.Error(t, err) | 28 | require.Error(t, err) | ||
| 28 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | 29 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") |
| 30 | + | assert.ErrorIs(t, err, root) | |||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 31 | func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | 33 | func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | ||
| 32 | - | err := formatDeployFailure(&domain.DeployFailureError{ | 34 | + | root := &domain.DeployFailureError{ |
| 33 | Summary: "failed to deploy", | 35 | Summary: "failed to deploy", | ||
| 34 | Cause: "health check failed", | 36 | Cause: "health check failed", | ||
| 35 | Hint: "check DATABASE_URL", | 37 | Hint: "check DATABASE_URL", | ||
| @@ -37,10 +39,12 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | |||||
| 37 | "booting app", | 39 | "booting app", | ||
| 38 | "connection refused", | 40 | "connection refused", | ||
| 39 | }, | 41 | }, | ||
| 40 | - | }) | 42 | + | } |
| 43 | + | err := formatDeployFailure(root) | |||
| 41 | 44 | ||||
| 42 | require.Error(t, err) | 45 | require.Error(t, err) | ||
| 43 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | 46 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") |
| 47 | + | assert.ErrorIs(t, err, root) | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | func TestFormatDeployFailure_GenericFallback(t *testing.T) { | 50 | func TestFormatDeployFailure_GenericFallback(t *testing.T) { | ||
| @@ -53,12 +57,15 @@ func TestFormatDeployFailure_GenericFallback(t *testing.T) { | |||||
| 53 | } | 57 | } | ||
| 54 | 58 | ||||
| 55 | func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) { | 59 | func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) { | ||
| 56 | - | err := formatDeployFailure(&domain.DeployFailureError{ | 60 | + | root := &domain.DeployFailureError{ |
| 57 | Summary: "failed to deploy", | 61 | Summary: "failed to deploy", | ||
| 58 | Cause: "health check failed", | 62 | Cause: "health check failed", | ||
| 59 | Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"}, | 63 | Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"}, | ||
| 60 | - | }) | 64 | + | } |
| 65 | + | err := formatDeployFailure(root) | |||
| 61 | 66 | ||||
| 62 | require.Error(t, err) | 67 | require.Error(t, err) | ||
| 63 | - | assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error()) | 68 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed") |
| 69 | + | assert.NotContains(t, err.Error(), "Recent container logs:") | |||
| 70 | + | assert.ErrorIs(t, err, root) | |||
| 64 | } | 71 | } | ||
M internal/adapters/in/cli/deploy_test.go +20 -3
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| @@ -22,15 +23,17 @@ func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResu | |||
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | |
| 25 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: &domain.DeployFailureError{ | |
| 26 | + | root := &domain.DeployFailureError{ | |
| 26 | 27 | Summary: "failed to deploy", | |
| 27 | 28 | Cause: "health check failed", | |
| 28 | 29 | Hint: "check DATABASE_URL", | |
| 29 | 30 | Logs: []string{"booting app", "connection refused"}, | |
| 30 | - | }}, false, "app.example.com", &bytes.Buffer{}, false) | |
| 31 | + | } | |
| 32 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false) | |
| 31 | 33 | ||
| 32 | 34 | require.Error(t, err) | |
| 33 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | |
| 35 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") | |
| 36 | + | assert.ErrorIs(t, err, root) | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | |
| @@ -45,3 +48,17 @@ func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | |||
| 45 | 48 | assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)") | |
| 46 | 49 | assert.NotContains(t, out.String(), "requested.example.com") | |
| 47 | 50 | } | |
| 51 | + | ||
| 52 | + | func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) { | |
| 53 | + | originalSendDeploySignal := sendDeploySignal | |
| 54 | + | sendDeploySignal = func(string) (string, error) { | |
| 55 | + | return "app.example.com", nil | |
| 56 | + | } | |
| 57 | + | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 58 | + | ||
| 59 | + | var out bytes.Buffer | |
| 60 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true) | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String()) | |
| 64 | + | } | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| @@ -22,15 +23,17 @@ func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResu | |||||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | 25 | func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | ||
| 25 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: &domain.DeployFailureError{ | 26 | + | root := &domain.DeployFailureError{ |
| 26 | Summary: "failed to deploy", | 27 | Summary: "failed to deploy", | ||
| 27 | Cause: "health check failed", | 28 | Cause: "health check failed", | ||
| 28 | Hint: "check DATABASE_URL", | 29 | Hint: "check DATABASE_URL", | ||
| 29 | Logs: []string{"booting app", "connection refused"}, | 30 | Logs: []string{"booting app", "connection refused"}, | ||
| 30 | - | }}, false, "app.example.com", &bytes.Buffer{}, false) | 31 | + | } |
| 32 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false) | |||
| 31 | 33 | ||||
| 32 | require.Error(t, err) | 34 | require.Error(t, err) | ||
| 33 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | 35 | + | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") |
| 36 | + | assert.ErrorIs(t, err, root) | |||
| 34 | } | 37 | } | ||
| 35 | 38 | ||||
| 36 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | 39 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | ||
| @@ -45,3 +48,17 @@ func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | |||||
| 45 | assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)") | 48 | assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)") | ||
| 46 | assert.NotContains(t, out.String(), "requested.example.com") | 49 | assert.NotContains(t, out.String(), "requested.example.com") | ||
| 47 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) { | |||
| 53 | + | originalSendDeploySignal := sendDeploySignal | |||
| 54 | + | sendDeploySignal = func(string) (string, error) { | |||
| 55 | + | return "app.example.com", nil | |||
| 56 | + | } | |||
| 57 | + | defer func() { sendDeploySignal = originalSendDeploySignal }() | |||
| 58 | + | ||||
| 59 | + | var out bytes.Buffer | |||
| 60 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String()) | |||
| 64 | + | } | |||