Merge pull request #165 from bnema/deploy-error-surfacing
Surface structured deploy failures in CLI and admin API
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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | ||
| 7 | 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 | 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 | 20 | // newDeployCmd creates the deploy command. | |
| 14 | 21 | func newDeployCmd() *cobra.Command { | |
| 15 | - | return &cobra.Command{ | |
| 22 | + | var jsonOut bool | |
| 23 | + | cmd := &cobra.Command{ | |
| 16 | 24 | Use: "deploy <domain>", | |
| 17 | 25 | Short: "Manually deploy or redeploy a route", | |
| 18 | 26 | Long: `Triggers a deployment for the specified route domain. | |
| @@ -27,40 +35,83 @@ Examples: | |||
| 27 | 35 | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 28 | 36 | Args: cobra.ExactArgs(1), | |
| 29 | 37 | 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) | |
| 59 | 39 | if err != nil { | |
| 60 | 40 | return err | |
| 61 | 41 | } | |
| 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) | |
| 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 | } | |
| @@ -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 err | 40 | return err | ||
| 61 | } | 41 | } | ||
| 62 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | 42 | + | defer handle.close() |
| 63 | - | return nil | 43 | + | |
| 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 | |||
| 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.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.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) { | |||
| 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.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 | 372 | func TestParseErrorResponse_NonJSON(t *testing.T) { | |
| 338 | 373 | resp := &http.Response{ | |
| 339 | 374 | 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 | |||
| 1280 | 1280 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 1281 | 1281 | if err != nil { | |
| 1282 | 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 | 1293 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | |
| 1284 | 1294 | return | |
| 1285 | 1295 | } | |
| @@ -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 | return | 1294 | return | ||
| 1285 | } | 1295 | } | ||
M internal/adapters/in/http/admin/handler_test.go +80 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "io" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "net/http/httptest" | |
| 11 | 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 | 1429 | func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |
| 1350 | 1430 | configSvc := inmocks.NewMockConfigService(t) | |
| 1351 | 1431 | 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 @@ | |||
| 2 | 2 | package container | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bufio" | |
| 5 | 6 | "context" | |
| 7 | + | "encoding/binary" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "fmt" | |
| 10 | + | "io" | |
| 8 | 11 | "maps" | |
| 9 | 12 | "slices" | |
| 10 | 13 | "strconv" | |
| @@ -69,6 +72,7 @@ const ( | |||
| 69 | 72 | // negatives during short startup flaps. | |
| 70 | 73 | readinessRecoveryWindow = 30 * time.Second | |
| 71 | 74 | internalPullMaxAttempts = 3 | |
| 75 | + | deployFailureLogLimit = 20 | |
| 72 | 76 | ) | |
| 73 | 77 | ||
| 74 | 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 | 320 | resources, err := s.prepareDeployResources(ctx, route, existing) | |
| 317 | 321 | if err != nil { | |
| 322 | + | if deployErr := s.wrapPullDeployFailure(route, existing, err); deployErr != nil { | |
| 323 | + | err = deployErr | |
| 324 | + | return nil, err | |
| 325 | + | } | |
| 318 | 326 | return nil, err | |
| 319 | 327 | } | |
| 320 | 328 | ||
| @@ -630,8 +638,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 630 | 638 | } | |
| 631 | 639 | if !domain.IsSkipReadiness(ctx) { | |
| 632 | 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 | 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 | 653 | 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 | 844 | func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool { | |
| 648 | 845 | s.mu.Lock() | |
| 649 | 846 | _, wasTracked := s.containers[domainName] | |
| @@ -2828,6 +3025,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 2828 | 3025 | if status == "healthy" { | |
| 2829 | 3026 | return nil | |
| 2830 | 3027 | } | |
| 3028 | + | if status == "unhealthy" { | |
| 3029 | + | return errors.New("container reported unhealthy") | |
| 3030 | + | } | |
| 2831 | 3031 | lastStatus = status | |
| 2832 | 3032 | ||
| 2833 | 3033 | select { | |
| @@ -2,9 +2,12 @@ | |||||
| 2 | package container | 2 | 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.Second | 73 | readinessRecoveryWindow = 30 * time.Second | ||
| 71 | internalPullMaxAttempts = 3 | 74 | 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, err | 326 | 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, nil | 653 | 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 nil | 3026 | return nil | ||
| 2830 | } | 3027 | } | ||
| 3028 | + | if status == "unhealthy" { | |||
| 3029 | + | return errors.New("container reported unhealthy") | |||
| 3030 | + | } | |||
| 2831 | lastStatus = status | 3031 | lastStatus = status | ||
| 2832 | 3032 | ||||
| 2833 | select { | 3033 | select { | ||
M internal/usecase/container/service_test.go +153 -2
| @@ -1,10 +1,14 @@ | |||
| 1 | 1 | package container | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "encoding/binary" | |
| 5 | 7 | "errors" | |
| 6 | 8 | "fmt" | |
| 9 | + | "io" | |
| 7 | 10 | "net" | |
| 11 | + | "strings" | |
| 8 | 12 | "sync" | |
| 9 | 13 | "testing" | |
| 10 | 14 | "time" | |
| @@ -27,6 +31,20 @@ func testContext() context.Context { | |||
| 27 | 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 | 48 | // testMinDelayConfig returns a Config with all timing delays set to 1ms, | |
| 31 | 49 | // suitable for unit tests that don't want to wait for real timeouts. | |
| 32 | 50 | func testMinDelayConfig() Config { | |
| @@ -474,7 +492,132 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||
| 474 | 492 | ||
| 475 | 493 | assert.Error(t, err) | |
| 476 | 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 | 623 | func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |
| @@ -3601,6 +3744,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) | |||
| 3601 | 3744 | // Recovery window poll: return error to fail fast instead of waiting 30s | |
| 3602 | 3745 | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container"). | |
| 3603 | 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 | 3749 | // cleanupFailedContainer: stop and remove the failed new container | |
| 3606 | 3750 | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |
| @@ -3615,7 +3759,14 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) | |||
| 3615 | 3759 | // Deploy should return an error (readiness failure) | |
| 3616 | 3760 | assert.Error(t, err) | |
| 3617 | 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 | 3771 | // Old container must still be tracked — completely untouched | |
| 3621 | 3772 | tracked, exists := svc.Get(ctx, "test.example.com") | |
| @@ -1,10 +1,14 @@ | |||||
| 1 | package container | 1 | 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 30s | 3744 | // 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 container | 3749 | // 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 untouched | 3771 | // 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") | ||