detached 4162c86

Merge pull request #165 from bnema/deploy-error-surfacing

Surface structured deploy failures in CLI and admin API
commit
4162c8604efc062fbbddeb0c4320a1bffa56fd06
author
bnema · · 2026-04-06 17:11 UTC
committer
GitHub · · 2026-04-06 17:11 UTC
merge of
f8fb41e + 292f2ec · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev5
13 files · +919 -41
A internal/adapters/dto/admin_deploy_error.go +9 -0
@@ -0,0 +1,9 @@
1+package dto
2+
3+// DeployErrorResponse represents a structured deployment failure response.
4+type DeployErrorResponse struct {
5+ Error string `json:"error"`
6+ Cause string `json:"cause,omitempty"`
7+ Hint string `json:"hint,omitempty"`
8+ Logs []string `json:"logs,omitempty"`
9+}
@@ -0,0 +1,9 @@
1+package dto
2+
3+// DeployErrorResponse represents a structured deployment failure response.
4+type DeployErrorResponse struct {
5+ Error string `json:"error"`
6+ Cause string `json:"cause,omitempty"`
7+ Hint string `json:"hint,omitempty"`
8+ Logs []string `json:"logs,omitempty"`
9+}
M internal/adapters/in/cli/deploy.go +84 -33
@@ -3,16 +3,24 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "io"
67
78 "github.com/spf13/cobra"
89
9- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1011 "github.com/bnema/gordon/internal/app"
1112 )
1213
14+type deployer interface {
15+ Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
16+}
17+
18+var sendDeploySignal = app.SendDeploySignal
19+
1320 // newDeployCmd creates the deploy command.
1421 func newDeployCmd() *cobra.Command {
15- return &cobra.Command{
22+ var jsonOut bool
23+ cmd := &cobra.Command{
1624 Use: "deploy <domain>",
1725 Short: "Manually deploy or redeploy a route",
1826 Long: `Triggers a deployment for the specified route domain.
@@ -27,40 +35,83 @@ Examples:
2735 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
2836 Args: cobra.ExactArgs(1),
2937 RunE: func(cmd *cobra.Command, args []string) error {
30- ctx := context.Background()
31- deployDomain := args[0]
32-
33- client, isRemote := GetRemoteClient()
34- if isRemote {
35- // Remote deployment via admin API
36- result, err := client.Deploy(ctx, deployDomain)
37- if err != nil {
38- if shouldFallbackToLocal(err) {
39- domain, localErr := app.SendDeploySignal(deployDomain)
40- if localErr == nil {
41- fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)))
42- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
43- return nil
44- }
45- return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr)
46- }
47- return fmt.Errorf("failed to deploy: %w", err)
48- }
49- containerID := result.ContainerID
50- if len(containerID) > 12 {
51- containerID = containerID[:12]
52- }
53- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, containerID)))
54- return nil
55- }
56-
57- // Local deployment via signal
58- domain, err := app.SendDeploySignal(deployDomain)
38+ handle, err := resolveControlPlane(configPath)
5939 if err != nil {
6040 return err
6141 }
62- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
63- return nil
42+ defer handle.close()
43+
44+ return runDeploy(cmd.Context(), handle.plane, handle.isRemote, args[0], cmd.OutOrStdout(), jsonOut)
6445 },
6546 }
47+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
48+ return cmd
49+}
50+
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)
53+ if err != nil {
54+ if formatted, ok := structuredDeployFailure(err); ok {
55+ return formatted
56+ }
57+
58+ if isRemote && shouldFallbackToLocal(err) {
59+ return handleLocalDeployFallback(err, deployDomain, out, jsonOut)
60+ }
61+
62+ return formatDeployFailure(err)
63+ }
64+
65+ if jsonOut {
66+ return writeJSON(out, result)
67+ }
68+
69+ messageDomain := deployDomain
70+ if result.Domain != "" {
71+ messageDomain = result.Domain
72+ }
73+
74+ containerID := result.ContainerID
75+ if containerID != "" && len(containerID) > 12 {
76+ containerID = containerID[:12]
77+ }
78+
79+ switch result.Status {
80+ case "deployed":
81+ msg := fmt.Sprintf("Deployed %s", messageDomain)
82+ if containerID != "" {
83+ msg += fmt.Sprintf(" (container: %s)", containerID)
84+ }
85+ return cliWriteLine(out, cliRenderSuccess(msg))
86+ case "queued":
87+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy queued for domain: %s", messageDomain)))
88+ default:
89+ msg := fmt.Sprintf("Deploy status for %s: %s", messageDomain, result.Status)
90+ if containerID != "" {
91+ msg += fmt.Sprintf(" (container: %s)", containerID)
92+ }
93+ return cliWriteLine(out, cliRenderInfo(msg))
94+ }
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: %w", err, localErr)
101+ }
102+
103+ warning := sanitizeDeployLogLine(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)))
66117 }
@@ -3,16 +3,24 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 7
7 "github.com/spf13/cobra"8 "github.com/spf13/cobra"
8 9
9- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10 "github.com/bnema/gordon/internal/app"11 "github.com/bnema/gordon/internal/app"
11 )12 )
12 13
14+type deployer interface {
15+ Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
16+}
17+
18+var sendDeploySignal = app.SendDeploySignal
19+
13 // newDeployCmd creates the deploy command.20 // newDeployCmd creates the deploy command.
14 func newDeployCmd() *cobra.Command {21 func newDeployCmd() *cobra.Command {
15- return &cobra.Command{22+ var jsonOut bool
23+ cmd := &cobra.Command{
16 Use: "deploy <domain>",24 Use: "deploy <domain>",
17 Short: "Manually deploy or redeploy a route",25 Short: "Manually deploy or redeploy a route",
18 Long: `Triggers a deployment for the specified route domain.26 Long: `Triggers a deployment for the specified route domain.
@@ -27,40 +35,83 @@ Examples:
27 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,35 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
28 Args: cobra.ExactArgs(1),36 Args: cobra.ExactArgs(1),
29 RunE: func(cmd *cobra.Command, args []string) error {37 RunE: func(cmd *cobra.Command, args []string) error {
30- ctx := context.Background()38+ handle, err := resolveControlPlane(configPath)
31- deployDomain := args[0]
32-
33- client, isRemote := GetRemoteClient()
34- if isRemote {
35- // Remote deployment via admin API
36- result, err := client.Deploy(ctx, deployDomain)
37- if err != nil {
38- if shouldFallbackToLocal(err) {
39- domain, localErr := app.SendDeploySignal(deployDomain)
40- if localErr == nil {
41- fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)))
42- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
43- return nil
44- }
45- return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr)
46- }
47- return fmt.Errorf("failed to deploy: %w", err)
48- }
49- containerID := result.ContainerID
50- if len(containerID) > 12 {
51- containerID = containerID[:12]
52- }
53- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, containerID)))
54- return nil
55- }
56-
57- // Local deployment via signal
58- domain, err := app.SendDeploySignal(deployDomain)
59 if err != nil {39 if err != nil {
60 return err40 return err
61 }41 }
62- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))42+ defer handle.close()
63- return nil43+
44+ return runDeploy(cmd.Context(), handle.plane, handle.isRemote, args[0], cmd.OutOrStdout(), jsonOut)
64 },45 },
65 }46 }
47+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
48+ return cmd
49+}
50+
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)
53+ if err != nil {
54+ if formatted, ok := structuredDeployFailure(err); ok {
55+ return formatted
56+ }
57+
58+ if isRemote && shouldFallbackToLocal(err) {
59+ return handleLocalDeployFallback(err, deployDomain, out, jsonOut)
60+ }
61+
62+ return formatDeployFailure(err)
63+ }
64+
65+ if jsonOut {
66+ return writeJSON(out, result)
67+ }
68+
69+ messageDomain := deployDomain
70+ if result.Domain != "" {
71+ messageDomain = result.Domain
72+ }
73+
74+ containerID := result.ContainerID
75+ if containerID != "" && len(containerID) > 12 {
76+ containerID = containerID[:12]
77+ }
78+
79+ switch result.Status {
80+ case "deployed":
81+ msg := fmt.Sprintf("Deployed %s", messageDomain)
82+ if containerID != "" {
83+ msg += fmt.Sprintf(" (container: %s)", containerID)
84+ }
85+ return cliWriteLine(out, cliRenderSuccess(msg))
86+ case "queued":
87+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy queued for domain: %s", messageDomain)))
88+ default:
89+ msg := fmt.Sprintf("Deploy status for %s: %s", messageDomain, result.Status)
90+ if containerID != "" {
91+ msg += fmt.Sprintf(" (container: %s)", containerID)
92+ }
93+ return cliWriteLine(out, cliRenderInfo(msg))
94+ }
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: %w", err, localErr)
101+ }
102+
103+ warning := sanitizeDeployLogLine(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)))
66 }117 }
A internal/adapters/in/cli/deploy_error.go +108 -0
@@ -0,0 +1,108 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "regexp"
7+ "strings"
8+ "unicode"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
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+
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) {
38+ var deployErr *domain.DeployFailureError
39+ if errors.As(err, &deployErr) {
40+ return &renderedDeployFailureError{
41+ message: renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs),
42+ err: deployErr,
43+ }, true
44+ }
45+
46+ var httpErr *remote.HTTPError
47+ if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
48+ return &renderedDeployFailureError{
49+ message: renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs),
50+ err: httpErr,
51+ }, true
52+ }
53+
54+ return nil, false
55+}
56+
57+func renderDeployFailure(summary, cause, hint string, logs []string) string {
58+ if summary == "" {
59+ summary = "failed to deploy"
60+ }
61+
62+ var b strings.Builder
63+ if summary == "failed to deploy" {
64+ b.WriteString(summary)
65+ } else {
66+ b.WriteString("failed to deploy: ")
67+ b.WriteString(summary)
68+ }
69+
70+ if cause != "" {
71+ b.WriteString("\nCause: ")
72+ b.WriteString(cause)
73+ }
74+ if hint != "" {
75+ b.WriteString("\nHint: ")
76+ b.WriteString(hint)
77+ }
78+ cleanedLogs := make([]string, 0, len(logs))
79+ for _, line := range logs {
80+ if cleanedLine := sanitizeDeployLogLine(line); cleanedLine != "" {
81+ cleanedLogs = append(cleanedLogs, cleanedLine)
82+ }
83+ }
84+ if len(cleanedLogs) > 0 {
85+ b.WriteString("\n\nRecent container logs:")
86+ for _, cleanedLine := range cleanedLogs {
87+ b.WriteString("\n ")
88+ b.WriteString(cleanedLine)
89+ }
90+ }
91+
92+ return b.String()
93+}
94+
95+func sanitizeDeployLogLine(line string) string {
96+ line = ansiEscapePattern.ReplaceAllString(line, "")
97+ line = strings.Map(func(r rune) rune {
98+ switch {
99+ case r == '\t':
100+ return r
101+ case !unicode.IsPrint(r):
102+ return -1
103+ default:
104+ return r
105+ }
106+ }, line)
107+ return strings.TrimSpace(line)
108+}
@@ -0,0 +1,108 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "regexp"
7+ "strings"
8+ "unicode"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
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+
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) {
38+ var deployErr *domain.DeployFailureError
39+ if errors.As(err, &deployErr) {
40+ return &renderedDeployFailureError{
41+ message: renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs),
42+ err: deployErr,
43+ }, true
44+ }
45+
46+ var httpErr *remote.HTTPError
47+ if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
48+ return &renderedDeployFailureError{
49+ message: renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs),
50+ err: httpErr,
51+ }, true
52+ }
53+
54+ return nil, false
55+}
56+
57+func renderDeployFailure(summary, cause, hint string, logs []string) string {
58+ if summary == "" {
59+ summary = "failed to deploy"
60+ }
61+
62+ var b strings.Builder
63+ if summary == "failed to deploy" {
64+ b.WriteString(summary)
65+ } else {
66+ b.WriteString("failed to deploy: ")
67+ b.WriteString(summary)
68+ }
69+
70+ if cause != "" {
71+ b.WriteString("\nCause: ")
72+ b.WriteString(cause)
73+ }
74+ if hint != "" {
75+ b.WriteString("\nHint: ")
76+ b.WriteString(hint)
77+ }
78+ cleanedLogs := make([]string, 0, len(logs))
79+ for _, line := range logs {
80+ if cleanedLine := sanitizeDeployLogLine(line); cleanedLine != "" {
81+ cleanedLogs = append(cleanedLogs, cleanedLine)
82+ }
83+ }
84+ if len(cleanedLogs) > 0 {
85+ b.WriteString("\n\nRecent container logs:")
86+ for _, cleanedLine := range cleanedLogs {
87+ b.WriteString("\n ")
88+ b.WriteString(cleanedLine)
89+ }
90+ }
91+
92+ return b.String()
93+}
94+
95+func sanitizeDeployLogLine(line string) string {
96+ line = ansiEscapePattern.ReplaceAllString(line, "")
97+ line = strings.Map(func(r rune) rune {
98+ switch {
99+ case r == '\t':
100+ return r
101+ case !unicode.IsPrint(r):
102+ return -1
103+ default:
104+ return r
105+ }
106+ }, line)
107+ return strings.TrimSpace(line)
108+}
A internal/adapters/in/cli/deploy_error_test.go +70 -0
@@ -0,0 +1,70 @@
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+ root := &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+ err := formatDeployFailure(root)
27+
28+ require.Error(t, err)
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)
31+}
32+
33+func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
34+ root := &domain.DeployFailureError{
35+ Summary: "failed to deploy",
36+ Cause: "health check failed",
37+ Hint: "check DATABASE_URL",
38+ Logs: []string{
39+ "booting app",
40+ "connection refused",
41+ },
42+ }
43+ err := formatDeployFailure(root)
44+
45+ require.Error(t, err)
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)
48+}
49+
50+func TestFormatDeployFailure_GenericFallback(t *testing.T) {
51+ root := errors.New("boom")
52+ err := formatDeployFailure(root)
53+
54+ require.Error(t, err)
55+ assert.Equal(t, "failed to deploy: boom", err.Error())
56+ assert.ErrorIs(t, err, root)
57+}
58+
59+func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) {
60+ root := &domain.DeployFailureError{
61+ Summary: "failed to deploy",
62+ Cause: "health check failed",
63+ Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"},
64+ }
65+ err := formatDeployFailure(root)
66+
67+ require.Error(t, err)
68+ assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error())
69+ assert.ErrorIs(t, err, root)
70+}
@@ -0,0 +1,70 @@
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+ root := &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+ err := formatDeployFailure(root)
27+
28+ require.Error(t, err)
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)
31+}
32+
33+func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
34+ root := &domain.DeployFailureError{
35+ Summary: "failed to deploy",
36+ Cause: "health check failed",
37+ Hint: "check DATABASE_URL",
38+ Logs: []string{
39+ "booting app",
40+ "connection refused",
41+ },
42+ }
43+ err := formatDeployFailure(root)
44+
45+ require.Error(t, err)
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)
48+}
49+
50+func TestFormatDeployFailure_GenericFallback(t *testing.T) {
51+ root := errors.New("boom")
52+ err := formatDeployFailure(root)
53+
54+ require.Error(t, err)
55+ assert.Equal(t, "failed to deploy: boom", err.Error())
56+ assert.ErrorIs(t, err, root)
57+}
58+
59+func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) {
60+ root := &domain.DeployFailureError{
61+ Summary: "failed to deploy",
62+ Cause: "health check failed",
63+ Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"},
64+ }
65+ err := formatDeployFailure(root)
66+
67+ require.Error(t, err)
68+ assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error())
69+ assert.ErrorIs(t, err, root)
70+}
A internal/adapters/in/cli/deploy_test.go +119 -0
@@ -0,0 +1,119 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+type deployTestDeployer struct {
17+ result *remote.DeployResult
18+ err error
19+}
20+
21+func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResult, error) {
22+ return d.result, d.err
23+}
24+
25+func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {
26+ root := &domain.DeployFailureError{
27+ Summary: "failed to deploy",
28+ Cause: "health check failed",
29+ Hint: "check DATABASE_URL",
30+ Logs: []string{"booting app", "connection refused"},
31+ }
32+ err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false)
33+
34+ require.Error(t, err)
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)
37+}
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+
63+func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
64+ var out bytes.Buffer
65+ err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{
66+ Status: "deployed",
67+ Domain: "actual.example.com",
68+ ContainerID: "1234567890abcdef",
69+ }}, false, "requested.example.com", &out, false)
70+
71+ require.NoError(t, err)
72+ assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")
73+ assert.NotContains(t, out.String(), "requested.example.com")
74+}
75+
76+func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) {
77+ originalSendDeploySignal := sendDeploySignal
78+ sendDeploySignal = func(string) (string, error) {
79+ return "app.example.com", nil
80+ }
81+ defer func() { sendDeploySignal = originalSendDeploySignal }()
82+
83+ var out bytes.Buffer
84+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true)
85+
86+ require.NoError(t, err)
87+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String())
88+}
89+
90+func TestHandleLocalDeployFallback_WrapsRemoteAndLocalErrors(t *testing.T) {
91+ originalSendDeploySignal := sendDeploySignal
92+ localErr := errors.New("local signal failed")
93+ sendDeploySignal = func(string) (string, error) {
94+ return "", localErr
95+ }
96+ defer func() { sendDeploySignal = originalSendDeploySignal }()
97+
98+ remoteErr := errors.New("remote deploy failed")
99+ err := handleLocalDeployFallback(remoteErr, "app.example.com", &bytes.Buffer{}, false)
100+
101+ require.Error(t, err)
102+ assert.Equal(t, "failed to deploy: remote error: remote deploy failed; local fallback also failed: local signal failed", err.Error())
103+ assert.ErrorIs(t, err, remoteErr)
104+ assert.ErrorIs(t, err, localErr)
105+}
106+
107+func TestRunDeploy_RemoteFallbackSanitizesWarning(t *testing.T) {
108+ originalSendDeploySignal := sendDeploySignal
109+ sendDeploySignal = func(string) (string, error) {
110+ return "app.example.com", nil
111+ }
112+ defer func() { sendDeploySignal = originalSendDeploySignal }()
113+
114+ var out bytes.Buffer
115+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable:\n\x1b[31mRegistry Unavailable\x1b[0m")}, true, "app.example.com", &out, true)
116+
117+ require.NoError(t, err)
118+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable:Registry Unavailable), used local signal fallback"}`, out.String())
119+}
@@ -0,0 +1,119 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+type deployTestDeployer struct {
17+ result *remote.DeployResult
18+ err error
19+}
20+
21+func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResult, error) {
22+ return d.result, d.err
23+}
24+
25+func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {
26+ root := &domain.DeployFailureError{
27+ Summary: "failed to deploy",
28+ Cause: "health check failed",
29+ Hint: "check DATABASE_URL",
30+ Logs: []string{"booting app", "connection refused"},
31+ }
32+ err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false)
33+
34+ require.Error(t, err)
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)
37+}
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+
63+func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
64+ var out bytes.Buffer
65+ err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{
66+ Status: "deployed",
67+ Domain: "actual.example.com",
68+ ContainerID: "1234567890abcdef",
69+ }}, false, "requested.example.com", &out, false)
70+
71+ require.NoError(t, err)
72+ assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")
73+ assert.NotContains(t, out.String(), "requested.example.com")
74+}
75+
76+func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) {
77+ originalSendDeploySignal := sendDeploySignal
78+ sendDeploySignal = func(string) (string, error) {
79+ return "app.example.com", nil
80+ }
81+ defer func() { sendDeploySignal = originalSendDeploySignal }()
82+
83+ var out bytes.Buffer
84+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true)
85+
86+ require.NoError(t, err)
87+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String())
88+}
89+
90+func TestHandleLocalDeployFallback_WrapsRemoteAndLocalErrors(t *testing.T) {
91+ originalSendDeploySignal := sendDeploySignal
92+ localErr := errors.New("local signal failed")
93+ sendDeploySignal = func(string) (string, error) {
94+ return "", localErr
95+ }
96+ defer func() { sendDeploySignal = originalSendDeploySignal }()
97+
98+ remoteErr := errors.New("remote deploy failed")
99+ err := handleLocalDeployFallback(remoteErr, "app.example.com", &bytes.Buffer{}, false)
100+
101+ require.Error(t, err)
102+ assert.Equal(t, "failed to deploy: remote error: remote deploy failed; local fallback also failed: local signal failed", err.Error())
103+ assert.ErrorIs(t, err, remoteErr)
104+ assert.ErrorIs(t, err, localErr)
105+}
106+
107+func TestRunDeploy_RemoteFallbackSanitizesWarning(t *testing.T) {
108+ originalSendDeploySignal := sendDeploySignal
109+ sendDeploySignal = func(string) (string, error) {
110+ return "app.example.com", nil
111+ }
112+ defer func() { sendDeploySignal = originalSendDeploySignal }()
113+
114+ var out bytes.Buffer
115+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable:\n\x1b[31mRegistry Unavailable\x1b[0m")}, true, "app.example.com", &out, true)
116+
117+ require.NoError(t, err)
118+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable:Registry Unavailable), used local signal fallback"}`, out.String())
119+}
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.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.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 +35 -0
@@ -334,6 +334,41 @@ 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.True(t, httpErr.Structured)
350+ assert.Equal(t, "health check failed", httpErr.Cause)
351+ assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
352+ assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
353+}
354+
355+func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
356+ resp := &http.Response{
357+ StatusCode: 500,
358+ Status: "500 Internal Server Error",
359+ Body: io.NopCloser(strings.NewReader("")),
360+ }
361+ err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
362+
363+ var httpErr *HTTPError
364+ require.True(t, errors.As(err, &httpErr))
365+ assert.Equal(t, "failed to deploy container", httpErr.Body)
366+ assert.False(t, httpErr.Structured)
367+ assert.Empty(t, httpErr.Cause)
368+ assert.Empty(t, httpErr.Hint)
369+ assert.Empty(t, httpErr.Logs)
370+}
371+
337372 func TestParseErrorResponse_NonJSON(t *testing.T) {
338373 resp := &http.Response{
339374 StatusCode: 500,
@@ -334,6 +334,41 @@ 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.True(t, httpErr.Structured)
350+ assert.Equal(t, "health check failed", httpErr.Cause)
351+ assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
352+ assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
353+}
354+
355+func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
356+ resp := &http.Response{
357+ StatusCode: 500,
358+ Status: "500 Internal Server Error",
359+ Body: io.NopCloser(strings.NewReader("")),
360+ }
361+ err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
362+
363+ var httpErr *HTTPError
364+ require.True(t, errors.As(err, &httpErr))
365+ assert.Equal(t, "failed to deploy container", httpErr.Body)
366+ assert.False(t, httpErr.Structured)
367+ assert.Empty(t, httpErr.Cause)
368+ assert.Empty(t, httpErr.Hint)
369+ assert.Empty(t, httpErr.Logs)
370+}
371+
337 func TestParseErrorResponse_NonJSON(t *testing.T) {372 func TestParseErrorResponse_NonJSON(t *testing.T) {
338 resp := &http.Response{373 resp := &http.Response{
339 StatusCode: 500,374 StatusCode: 500,
M internal/adapters/in/http/admin/handler.go +10 -0
@@ -1280,6 +1280,16 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
12801280 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
12811281 if err != nil {
12821282 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1283+ var deployErr *domain.DeployFailureError
1284+ if errors.As(err, &deployErr) {
1285+ h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{
1286+ Error: deployErr.Error(),
1287+ Cause: deployErr.Cause,
1288+ Hint: deployErr.Hint,
1289+ Logs: deployErr.Logs,
1290+ })
1291+ return
1292+ }
12831293 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
12841294 return
12851295 }
@@ -1280,6 +1280,16 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1280 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)1280 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1281 if err != nil {1281 if err != nil {
1282 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")1282 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1283+ var deployErr *domain.DeployFailureError
1284+ if errors.As(err, &deployErr) {
1285+ h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{
1286+ Error: deployErr.Error(),
1287+ Cause: deployErr.Cause,
1288+ Hint: deployErr.Hint,
1289+ Logs: deployErr.Logs,
1290+ })
1291+ return
1292+ }
1283 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")1293 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
1284 return1294 return
1285 }1295 }
M internal/adapters/in/http/admin/handler_test.go +80 -0
@@ -6,6 +6,7 @@ import (
66 "encoding/json"
77 "errors"
88 "fmt"
9+ "io"
910 "net/http"
1011 "net/http/httptest"
1112 "net/url"
@@ -1346,6 +1347,85 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
13461347 }
13471348 }
13481349
1350+func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1351+ configSvc := inmocks.NewMockConfigService(t)
1352+ authSvc := inmocks.NewMockAuthService(t)
1353+ containerSvc := inmocks.NewMockContainerService(t)
1354+ secretSvc := inmocks.NewMockSecretService(t)
1355+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1356+ deployErr := &domain.DeployFailureError{
1357+ Summary: "failed to deploy",
1358+ Cause: "image pull failed",
1359+ Hint: "push the image before retrying",
1360+ Logs: []string{
1361+ "pulling manifest",
1362+ "manifest unknown",
1363+ },
1364+ Err: errors.New("manifest unknown: internal registry detail"),
1365+ }
1366+
1367+ handler := newTestHandler(t, func(d *HandlerDeps) {
1368+ d.ConfigSvc = configSvc
1369+ d.AuthSvc = authSvc
1370+ d.ContainerSvc = containerSvc
1371+ d.SecretSvc = secretSvc
1372+ })
1373+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1374+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1375+ }))
1376+ defer s.Close()
1377+
1378+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1379+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1380+
1381+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1382+ assert.NoError(t, err)
1383+ resp, err := s.Client().Do(req)
1384+ assert.NoError(t, err)
1385+ defer resp.Body.Close()
1386+ body, err := io.ReadAll(resp.Body)
1387+ assert.NoError(t, err)
1388+
1389+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1390+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))
1391+ assert.NotContains(t, string(body), "internal registry detail")
1392+}
1393+
1394+func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
1395+ configSvc := inmocks.NewMockConfigService(t)
1396+ authSvc := inmocks.NewMockAuthService(t)
1397+ containerSvc := inmocks.NewMockContainerService(t)
1398+ secretSvc := inmocks.NewMockSecretService(t)
1399+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1400+ deployErr := errors.New("manifest unknown: internal registry detail")
1401+
1402+ handler := newTestHandler(t, func(d *HandlerDeps) {
1403+ d.ConfigSvc = configSvc
1404+ d.AuthSvc = authSvc
1405+ d.ContainerSvc = containerSvc
1406+ d.SecretSvc = secretSvc
1407+ })
1408+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1409+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1410+ }))
1411+ defer s.Close()
1412+
1413+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1414+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1415+
1416+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1417+ assert.NoError(t, err)
1418+ resp, err := s.Client().Do(req)
1419+ assert.NoError(t, err)
1420+ defer resp.Body.Close()
1421+ body, err := io.ReadAll(resp.Body)
1422+ assert.NoError(t, err)
1423+
1424+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1425+ assert.JSONEq(t, `{"error":"failed to deploy container"}`, string(body))
1426+ assert.NotContains(t, string(body), "internal registry detail")
1427+}
1428+
13491429 func TestHandler_Restart_ContainerNotFound(t *testing.T) {
13501430 configSvc := inmocks.NewMockConfigService(t)
13511431 authSvc := inmocks.NewMockAuthService(t)
@@ -6,6 +6,7 @@ import (
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "io"
9 "net/http"10 "net/http"
10 "net/http/httptest"11 "net/http/httptest"
11 "net/url"12 "net/url"
@@ -1346,6 +1347,85 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
1346 }1347 }
1347 }1348 }
1348 1349
1350+func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1351+ configSvc := inmocks.NewMockConfigService(t)
1352+ authSvc := inmocks.NewMockAuthService(t)
1353+ containerSvc := inmocks.NewMockContainerService(t)
1354+ secretSvc := inmocks.NewMockSecretService(t)
1355+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1356+ deployErr := &domain.DeployFailureError{
1357+ Summary: "failed to deploy",
1358+ Cause: "image pull failed",
1359+ Hint: "push the image before retrying",
1360+ Logs: []string{
1361+ "pulling manifest",
1362+ "manifest unknown",
1363+ },
1364+ Err: errors.New("manifest unknown: internal registry detail"),
1365+ }
1366+
1367+ handler := newTestHandler(t, func(d *HandlerDeps) {
1368+ d.ConfigSvc = configSvc
1369+ d.AuthSvc = authSvc
1370+ d.ContainerSvc = containerSvc
1371+ d.SecretSvc = secretSvc
1372+ })
1373+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1374+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1375+ }))
1376+ defer s.Close()
1377+
1378+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1379+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1380+
1381+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1382+ assert.NoError(t, err)
1383+ resp, err := s.Client().Do(req)
1384+ assert.NoError(t, err)
1385+ defer resp.Body.Close()
1386+ body, err := io.ReadAll(resp.Body)
1387+ assert.NoError(t, err)
1388+
1389+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1390+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))
1391+ assert.NotContains(t, string(body), "internal registry detail")
1392+}
1393+
1394+func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
1395+ configSvc := inmocks.NewMockConfigService(t)
1396+ authSvc := inmocks.NewMockAuthService(t)
1397+ containerSvc := inmocks.NewMockContainerService(t)
1398+ secretSvc := inmocks.NewMockSecretService(t)
1399+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1400+ deployErr := errors.New("manifest unknown: internal registry detail")
1401+
1402+ handler := newTestHandler(t, func(d *HandlerDeps) {
1403+ d.ConfigSvc = configSvc
1404+ d.AuthSvc = authSvc
1405+ d.ContainerSvc = containerSvc
1406+ d.SecretSvc = secretSvc
1407+ })
1408+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1409+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1410+ }))
1411+ defer s.Close()
1412+
1413+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1414+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1415+
1416+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1417+ assert.NoError(t, err)
1418+ resp, err := s.Client().Do(req)
1419+ assert.NoError(t, err)
1420+ defer resp.Body.Close()
1421+ body, err := io.ReadAll(resp.Body)
1422+ assert.NoError(t, err)
1423+
1424+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1425+ assert.JSONEq(t, `{"error":"failed to deploy container"}`, string(body))
1426+ assert.NotContains(t, string(body), "internal registry detail")
1427+}
1428+
1349 func TestHandler_Restart_ContainerNotFound(t *testing.T) {1429 func TestHandler_Restart_ContainerNotFound(t *testing.T) {
1350 configSvc := inmocks.NewMockConfigService(t)1430 configSvc := inmocks.NewMockConfigService(t)
1351 authSvc := inmocks.NewMockAuthService(t)1431 authSvc := inmocks.NewMockAuthService(t)
A internal/domain/deploy_failure.go +30 -0
@@ -0,0 +1,30 @@
1+package domain
2+
3+// DeployFailureError describes a surfaced deployment failure.
4+type DeployFailureError struct {
5+ Summary string
6+ Cause string
7+ Hint string
8+ Logs []string
9+ ContainerName string
10+ ContainerID string
11+ Err error
12+}
13+
14+// Error returns the deploy failure summary.
15+func (e *DeployFailureError) Error() string {
16+ if e == nil || e.Summary == "" {
17+ return "failed to deploy"
18+ }
19+
20+ return e.Summary
21+}
22+
23+// Unwrap returns the wrapped error.
24+func (e *DeployFailureError) Unwrap() error {
25+ if e == nil {
26+ return nil
27+ }
28+
29+ return e.Err
30+}
@@ -0,0 +1,30 @@
1+package domain
2+
3+// DeployFailureError describes a surfaced deployment failure.
4+type DeployFailureError struct {
5+ Summary string
6+ Cause string
7+ Hint string
8+ Logs []string
9+ ContainerName string
10+ ContainerID string
11+ Err error
12+}
13+
14+// Error returns the deploy failure summary.
15+func (e *DeployFailureError) Error() string {
16+ if e == nil || e.Summary == "" {
17+ return "failed to deploy"
18+ }
19+
20+ return e.Summary
21+}
22+
23+// Unwrap returns the wrapped error.
24+func (e *DeployFailureError) Unwrap() error {
25+ if e == nil {
26+ return nil
27+ }
28+
29+ return e.Err
30+}
M internal/usecase/container/service.go +201 -1
@@ -2,9 +2,12 @@
22 package container
33
44 import (
5+ "bufio"
56 "context"
7+ "encoding/binary"
68 "errors"
79 "fmt"
10+ "io"
811 "maps"
912 "slices"
1013 "strconv"
@@ -69,6 +72,7 @@ const (
6972 // negatives during short startup flaps.
7073 readinessRecoveryWindow = 30 * time.Second
7174 internalPullMaxAttempts = 3
75+ deployFailureLogLimit = 20
7276 )
7377
7478 // Service implements the ContainerService interface.
@@ -315,6 +319,10 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
315319
316320 resources, err := s.prepareDeployResources(ctx, route, existing)
317321 if err != nil {
322+ if deployErr := s.wrapPullDeployFailure(route, existing, err); deployErr != nil {
323+ err = deployErr
324+ return nil, err
325+ }
318326 return nil, err
319327 }
320328
@@ -630,8 +638,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
630638 }
631639 if !domain.IsSkipReadiness(ctx) {
632640 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
641+ deployErr := s.newDeployFailure(newContainer, "container failed readiness check", err, s.captureRecentContainerLogs(ctx, newContainer.ID))
633642 s.cleanupFailedContainer(ctx, newContainer.ID)
634- return nil, log.WrapErr(err, "container failed readiness check")
643+ return nil, deployErr
635644 }
636645 }
637646
@@ -644,6 +653,194 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
644653 return inspected, nil
645654 }
646655
656+func (s *Service) newDeployFailure(container *domain.Container, cause string, err error, logs []string) error {
657+ deployErr := &domain.DeployFailureError{
658+ Summary: "failed to deploy",
659+ Cause: cause,
660+ Hint: inferDeployFailureHint(err),
661+ Logs: logs,
662+ Err: err,
663+ }
664+ if container != nil {
665+ deployErr.ContainerName = container.Name
666+ deployErr.ContainerID = container.ID
667+ }
668+
669+ return deployErr
670+}
671+
672+func (s *Service) wrapPullDeployFailure(route domain.Route, existing *domain.Container, err error) error {
673+ if !isPullFailure(err) {
674+ return nil
675+ }
676+
677+ return s.newDeployFailure(&domain.Container{Name: s.deploymentContainerName(route.Domain, existing)}, "failed to pull image", err, nil)
678+}
679+
680+func (s *Service) captureRecentContainerLogs(ctx context.Context, containerID string) []string {
681+ if ctx.Err() != nil {
682+ return nil
683+ }
684+
685+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, false)
686+ if err != nil {
687+ return nil
688+ }
689+ defer func() {
690+ _ = logStream.Close()
691+ }()
692+
693+ logs, err := parseDockerLogLines(logStream, deployFailureLogLimit)
694+ if err != nil {
695+ return nil
696+ }
697+
698+ return logs
699+}
700+
701+func parseDockerLogLines(r io.Reader, maxLines int) ([]string, error) {
702+ reader := bufio.NewReader(r)
703+ collector := newLogLineCollector(maxLines)
704+
705+ header, err := reader.Peek(8)
706+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {
707+ return nil, err
708+ }
709+
710+ if len(header) >= 8 && looksLikeDockerLogStream(header) {
711+ return parseDockerFramedLogLines(reader, collector)
712+ }
713+
714+ return parsePlainLogLines(reader, collector)
715+}
716+
717+func parseDockerFramedLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
718+ for {
719+ frameHeader := make([]byte, 8)
720+ if _, err := io.ReadFull(reader, frameHeader); err != nil {
721+ if isLogStreamEOF(err) {
722+ collector.flush()
723+ return collector.lines, nil
724+ }
725+ return nil, err
726+ }
727+
728+ size := int(binary.BigEndian.Uint32(frameHeader[4:8]))
729+ if size <= 0 {
730+ continue
731+ }
732+
733+ payload, err := readLogPayload(reader, size)
734+ if err != nil {
735+ collector.addChunk(string(payload))
736+ collector.flush()
737+ if isLogStreamEOF(err) {
738+ return collector.lines, nil
739+ }
740+ return nil, err
741+ }
742+
743+ collector.addChunk(string(payload))
744+ }
745+}
746+
747+func parsePlainLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
748+
749+ scanner := bufio.NewScanner(reader)
750+ for scanner.Scan() {
751+ collector.addLine(scanner.Text())
752+ }
753+ if err := scanner.Err(); err != nil {
754+ return nil, err
755+ }
756+
757+ collector.flush()
758+ return collector.lines, nil
759+}
760+
761+func readLogPayload(reader *bufio.Reader, size int) ([]byte, error) {
762+ payload := make([]byte, size)
763+ _, err := io.ReadFull(reader, payload)
764+ return payload, err
765+}
766+
767+func isLogStreamEOF(err error) bool {
768+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
769+}
770+
771+func looksLikeDockerLogStream(data []byte) bool {
772+ return len(data) >= 8 && data[1] == 0 && data[2] == 0 && data[3] == 0
773+}
774+
775+type logLineCollector struct {
776+ lines []string
777+ pending string
778+ maxLines int
779+}
780+
781+func newLogLineCollector(maxLines int) *logLineCollector {
782+ return &logLineCollector{maxLines: maxLines}
783+}
784+
785+func (c *logLineCollector) addChunk(chunk string) {
786+ for _, part := range strings.SplitAfter(chunk, "\n") {
787+ c.pending += part
788+ if strings.HasSuffix(part, "\n") {
789+ c.flush()
790+ }
791+ }
792+}
793+
794+func (c *logLineCollector) addLine(line string) {
795+ line = strings.TrimRight(line, "\r")
796+ if line == "" {
797+ return
798+ }
799+ c.lines = append(c.lines, line)
800+ if c.maxLines > 0 && len(c.lines) > c.maxLines {
801+ c.lines = c.lines[len(c.lines)-c.maxLines:]
802+ }
803+}
804+
805+func (c *logLineCollector) flush() {
806+ if c.pending == "" {
807+ return
808+ }
809+ c.addLine(strings.TrimRight(c.pending, "\r\n"))
810+ c.pending = ""
811+}
812+
813+func inferDeployFailureHint(err error) string {
814+ if err == nil {
815+ return ""
816+ }
817+
818+ msg := strings.ToLower(err.Error())
819+ switch {
820+ case strings.Contains(msg, "healthcheck timeout"):
821+ return "check startup logs and confirm the health endpoint becomes ready within the configured timeout"
822+ case strings.Contains(msg, "unhealthy"):
823+ return "inspect recent app logs and verify the healthcheck command reflects actual readiness"
824+ case strings.Contains(msg, "no healthcheck detected"):
825+ return "add a Docker healthcheck or switch readiness mode"
826+ case isPullFailure(err), strings.Contains(msg, "access denied"), strings.Contains(msg, "unauthorized"):
827+ return "verify registry auth and confirm the image exists at the requested tag"
828+ default:
829+ return ""
830+ }
831+}
832+
833+func isPullFailure(err error) bool {
834+ if err == nil {
835+ return false
836+ }
837+
838+ msg := strings.ToLower(err.Error())
839+ return strings.Contains(msg, "failed to pull image") ||
840+ strings.Contains(msg, "pull access denied") ||
841+ strings.Contains(msg, "unauthorized")
842+}
843+
647844 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
648845 s.mu.Lock()
649846 _, wasTracked := s.containers[domainName]
@@ -2828,6 +3025,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
28283025 if status == "healthy" {
28293026 return nil
28303027 }
3028+ if status == "unhealthy" {
3029+ return errors.New("container reported unhealthy")
3030+ }
28313031 lastStatus = status
28323032
28333033 select {
@@ -2,9 +2,12 @@
2 package container2 package container
3 3
4 import (4 import (
5+ "bufio"
5 "context"6 "context"
7+ "encoding/binary"
6 "errors"8 "errors"
7 "fmt"9 "fmt"
10+ "io"
8 "maps"11 "maps"
9 "slices"12 "slices"
10 "strconv"13 "strconv"
@@ -69,6 +72,7 @@ const (
69 // negatives during short startup flaps.72 // negatives during short startup flaps.
70 readinessRecoveryWindow = 30 * time.Second73 readinessRecoveryWindow = 30 * time.Second
71 internalPullMaxAttempts = 374 internalPullMaxAttempts = 3
75+ deployFailureLogLimit = 20
72 )76 )
73 77
74 // Service implements the ContainerService interface.78 // Service implements the ContainerService interface.
@@ -315,6 +319,10 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
315 319
316 resources, err := s.prepareDeployResources(ctx, route, existing)320 resources, err := s.prepareDeployResources(ctx, route, existing)
317 if err != nil {321 if err != nil {
322+ if deployErr := s.wrapPullDeployFailure(route, existing, err); deployErr != nil {
323+ err = deployErr
324+ return nil, err
325+ }
318 return nil, err326 return nil, err
319 }327 }
320 328
@@ -630,8 +638,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
630 }638 }
631 if !domain.IsSkipReadiness(ctx) {639 if !domain.IsSkipReadiness(ctx) {
632 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {640 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
641+ deployErr := s.newDeployFailure(newContainer, "container failed readiness check", err, s.captureRecentContainerLogs(ctx, newContainer.ID))
633 s.cleanupFailedContainer(ctx, newContainer.ID)642 s.cleanupFailedContainer(ctx, newContainer.ID)
634- return nil, log.WrapErr(err, "container failed readiness check")643+ return nil, deployErr
635 }644 }
636 }645 }
637 646
@@ -644,6 +653,194 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
644 return inspected, nil653 return inspected, nil
645 }654 }
646 655
656+func (s *Service) newDeployFailure(container *domain.Container, cause string, err error, logs []string) error {
657+ deployErr := &domain.DeployFailureError{
658+ Summary: "failed to deploy",
659+ Cause: cause,
660+ Hint: inferDeployFailureHint(err),
661+ Logs: logs,
662+ Err: err,
663+ }
664+ if container != nil {
665+ deployErr.ContainerName = container.Name
666+ deployErr.ContainerID = container.ID
667+ }
668+
669+ return deployErr
670+}
671+
672+func (s *Service) wrapPullDeployFailure(route domain.Route, existing *domain.Container, err error) error {
673+ if !isPullFailure(err) {
674+ return nil
675+ }
676+
677+ return s.newDeployFailure(&domain.Container{Name: s.deploymentContainerName(route.Domain, existing)}, "failed to pull image", err, nil)
678+}
679+
680+func (s *Service) captureRecentContainerLogs(ctx context.Context, containerID string) []string {
681+ if ctx.Err() != nil {
682+ return nil
683+ }
684+
685+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, false)
686+ if err != nil {
687+ return nil
688+ }
689+ defer func() {
690+ _ = logStream.Close()
691+ }()
692+
693+ logs, err := parseDockerLogLines(logStream, deployFailureLogLimit)
694+ if err != nil {
695+ return nil
696+ }
697+
698+ return logs
699+}
700+
701+func parseDockerLogLines(r io.Reader, maxLines int) ([]string, error) {
702+ reader := bufio.NewReader(r)
703+ collector := newLogLineCollector(maxLines)
704+
705+ header, err := reader.Peek(8)
706+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {
707+ return nil, err
708+ }
709+
710+ if len(header) >= 8 && looksLikeDockerLogStream(header) {
711+ return parseDockerFramedLogLines(reader, collector)
712+ }
713+
714+ return parsePlainLogLines(reader, collector)
715+}
716+
717+func parseDockerFramedLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
718+ for {
719+ frameHeader := make([]byte, 8)
720+ if _, err := io.ReadFull(reader, frameHeader); err != nil {
721+ if isLogStreamEOF(err) {
722+ collector.flush()
723+ return collector.lines, nil
724+ }
725+ return nil, err
726+ }
727+
728+ size := int(binary.BigEndian.Uint32(frameHeader[4:8]))
729+ if size <= 0 {
730+ continue
731+ }
732+
733+ payload, err := readLogPayload(reader, size)
734+ if err != nil {
735+ collector.addChunk(string(payload))
736+ collector.flush()
737+ if isLogStreamEOF(err) {
738+ return collector.lines, nil
739+ }
740+ return nil, err
741+ }
742+
743+ collector.addChunk(string(payload))
744+ }
745+}
746+
747+func parsePlainLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
748+
749+ scanner := bufio.NewScanner(reader)
750+ for scanner.Scan() {
751+ collector.addLine(scanner.Text())
752+ }
753+ if err := scanner.Err(); err != nil {
754+ return nil, err
755+ }
756+
757+ collector.flush()
758+ return collector.lines, nil
759+}
760+
761+func readLogPayload(reader *bufio.Reader, size int) ([]byte, error) {
762+ payload := make([]byte, size)
763+ _, err := io.ReadFull(reader, payload)
764+ return payload, err
765+}
766+
767+func isLogStreamEOF(err error) bool {
768+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
769+}
770+
771+func looksLikeDockerLogStream(data []byte) bool {
772+ return len(data) >= 8 && data[1] == 0 && data[2] == 0 && data[3] == 0
773+}
774+
775+type logLineCollector struct {
776+ lines []string
777+ pending string
778+ maxLines int
779+}
780+
781+func newLogLineCollector(maxLines int) *logLineCollector {
782+ return &logLineCollector{maxLines: maxLines}
783+}
784+
785+func (c *logLineCollector) addChunk(chunk string) {
786+ for _, part := range strings.SplitAfter(chunk, "\n") {
787+ c.pending += part
788+ if strings.HasSuffix(part, "\n") {
789+ c.flush()
790+ }
791+ }
792+}
793+
794+func (c *logLineCollector) addLine(line string) {
795+ line = strings.TrimRight(line, "\r")
796+ if line == "" {
797+ return
798+ }
799+ c.lines = append(c.lines, line)
800+ if c.maxLines > 0 && len(c.lines) > c.maxLines {
801+ c.lines = c.lines[len(c.lines)-c.maxLines:]
802+ }
803+}
804+
805+func (c *logLineCollector) flush() {
806+ if c.pending == "" {
807+ return
808+ }
809+ c.addLine(strings.TrimRight(c.pending, "\r\n"))
810+ c.pending = ""
811+}
812+
813+func inferDeployFailureHint(err error) string {
814+ if err == nil {
815+ return ""
816+ }
817+
818+ msg := strings.ToLower(err.Error())
819+ switch {
820+ case strings.Contains(msg, "healthcheck timeout"):
821+ return "check startup logs and confirm the health endpoint becomes ready within the configured timeout"
822+ case strings.Contains(msg, "unhealthy"):
823+ return "inspect recent app logs and verify the healthcheck command reflects actual readiness"
824+ case strings.Contains(msg, "no healthcheck detected"):
825+ return "add a Docker healthcheck or switch readiness mode"
826+ case isPullFailure(err), strings.Contains(msg, "access denied"), strings.Contains(msg, "unauthorized"):
827+ return "verify registry auth and confirm the image exists at the requested tag"
828+ default:
829+ return ""
830+ }
831+}
832+
833+func isPullFailure(err error) bool {
834+ if err == nil {
835+ return false
836+ }
837+
838+ msg := strings.ToLower(err.Error())
839+ return strings.Contains(msg, "failed to pull image") ||
840+ strings.Contains(msg, "pull access denied") ||
841+ strings.Contains(msg, "unauthorized")
842+}
843+
647 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {844 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
648 s.mu.Lock()845 s.mu.Lock()
649 _, wasTracked := s.containers[domainName]846 _, wasTracked := s.containers[domainName]
@@ -2828,6 +3025,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
2828 if status == "healthy" {3025 if status == "healthy" {
2829 return nil3026 return nil
2830 }3027 }
3028+ if status == "unhealthy" {
3029+ return errors.New("container reported unhealthy")
3030+ }
2831 lastStatus = status3031 lastStatus = status
2832 3032
2833 select {3033 select {
M internal/usecase/container/service_test.go +153 -2
@@ -1,10 +1,14 @@
11 package container
22
33 import (
4+ "bytes"
45 "context"
6+ "encoding/binary"
57 "errors"
68 "fmt"
9+ "io"
710 "net"
11+ "strings"
812 "sync"
913 "testing"
1014 "time"
@@ -27,6 +31,20 @@ func testContext() context.Context {
2731 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2832 }
2933
34+func dockerLogFrames(stream byte, lines ...string) io.ReadCloser {
35+ var buf bytes.Buffer
36+ for _, line := range lines {
37+ payload := []byte(line)
38+ header := make([]byte, 8)
39+ header[0] = stream
40+ binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
41+ _, _ = buf.Write(header)
42+ _, _ = buf.Write(payload)
43+ }
44+
45+ return io.NopCloser(bytes.NewReader(buf.Bytes()))
46+}
47+
3048 // testMinDelayConfig returns a Config with all timing delays set to 1ms,
3149 // suitable for unit tests that don't want to wait for real timeouts.
3250 func testMinDelayConfig() Config {
@@ -474,7 +492,132 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
474492
475493 assert.Error(t, err)
476494 assert.Nil(t, result)
477- assert.Contains(t, err.Error(), "failed to pull image")
495+ var deployErr *domain.DeployFailureError
496+ require.ErrorAs(t, err, &deployErr)
497+ assert.Equal(t, "failed to deploy", deployErr.Error())
498+ assert.Equal(t, "failed to pull image", deployErr.Cause)
499+ assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501+ assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503+}
504+
505+func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
508+
509+ ctx, cancel := context.WithCancel(testContext())
510+ cancel()
511+
512+ logs := svc.captureRecentContainerLogs(ctx, "container-123")
513+
514+ assert.Nil(t, logs)
515+}
516+
517+func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
518+ runtime := mocks.NewMockContainerRuntime(t)
519+ envLoader := mocks.NewMockEnvLoader(t)
520+ eventBus := mocks.NewMockEventPublisher(t)
521+
522+ config := Config{
523+ ReadinessMode: "docker-health",
524+ HealthTimeout: 10 * time.Millisecond,
525+ ReadinessDelay: time.Millisecond,
526+ DrainDelay: time.Millisecond,
527+ DrainDelayConfigured: true,
528+ }
529+
530+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
531+ ctx := testContext()
532+
533+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
534+
535+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
536+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
537+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
538+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
539+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
541+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
542+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
543+
544+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
545+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
546+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
547+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
548+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("starting", true, nil).Twice()
549+
550+ logLines := make([]string, 0, 25)
551+ for i := 1; i <= 25; i++ {
552+ logLines = append(logLines, fmt.Sprintf("line-%02d\n", i))
553+ }
554+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, logLines...), nil)
555+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
556+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
557+
558+ result, err := svc.Deploy(ctx, route)
559+
560+ assert.Nil(t, result)
561+ var deployErr *domain.DeployFailureError
562+ require.ErrorAs(t, err, &deployErr)
563+ assert.Equal(t, "failed to deploy", deployErr.Summary)
564+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
565+ assert.Equal(t, "check startup logs and confirm the health endpoint becomes ready within the configured timeout", deployErr.Hint)
566+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
567+ assert.Equal(t, "new-container", deployErr.ContainerID)
568+ require.Len(t, deployErr.Logs, 20)
569+ assert.Equal(t, "line-06", deployErr.Logs[0])
570+ assert.Equal(t, "line-25", deployErr.Logs[19])
571+ assert.True(t, strings.Contains(err.Error(), "failed to deploy"))
572+ assert.ErrorContains(t, deployErr.Err, "last status: starting")
573+}
574+
575+func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
576+ runtime := mocks.NewMockContainerRuntime(t)
577+ envLoader := mocks.NewMockEnvLoader(t)
578+ eventBus := mocks.NewMockEventPublisher(t)
579+
580+ config := Config{
581+ ReadinessMode: "docker-health",
582+ ReadinessDelay: time.Millisecond,
583+ DrainDelay: time.Millisecond,
584+ DrainDelayConfigured: true,
585+ }
586+
587+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
588+ ctx := testContext()
589+
590+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
591+
592+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
593+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
594+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
595+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
596+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
597+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
598+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
599+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
600+
601+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
602+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
603+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
604+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
605+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("", false, nil).Once()
606+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "booting\n"), nil)
607+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
608+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
609+
610+ result, err := svc.Deploy(ctx, route)
611+
612+ assert.Nil(t, result)
613+ var deployErr *domain.DeployFailureError
614+ require.ErrorAs(t, err, &deployErr)
615+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
616+ assert.Equal(t, "add a Docker healthcheck or switch readiness mode", deployErr.Hint)
617+ assert.Equal(t, []string{"booting"}, deployErr.Logs)
618+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
619+ assert.Equal(t, "new-container", deployErr.ContainerID)
620+ assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
478621 }
479622
480623 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
@@ -3601,6 +3744,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
36013744 // Recovery window poll: return error to fail fast instead of waiting 30s
36023745 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
36033746 Return(false, errors.New("container exited")).Once()
3747+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
36043748
36053749 // cleanupFailedContainer: stop and remove the failed new container
36063750 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
@@ -3615,7 +3759,14 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
36153759 // Deploy should return an error (readiness failure)
36163760 assert.Error(t, err)
36173761 assert.Nil(t, result)
3618- assert.Contains(t, err.Error(), "readiness check")
3762+ var deployErr *domain.DeployFailureError
3763+ require.ErrorAs(t, err, &deployErr)
3764+ assert.Equal(t, "failed to deploy", deployErr.Error())
3765+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
3766+ assert.Equal(t, []string{"crash line"}, deployErr.Logs)
3767+ assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
3768+ assert.Equal(t, "new-container", deployErr.ContainerID)
3769+ assert.ErrorContains(t, deployErr.Err, "container exited")
36193770
36203771 // Old container must still be tracked — completely untouched
36213772 tracked, exists := svc.Get(ctx, "test.example.com")
@@ -1,10 +1,14 @@
1 package container1 package container
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "encoding/binary"
5 "errors"7 "errors"
6 "fmt"8 "fmt"
9+ "io"
7 "net"10 "net"
11+ "strings"
8 "sync"12 "sync"
9 "testing"13 "testing"
10 "time"14 "time"
@@ -27,6 +31,20 @@ func testContext() context.Context {
27 return zerowrap.WithCtx(context.Background(), zerowrap.Default())31 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
28 }32 }
29 33
34+func dockerLogFrames(stream byte, lines ...string) io.ReadCloser {
35+ var buf bytes.Buffer
36+ for _, line := range lines {
37+ payload := []byte(line)
38+ header := make([]byte, 8)
39+ header[0] = stream
40+ binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
41+ _, _ = buf.Write(header)
42+ _, _ = buf.Write(payload)
43+ }
44+
45+ return io.NopCloser(bytes.NewReader(buf.Bytes()))
46+}
47+
30 // testMinDelayConfig returns a Config with all timing delays set to 1ms,48 // testMinDelayConfig returns a Config with all timing delays set to 1ms,
31 // suitable for unit tests that don't want to wait for real timeouts.49 // suitable for unit tests that don't want to wait for real timeouts.
32 func testMinDelayConfig() Config {50 func testMinDelayConfig() Config {
@@ -474,7 +492,132 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
474 492
475 assert.Error(t, err)493 assert.Error(t, err)
476 assert.Nil(t, result)494 assert.Nil(t, result)
477- assert.Contains(t, err.Error(), "failed to pull image")495+ var deployErr *domain.DeployFailureError
496+ require.ErrorAs(t, err, &deployErr)
497+ assert.Equal(t, "failed to deploy", deployErr.Error())
498+ assert.Equal(t, "failed to pull image", deployErr.Cause)
499+ assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501+ assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503+}
504+
505+func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
508+
509+ ctx, cancel := context.WithCancel(testContext())
510+ cancel()
511+
512+ logs := svc.captureRecentContainerLogs(ctx, "container-123")
513+
514+ assert.Nil(t, logs)
515+}
516+
517+func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
518+ runtime := mocks.NewMockContainerRuntime(t)
519+ envLoader := mocks.NewMockEnvLoader(t)
520+ eventBus := mocks.NewMockEventPublisher(t)
521+
522+ config := Config{
523+ ReadinessMode: "docker-health",
524+ HealthTimeout: 10 * time.Millisecond,
525+ ReadinessDelay: time.Millisecond,
526+ DrainDelay: time.Millisecond,
527+ DrainDelayConfigured: true,
528+ }
529+
530+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
531+ ctx := testContext()
532+
533+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
534+
535+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
536+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
537+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
538+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
539+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
541+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
542+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
543+
544+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
545+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
546+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
547+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
548+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("starting", true, nil).Twice()
549+
550+ logLines := make([]string, 0, 25)
551+ for i := 1; i <= 25; i++ {
552+ logLines = append(logLines, fmt.Sprintf("line-%02d\n", i))
553+ }
554+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, logLines...), nil)
555+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
556+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
557+
558+ result, err := svc.Deploy(ctx, route)
559+
560+ assert.Nil(t, result)
561+ var deployErr *domain.DeployFailureError
562+ require.ErrorAs(t, err, &deployErr)
563+ assert.Equal(t, "failed to deploy", deployErr.Summary)
564+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
565+ assert.Equal(t, "check startup logs and confirm the health endpoint becomes ready within the configured timeout", deployErr.Hint)
566+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
567+ assert.Equal(t, "new-container", deployErr.ContainerID)
568+ require.Len(t, deployErr.Logs, 20)
569+ assert.Equal(t, "line-06", deployErr.Logs[0])
570+ assert.Equal(t, "line-25", deployErr.Logs[19])
571+ assert.True(t, strings.Contains(err.Error(), "failed to deploy"))
572+ assert.ErrorContains(t, deployErr.Err, "last status: starting")
573+}
574+
575+func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
576+ runtime := mocks.NewMockContainerRuntime(t)
577+ envLoader := mocks.NewMockEnvLoader(t)
578+ eventBus := mocks.NewMockEventPublisher(t)
579+
580+ config := Config{
581+ ReadinessMode: "docker-health",
582+ ReadinessDelay: time.Millisecond,
583+ DrainDelay: time.Millisecond,
584+ DrainDelayConfigured: true,
585+ }
586+
587+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
588+ ctx := testContext()
589+
590+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
591+
592+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
593+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
594+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
595+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
596+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
597+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
598+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
599+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
600+
601+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
602+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
603+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
604+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
605+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("", false, nil).Once()
606+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "booting\n"), nil)
607+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
608+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
609+
610+ result, err := svc.Deploy(ctx, route)
611+
612+ assert.Nil(t, result)
613+ var deployErr *domain.DeployFailureError
614+ require.ErrorAs(t, err, &deployErr)
615+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
616+ assert.Equal(t, "add a Docker healthcheck or switch readiness mode", deployErr.Hint)
617+ assert.Equal(t, []string{"booting"}, deployErr.Logs)
618+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
619+ assert.Equal(t, "new-container", deployErr.ContainerID)
620+ assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
478 }621 }
479 622
480 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {623 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
@@ -3601,6 +3744,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3601 // Recovery window poll: return error to fail fast instead of waiting 30s3744 // Recovery window poll: return error to fail fast instead of waiting 30s
3602 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").3745 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
3603 Return(false, errors.New("container exited")).Once()3746 Return(false, errors.New("container exited")).Once()
3747+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
3604 3748
3605 // cleanupFailedContainer: stop and remove the failed new container3749 // cleanupFailedContainer: stop and remove the failed new container
3606 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)3750 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
@@ -3615,7 +3759,14 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3615 // Deploy should return an error (readiness failure)3759 // Deploy should return an error (readiness failure)
3616 assert.Error(t, err)3760 assert.Error(t, err)
3617 assert.Nil(t, result)3761 assert.Nil(t, result)
3618- assert.Contains(t, err.Error(), "readiness check")3762+ var deployErr *domain.DeployFailureError
3763+ require.ErrorAs(t, err, &deployErr)
3764+ assert.Equal(t, "failed to deploy", deployErr.Error())
3765+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
3766+ assert.Equal(t, []string{"crash line"}, deployErr.Logs)
3767+ assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
3768+ assert.Equal(t, "new-container", deployErr.ContainerID)
3769+ assert.ErrorContains(t, deployErr.Err, "container exited")
3619 3770
3620 // Old container must still be tracked — completely untouched3771 // Old container must still be tracked — completely untouched
3621 tracked, exists := svc.Get(ctx, "test.example.com")3772 tracked, exists := svc.Get(ctx, "test.example.com")

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