feat(cli): render structured deploy failures
- commit
- 40add0f458941c0dc4b8cabb2ac384b30bd498c1
- author
- brice · · 2026-04-06 13:02 UTC
- parent
- 963eb7f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 44 | 44 | } | |
| 45 | 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 | 49 | containerID := result.ContainerID | |
| 50 | 50 | 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.ContainerID | 49 | 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 | |||
| 679 | 679 | result, err = cp.Deploy(ctx, pushDomain) | |
| 680 | 680 | } | |
| 681 | 681 | if err != nil { | |
| 682 | - | return fmt.Errorf("failed to deploy: %w", err) | |
| 682 | + | return formatDeployFailure(err) | |
| 683 | 683 | } | |
| 684 | 684 | containerID := result.ContainerID | |
| 685 | 685 | 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.ContainerID | 684 | 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 { | |||
| 323 | 323 | StatusCode int | |
| 324 | 324 | Status string | |
| 325 | 325 | Body string | |
| 326 | + | Cause string | |
| 327 | + | Hint string | |
| 328 | + | Logs []string | |
| 329 | + | Structured bool | |
| 326 | 330 | } | |
| 327 | 331 | ||
| 328 | 332 | func (e *HTTPError) Error() string { | |
| @@ -331,16 +335,27 @@ func (e *HTTPError) Error() string { | |||
| 331 | 335 | ||
| 332 | 336 | func parseErrorResponse(resp *http.Response, body []byte) error { | |
| 333 | 337 | msg := string(body) | |
| 338 | + | structured := false | |
| 334 | 339 | 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 | + | } | |
| 339 | 350 | } | |
| 340 | 351 | return &HTTPError{ | |
| 341 | 352 | StatusCode: resp.StatusCode, | |
| 342 | 353 | Status: resp.Status, | |
| 343 | 354 | Body: msg, | |
| 355 | + | Cause: errResp.Cause, | |
| 356 | + | Hint: errResp.Hint, | |
| 357 | + | Logs: errResp.Logs, | |
| 358 | + | Structured: structured, | |
| 344 | 359 | } | |
| 345 | 360 | } | |
| 346 | 361 | ||
| @@ -323,6 +323,10 @@ type HTTPError struct { | |||||
| 323 | StatusCode int | 323 | StatusCode int | ||
| 324 | Status string | 324 | Status string | ||
| 325 | Body string | 325 | 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.Error | 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 | + | } | |||
| 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) { | |||
| 334 | 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 | 354 | func TestParseErrorResponse_NonJSON(t *testing.T) { | |
| 338 | 355 | resp := &http.Response{ | |
| 339 | 356 | 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, | ||