detached 2f5cb8f

fix(cli): avoid fallback for structured deploy errors

commit
2f5cb8f93e597fc40e107c76c8e292bd40cd3eb1
author
brice · · 2026-04-06 16:40 UTC
parent
243a5c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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:
5151 func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error {
5252 result, err := deployer.Deploy(ctx, deployDomain)
5353 if err != nil {
54+ if formatted, ok := structuredDeployFailure(err); ok {
55+ return formatted
56+ }
57+
5458 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)
7160 }
7261
7362 return formatDeployFailure(err)
@@ -104,3 +93,25 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma
10493 return cliWriteLine(out, cliRenderInfo(msg))
10594 }
10695 }
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 (
1313
1414 var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
1515
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+
1629 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) {
1738 var deployErr *domain.DeployFailureError
1839 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
2044 }
2145
2246 var httpErr *remote.HTTPError
2347 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
2552 }
2653
27- return fmt.Errorf("failed to deploy: %w", err)
54+ return nil, false
2855 }
2956
3057 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.DeployFailureError38 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.HTTPError46 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) {
2626 err := formatDeployFailure(root)
2727
2828 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())
3030 assert.ErrorIs(t, err, root)
3131 }
3232
@@ -43,7 +43,7 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
4343 err := formatDeployFailure(root)
4444
4545 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())
4747 assert.ErrorIs(t, err, root)
4848 }
4949
@@ -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) {
3636 assert.ErrorIs(t, err, root)
3737 }
3838
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+
3963 func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
4064 var out bytes.Buffer
4165 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.Buffer64 var out bytes.Buffer
41 err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{65 err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close