fix(cli): avoid fallback for structured deploy errors
- commit
- 2f5cb8f93e597fc40e107c76c8e292bd40cd3eb1
- author
- brice · · 2026-04-06 16:40 UTC
- parent
- 243a5c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +83 -21
M internal/adapters/in/cli/deploy.go +27 -16
| @@ -51,23 +51,12 @@ Examples: | |||
| 51 | 51 | func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error { | |
| 52 | 52 | result, err := deployer.Deploy(ctx, deployDomain) | |
| 53 | 53 | if err != nil { | |
| 54 | + | if formatted, ok := structuredDeployFailure(err); ok { | |
| 55 | + | return formatted | |
| 56 | + | } | |
| 57 | + | ||
| 54 | 58 | if isRemote && shouldFallbackToLocal(err) { | |
| 55 | - | domain, localErr := sendDeploySignal(deployDomain) | |
| 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 | - | } | |
| 65 | - | if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil { | |
| 66 | - | return writeErr | |
| 67 | - | } | |
| 68 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |
| 69 | - | } | |
| 70 | - | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |
| 59 | + | return handleLocalDeployFallback(err, deployDomain, out, jsonOut) | |
| 71 | 60 | } | |
| 72 | 61 | ||
| 73 | 62 | return formatDeployFailure(err) | |
| @@ -104,3 +93,25 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma | |||
| 104 | 93 | return cliWriteLine(out, cliRenderInfo(msg)) | |
| 105 | 94 | } | |
| 106 | 95 | } | |
| 96 | + | ||
| 97 | + | func handleLocalDeployFallback(err error, deployDomain string, out io.Writer, jsonOut bool) error { | |
| 98 | + | domain, localErr := sendDeploySignal(deployDomain) | |
| 99 | + | if localErr != nil { | |
| 100 | + | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err) | |
| 104 | + | if jsonOut { | |
| 105 | + | return writeJSON(out, map[string]string{ | |
| 106 | + | "warning": warning, | |
| 107 | + | "domain": domain, | |
| 108 | + | "status": "success", | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if writeErr := cliWriteLine(out, cliRenderWarning(warning)); writeErr != nil { | |
| 113 | + | return writeErr | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |
| 117 | + | } | |
| @@ -51,23 +51,12 @@ Examples: | |||||
| 51 | func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error { | 51 | func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error { | ||
| 52 | result, err := deployer.Deploy(ctx, deployDomain) | 52 | result, err := deployer.Deploy(ctx, deployDomain) | ||
| 53 | if err != nil { | 53 | if err != nil { | ||
| 54 | + | if formatted, ok := structuredDeployFailure(err); ok { | |||
| 55 | + | return formatted | |||
| 56 | + | } | |||
| 57 | + | ||||
| 54 | if isRemote && shouldFallbackToLocal(err) { | 58 | if isRemote && shouldFallbackToLocal(err) { | ||
| 55 | - | domain, localErr := sendDeploySignal(deployDomain) | 59 | + | return handleLocalDeployFallback(err, deployDomain, out, jsonOut) |
| 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 | - | } | |||
| 65 | - | if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil { | |||
| 66 | - | return writeErr | |||
| 67 | - | } | |||
| 68 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |||
| 69 | - | } | |||
| 70 | - | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |||
| 71 | } | 60 | } | ||
| 72 | 61 | ||||
| 73 | return formatDeployFailure(err) | 62 | return formatDeployFailure(err) | ||
| @@ -104,3 +93,25 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma | |||||
| 104 | return cliWriteLine(out, cliRenderInfo(msg)) | 93 | return cliWriteLine(out, cliRenderInfo(msg)) | ||
| 105 | } | 94 | } | ||
| 106 | } | 95 | } | ||
| 96 | + | ||||
| 97 | + | func handleLocalDeployFallback(err error, deployDomain string, out io.Writer, jsonOut bool) error { | |||
| 98 | + | domain, localErr := sendDeploySignal(deployDomain) | |||
| 99 | + | if localErr != nil { | |||
| 100 | + | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err) | |||
| 104 | + | if jsonOut { | |||
| 105 | + | return writeJSON(out, map[string]string{ | |||
| 106 | + | "warning": warning, | |||
| 107 | + | "domain": domain, | |||
| 108 | + | "status": "success", | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | if writeErr := cliWriteLine(out, cliRenderWarning(warning)); writeErr != nil { | |||
| 113 | + | return writeErr | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |||
| 117 | + | } | |||
M internal/adapters/in/cli/deploy_error.go +30 -3
| @@ -13,18 +13,45 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) | |
| 15 | 15 | ||
| 16 | + | type renderedDeployFailureError struct { | |
| 17 | + | message string | |
| 18 | + | err error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (e *renderedDeployFailureError) Error() string { | |
| 22 | + | return e.message | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (e *renderedDeployFailureError) Unwrap() error { | |
| 26 | + | return e.err | |
| 27 | + | } | |
| 28 | + | ||
| 16 | 29 | func formatDeployFailure(err error) error { | |
| 30 | + | if formatted, ok := structuredDeployFailure(err); ok { | |
| 31 | + | return formatted | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | return fmt.Errorf("failed to deploy: %w", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func structuredDeployFailure(err error) (error, bool) { | |
| 17 | 38 | var deployErr *domain.DeployFailureError | |
| 18 | 39 | if errors.As(err, &deployErr) { | |
| 19 | - | return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr) | |
| 40 | + | return &renderedDeployFailureError{ | |
| 41 | + | message: renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), | |
| 42 | + | err: deployErr, | |
| 43 | + | }, true | |
| 20 | 44 | } | |
| 21 | 45 | ||
| 22 | 46 | var httpErr *remote.HTTPError | |
| 23 | 47 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | |
| 24 | - | return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr) | |
| 48 | + | return &renderedDeployFailureError{ | |
| 49 | + | message: renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), | |
| 50 | + | err: httpErr, | |
| 51 | + | }, true | |
| 25 | 52 | } | |
| 26 | 53 | ||
| 27 | - | return fmt.Errorf("failed to deploy: %w", err) | |
| 54 | + | return nil, false | |
| 28 | 55 | } | |
| 29 | 56 | ||
| 30 | 57 | func renderDeployFailure(summary, cause, hint string, logs []string) string { | |
| @@ -13,18 +13,45 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) | 14 | var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) | ||
| 15 | 15 | ||||
| 16 | + | type renderedDeployFailureError struct { | |||
| 17 | + | message string | |||
| 18 | + | err error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (e *renderedDeployFailureError) Error() string { | |||
| 22 | + | return e.message | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (e *renderedDeployFailureError) Unwrap() error { | |||
| 26 | + | return e.err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 16 | func formatDeployFailure(err error) error { | 29 | func formatDeployFailure(err error) error { | ||
| 30 | + | if formatted, ok := structuredDeployFailure(err); ok { | |||
| 31 | + | return formatted | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | return fmt.Errorf("failed to deploy: %w", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func structuredDeployFailure(err error) (error, bool) { | |||
| 17 | var deployErr *domain.DeployFailureError | 38 | var deployErr *domain.DeployFailureError | ||
| 18 | if errors.As(err, &deployErr) { | 39 | if errors.As(err, &deployErr) { | ||
| 19 | - | return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr) | 40 | + | return &renderedDeployFailureError{ |
| 41 | + | message: renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), | |||
| 42 | + | err: deployErr, | |||
| 43 | + | }, true | |||
| 20 | } | 44 | } | ||
| 21 | 45 | ||||
| 22 | var httpErr *remote.HTTPError | 46 | var httpErr *remote.HTTPError | ||
| 23 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | 47 | if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) { | ||
| 24 | - | return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr) | 48 | + | return &renderedDeployFailureError{ |
| 49 | + | message: renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), | |||
| 50 | + | err: httpErr, | |||
| 51 | + | }, true | |||
| 25 | } | 52 | } | ||
| 26 | 53 | ||||
| 27 | - | return fmt.Errorf("failed to deploy: %w", err) | 54 | + | return nil, false |
| 28 | } | 55 | } | ||
| 29 | 56 | ||||
| 30 | func renderDeployFailure(summary, cause, hint string, logs []string) string { | 57 | func renderDeployFailure(summary, cause, hint string, logs []string) string { | ||
M internal/adapters/in/cli/deploy_error_test.go +2 -2
| @@ -26,7 +26,7 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | |||
| 26 | 26 | err := formatDeployFailure(root) | |
| 27 | 27 | ||
| 28 | 28 | require.Error(t, err) | |
| 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") | |
| 29 | + | 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()) | |
| 30 | 30 | assert.ErrorIs(t, err, root) | |
| 31 | 31 | } | |
| 32 | 32 | ||
| @@ -43,7 +43,7 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | |||
| 43 | 43 | err := formatDeployFailure(root) | |
| 44 | 44 | ||
| 45 | 45 | require.Error(t, err) | |
| 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") | |
| 46 | + | 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()) | |
| 47 | 47 | assert.ErrorIs(t, err, root) | |
| 48 | 48 | } | |
| 49 | 49 | ||
| @@ -26,7 +26,7 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) { | |||||
| 26 | err := formatDeployFailure(root) | 26 | err := formatDeployFailure(root) | ||
| 27 | 27 | ||||
| 28 | require.Error(t, err) | 28 | require.Error(t, err) | ||
| 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") | 29 | + | 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()) |
| 30 | assert.ErrorIs(t, err, root) | 30 | assert.ErrorIs(t, err, root) | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| @@ -43,7 +43,7 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) { | |||||
| 43 | err := formatDeployFailure(root) | 43 | err := formatDeployFailure(root) | ||
| 44 | 44 | ||||
| 45 | require.Error(t, err) | 45 | require.Error(t, err) | ||
| 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") | 46 | + | 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()) |
| 47 | assert.ErrorIs(t, err, root) | 47 | assert.ErrorIs(t, err, root) | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
M internal/adapters/in/cli/deploy_test.go +24 -0
| @@ -36,6 +36,30 @@ func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | |||
| 36 | 36 | assert.ErrorIs(t, err, root) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | func TestRunDeploy_StructuredRemoteNoFallbackReturnsFormattedError(t *testing.T) { | |
| 40 | + | originalSendDeploySignal := sendDeploySignal | |
| 41 | + | sendDeploySignal = func(string) (string, error) { | |
| 42 | + | t.Fatalf("sendDeploySignal should not be called for structured deploy failures") | |
| 43 | + | return "", nil | |
| 44 | + | } | |
| 45 | + | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 46 | + | ||
| 47 | + | root := &remote.HTTPError{ | |
| 48 | + | StatusCode: 503, | |
| 49 | + | Status: "503 Service Unavailable", | |
| 50 | + | Body: "failed to deploy", | |
| 51 | + | Cause: "health check failed", | |
| 52 | + | Hint: "check DATABASE_URL", | |
| 53 | + | Logs: []string{"booting app", "connection refused"}, | |
| 54 | + | Structured: true, | |
| 55 | + | } | |
| 56 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, true, "app.example.com", &bytes.Buffer{}, false) | |
| 57 | + | ||
| 58 | + | require.Error(t, err) | |
| 59 | + | 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()) | |
| 60 | + | assert.ErrorIs(t, err, root) | |
| 61 | + | } | |
| 62 | + | ||
| 39 | 63 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | |
| 40 | 64 | var out bytes.Buffer | |
| 41 | 65 | err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{ | |
| @@ -36,6 +36,30 @@ func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | |||||
| 36 | assert.ErrorIs(t, err, root) | 36 | assert.ErrorIs(t, err, root) | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | func TestRunDeploy_StructuredRemoteNoFallbackReturnsFormattedError(t *testing.T) { | |||
| 40 | + | originalSendDeploySignal := sendDeploySignal | |||
| 41 | + | sendDeploySignal = func(string) (string, error) { | |||
| 42 | + | t.Fatalf("sendDeploySignal should not be called for structured deploy failures") | |||
| 43 | + | return "", nil | |||
| 44 | + | } | |||
| 45 | + | defer func() { sendDeploySignal = originalSendDeploySignal }() | |||
| 46 | + | ||||
| 47 | + | root := &remote.HTTPError{ | |||
| 48 | + | StatusCode: 503, | |||
| 49 | + | Status: "503 Service Unavailable", | |||
| 50 | + | Body: "failed to deploy", | |||
| 51 | + | Cause: "health check failed", | |||
| 52 | + | Hint: "check DATABASE_URL", | |||
| 53 | + | Logs: []string{"booting app", "connection refused"}, | |||
| 54 | + | Structured: true, | |||
| 55 | + | } | |||
| 56 | + | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, true, "app.example.com", &bytes.Buffer{}, false) | |||
| 57 | + | ||||
| 58 | + | require.Error(t, err) | |||
| 59 | + | 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()) | |||
| 60 | + | assert.ErrorIs(t, err, root) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 39 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | 63 | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | ||
| 40 | var out bytes.Buffer | 64 | var out bytes.Buffer | ||
| 41 | err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{ | 65 | err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{ | ||