detached 40add0f

feat(cli): render structured deploy failures

commit
40add0f458941c0dc4b8cabb2ac384b30bd498c1
author
brice · · 2026-04-06 13:02 UTC
parent
963eb7f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +147 -6
M internal/adapters/in/cli/deploy.go +1 -1
@@ -44,7 +44,7 @@ Examples:
4444 }
4545 return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr)
4646 }
47- return fmt.Errorf("failed to deploy: %w", err)
47+ return formatDeployFailure(err)
4848 }
4949 containerID := result.ContainerID
5050 if len(containerID) > 12 {
@@ -44,7 +44,7 @@ Examples:
44 }44 }
45 return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr)45 return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr)
46 }46 }
47- return fmt.Errorf("failed to deploy: %w", err)47+ return formatDeployFailure(err)
48 }48 }
49 containerID := result.ContainerID49 containerID := result.ContainerID
50 if len(containerID) > 12 {50 if len(containerID) > 12 {
A internal/adapters/in/cli/deploy_error.go +56 -0
@@ -0,0 +1,56 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func formatDeployFailure(err error) error {
13+ var deployErr *domain.DeployFailureError
14+ if errors.As(err, &deployErr) {
15+ return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs))
16+ }
17+
18+ var httpErr *remote.HTTPError
19+ if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
20+ return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs))
21+ }
22+
23+ return fmt.Errorf("failed to deploy: %w", err)
24+}
25+
26+func renderDeployFailure(summary, cause, hint string, logs []string) string {
27+ if summary == "" {
28+ summary = "failed to deploy"
29+ }
30+
31+ var b strings.Builder
32+ if summary == "failed to deploy" {
33+ b.WriteString(summary)
34+ } else {
35+ b.WriteString("failed to deploy: ")
36+ b.WriteString(summary)
37+ }
38+
39+ if cause != "" {
40+ b.WriteString("\nCause: ")
41+ b.WriteString(cause)
42+ }
43+ if hint != "" {
44+ b.WriteString("\nHint: ")
45+ b.WriteString(hint)
46+ }
47+ if len(logs) > 0 {
48+ b.WriteString("\n\nRecent container logs:")
49+ for _, line := range logs {
50+ b.WriteString("\n ")
51+ b.WriteString(line)
52+ }
53+ }
54+
55+ return b.String()
56+}
@@ -0,0 +1,56 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func formatDeployFailure(err error) error {
13+ var deployErr *domain.DeployFailureError
14+ if errors.As(err, &deployErr) {
15+ return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs))
16+ }
17+
18+ var httpErr *remote.HTTPError
19+ if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
20+ return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs))
21+ }
22+
23+ return fmt.Errorf("failed to deploy: %w", err)
24+}
25+
26+func renderDeployFailure(summary, cause, hint string, logs []string) string {
27+ if summary == "" {
28+ summary = "failed to deploy"
29+ }
30+
31+ var b strings.Builder
32+ if summary == "failed to deploy" {
33+ b.WriteString(summary)
34+ } else {
35+ b.WriteString("failed to deploy: ")
36+ b.WriteString(summary)
37+ }
38+
39+ if cause != "" {
40+ b.WriteString("\nCause: ")
41+ b.WriteString(cause)
42+ }
43+ if hint != "" {
44+ b.WriteString("\nHint: ")
45+ b.WriteString(hint)
46+ }
47+ if len(logs) > 0 {
48+ b.WriteString("\n\nRecent container logs:")
49+ for _, line := range logs {
50+ b.WriteString("\n ")
51+ b.WriteString(line)
52+ }
53+ }
54+
55+ return b.String()
56+}
A internal/adapters/in/cli/deploy_error_test.go +53 -0
@@ -0,0 +1,53 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
15+ err := formatDeployFailure(&remote.HTTPError{
16+ StatusCode: 500,
17+ Status: "500 Internal Server Error",
18+ Body: "failed to deploy",
19+ Cause: "health check failed",
20+ Hint: "check DATABASE_URL",
21+ Logs: []string{
22+ "booting app",
23+ "connection refused",
24+ },
25+ })
26+
27+ 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+}
30+
31+func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
32+ err := formatDeployFailure(&domain.DeployFailureError{
33+ Summary: "failed to deploy",
34+ Cause: "health check failed",
35+ Hint: "check DATABASE_URL",
36+ Logs: []string{
37+ "booting app",
38+ "connection refused",
39+ },
40+ })
41+
42+ 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())
44+}
45+
46+func TestFormatDeployFailure_GenericFallback(t *testing.T) {
47+ root := errors.New("boom")
48+ err := formatDeployFailure(root)
49+
50+ require.Error(t, err)
51+ assert.Equal(t, "failed to deploy: boom", err.Error())
52+ assert.ErrorIs(t, err, root)
53+}
@@ -0,0 +1,53 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
15+ err := formatDeployFailure(&remote.HTTPError{
16+ StatusCode: 500,
17+ Status: "500 Internal Server Error",
18+ Body: "failed to deploy",
19+ Cause: "health check failed",
20+ Hint: "check DATABASE_URL",
21+ Logs: []string{
22+ "booting app",
23+ "connection refused",
24+ },
25+ })
26+
27+ 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+}
30+
31+func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
32+ err := formatDeployFailure(&domain.DeployFailureError{
33+ Summary: "failed to deploy",
34+ Cause: "health check failed",
35+ Hint: "check DATABASE_URL",
36+ Logs: []string{
37+ "booting app",
38+ "connection refused",
39+ },
40+ })
41+
42+ 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())
44+}
45+
46+func TestFormatDeployFailure_GenericFallback(t *testing.T) {
47+ root := errors.New("boom")
48+ err := formatDeployFailure(root)
49+
50+ require.Error(t, err)
51+ assert.Equal(t, "failed to deploy: boom", err.Error())
52+ assert.ErrorIs(t, err, root)
53+}
M internal/adapters/in/cli/push.go +1 -1
@@ -679,7 +679,7 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
679679 result, err = cp.Deploy(ctx, pushDomain)
680680 }
681681 if err != nil {
682- return fmt.Errorf("failed to deploy: %w", err)
682+ return formatDeployFailure(err)
683683 }
684684 containerID := result.ContainerID
685685 if len(containerID) > 12 {
@@ -679,7 +679,7 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
679 result, err = cp.Deploy(ctx, pushDomain)679 result, err = cp.Deploy(ctx, pushDomain)
680 }680 }
681 if err != nil {681 if err != nil {
682- return fmt.Errorf("failed to deploy: %w", err)682+ return formatDeployFailure(err)
683 }683 }
684 containerID := result.ContainerID684 containerID := result.ContainerID
685 if len(containerID) > 12 {685 if len(containerID) > 12 {
M internal/adapters/in/cli/remote/client.go +19 -4
@@ -323,6 +323,10 @@ type HTTPError struct {
323323 StatusCode int
324324 Status string
325325 Body string
326+ Cause string
327+ Hint string
328+ Logs []string
329+ Structured bool
326330 }
327331
328332 func (e *HTTPError) Error() string {
@@ -331,16 +335,27 @@ func (e *HTTPError) Error() string {
331335
332336 func parseErrorResponse(resp *http.Response, body []byte) error {
333337 msg := string(body)
338+ structured := false
334339 var errResp struct {
335- Error string `json:"error"`
336- }
337- if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
338- msg = errResp.Error
340+ Error string `json:"error"`
341+ Cause string `json:"cause"`
342+ Hint string `json:"hint"`
343+ Logs []string `json:"logs"`
344+ }
345+ if err := json.Unmarshal(body, &errResp); err == nil {
346+ structured = errResp.Error != "" || errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
347+ if errResp.Error != "" {
348+ msg = errResp.Error
349+ }
339350 }
340351 return &HTTPError{
341352 StatusCode: resp.StatusCode,
342353 Status: resp.Status,
343354 Body: msg,
355+ Cause: errResp.Cause,
356+ Hint: errResp.Hint,
357+ Logs: errResp.Logs,
358+ Structured: structured,
344359 }
345360 }
346361
@@ -323,6 +323,10 @@ type HTTPError struct {
323 StatusCode int323 StatusCode int
324 Status string324 Status string
325 Body string325 Body string
326+ Cause string
327+ Hint string
328+ Logs []string
329+ Structured bool
326 }330 }
327 331
328 func (e *HTTPError) Error() string {332 func (e *HTTPError) Error() string {
@@ -331,16 +335,27 @@ func (e *HTTPError) Error() string {
331 335
332 func parseErrorResponse(resp *http.Response, body []byte) error {336 func parseErrorResponse(resp *http.Response, body []byte) error {
333 msg := string(body)337 msg := string(body)
338+ structured := false
334 var errResp struct {339 var errResp struct {
335- Error string `json:"error"`340+ Error string `json:"error"`
336- }341+ Cause string `json:"cause"`
337- if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {342+ Hint string `json:"hint"`
338- msg = errResp.Error343+ Logs []string `json:"logs"`
344+ }
345+ if err := json.Unmarshal(body, &errResp); err == nil {
346+ structured = errResp.Error != "" || errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
347+ if errResp.Error != "" {
348+ msg = errResp.Error
349+ }
339 }350 }
340 return &HTTPError{351 return &HTTPError{
341 StatusCode: resp.StatusCode,352 StatusCode: resp.StatusCode,
342 Status: resp.Status,353 Status: resp.Status,
343 Body: msg,354 Body: msg,
355+ Cause: errResp.Cause,
356+ Hint: errResp.Hint,
357+ Logs: errResp.Logs,
358+ Structured: structured,
344 }359 }
345 }360 }
346 361
M internal/adapters/in/cli/remote/client_test.go +17 -0
@@ -334,6 +334,23 @@ func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
334334 assert.Equal(t, "insufficient scope", httpErr.Body)
335335 }
336336
337+func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
338+ resp := &http.Response{
339+ StatusCode: 500,
340+ Status: "500 Internal Server Error",
341+ Body: io.NopCloser(strings.NewReader("")),
342+ }
343+ err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
344+
345+ var httpErr *HTTPError
346+ require.True(t, errors.As(err, &httpErr))
347+ assert.Equal(t, 500, httpErr.StatusCode)
348+ assert.Equal(t, "container exited during startup", httpErr.Body)
349+ assert.Equal(t, "health check failed", httpErr.Cause)
350+ assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
351+ assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
352+}
353+
337354 func TestParseErrorResponse_NonJSON(t *testing.T) {
338355 resp := &http.Response{
339356 StatusCode: 500,
@@ -334,6 +334,23 @@ func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
334 assert.Equal(t, "insufficient scope", httpErr.Body)334 assert.Equal(t, "insufficient scope", httpErr.Body)
335 }335 }
336 336
337+func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
338+ resp := &http.Response{
339+ StatusCode: 500,
340+ Status: "500 Internal Server Error",
341+ Body: io.NopCloser(strings.NewReader("")),
342+ }
343+ err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
344+
345+ var httpErr *HTTPError
346+ require.True(t, errors.As(err, &httpErr))
347+ assert.Equal(t, 500, httpErr.StatusCode)
348+ assert.Equal(t, "container exited during startup", httpErr.Body)
349+ assert.Equal(t, "health check failed", httpErr.Cause)
350+ assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
351+ assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
352+}
353+
337 func TestParseErrorResponse_NonJSON(t *testing.T) {354 func TestParseErrorResponse_NonJSON(t *testing.T) {
338 resp := &http.Response{355 resp := &http.Response{
339 StatusCode: 500,356 StatusCode: 500,

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