detached 770a7a2

feat(deploy): capture structured deploy failures

commit
770a7a280d29e95645589d59403a555bb6b628e2
author
brice · · 2026-04-06 12:42 UTC
parent
f8fb41e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +383 -3
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 +200 -1
@@ -2,9 +2,12 @@
22 package container
33
44 import (
5+ "bufio"
56 "context"
7+ "encoding/binary"
68 "errors"
79 "fmt"
10+ "io"
811 "maps"
912 "slices"
1013 "strconv"
@@ -69,6 +72,7 @@ const (
6972 // negatives during short startup flaps.
7073 readinessRecoveryWindow = 30 * time.Second
7174 internalPullMaxAttempts = 3
75+ deployFailureLogLimit = 20
7276 )
7377
7478 // Service implements the ContainerService interface.
@@ -315,6 +319,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
315319
316320 resources, err := s.prepareDeployResources(ctx, route, existing)
317321 if err != nil {
322+ if deployErr := s.wrapPullDeployFailure(route, existing, err); deployErr != nil {
323+ return nil, deployErr
324+ }
318325 return nil, err
319326 }
320327
@@ -630,8 +637,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
630637 }
631638 if !domain.IsSkipReadiness(ctx) {
632639 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
640+ deployErr := s.newDeployFailure(newContainer, "container failed readiness check", err, s.captureRecentContainerLogs(ctx, newContainer.ID))
633641 s.cleanupFailedContainer(ctx, newContainer.ID)
634- return nil, log.WrapErr(err, "container failed readiness check")
642+ return nil, deployErr
635643 }
636644 }
637645
@@ -644,6 +652,194 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
644652 return inspected, nil
645653 }
646654
655+func (s *Service) newDeployFailure(container *domain.Container, cause string, err error, logs []string) error {
656+ deployErr := &domain.DeployFailureError{
657+ Summary: "failed to deploy",
658+ Cause: cause,
659+ Hint: inferDeployFailureHint(err),
660+ Logs: logs,
661+ Err: err,
662+ }
663+ if container != nil {
664+ deployErr.ContainerName = container.Name
665+ deployErr.ContainerID = container.ID
666+ }
667+
668+ return deployErr
669+}
670+
671+func (s *Service) wrapPullDeployFailure(route domain.Route, existing *domain.Container, err error) error {
672+ if !isPullFailure(err) {
673+ return nil
674+ }
675+
676+ return s.newDeployFailure(&domain.Container{Name: s.deploymentContainerName(route.Domain, existing)}, "failed to pull image", err, nil)
677+}
678+
679+func (s *Service) captureRecentContainerLogs(ctx context.Context, containerID string) []string {
680+ if ctx.Err() != nil {
681+ return nil
682+ }
683+
684+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, false)
685+ if err != nil {
686+ return nil
687+ }
688+ defer func() {
689+ _ = logStream.Close()
690+ }()
691+
692+ logs, err := parseDockerLogLines(logStream, deployFailureLogLimit)
693+ if err != nil {
694+ return nil
695+ }
696+
697+ return logs
698+}
699+
700+func parseDockerLogLines(r io.Reader, maxLines int) ([]string, error) {
701+ reader := bufio.NewReader(r)
702+ collector := newLogLineCollector(maxLines)
703+
704+ header, err := reader.Peek(8)
705+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {
706+ return nil, err
707+ }
708+
709+ if len(header) >= 8 && looksLikeDockerLogStream(header) {
710+ return parseDockerFramedLogLines(reader, collector)
711+ }
712+
713+ return parsePlainLogLines(reader, collector)
714+}
715+
716+func parseDockerFramedLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
717+ for {
718+ frameHeader := make([]byte, 8)
719+ if _, err := io.ReadFull(reader, frameHeader); err != nil {
720+ if isLogStreamEOF(err) {
721+ collector.flush()
722+ return collector.lines, nil
723+ }
724+ return nil, err
725+ }
726+
727+ size := int(binary.BigEndian.Uint32(frameHeader[4:8]))
728+ if size <= 0 {
729+ continue
730+ }
731+
732+ payload, err := readLogPayload(reader, size)
733+ if err != nil {
734+ collector.addChunk(string(payload))
735+ collector.flush()
736+ if isLogStreamEOF(err) {
737+ return collector.lines, nil
738+ }
739+ return nil, err
740+ }
741+
742+ collector.addChunk(string(payload))
743+ }
744+}
745+
746+func parsePlainLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
747+
748+ scanner := bufio.NewScanner(reader)
749+ for scanner.Scan() {
750+ collector.addLine(scanner.Text())
751+ }
752+ if err := scanner.Err(); err != nil {
753+ return nil, err
754+ }
755+
756+ collector.flush()
757+ return collector.lines, nil
758+}
759+
760+func readLogPayload(reader *bufio.Reader, size int) ([]byte, error) {
761+ payload := make([]byte, size)
762+ _, err := io.ReadFull(reader, payload)
763+ return payload, err
764+}
765+
766+func isLogStreamEOF(err error) bool {
767+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
768+}
769+
770+func looksLikeDockerLogStream(data []byte) bool {
771+ return len(data) >= 8 && data[1] == 0 && data[2] == 0 && data[3] == 0
772+}
773+
774+type logLineCollector struct {
775+ lines []string
776+ pending string
777+ maxLines int
778+}
779+
780+func newLogLineCollector(maxLines int) *logLineCollector {
781+ return &logLineCollector{maxLines: maxLines}
782+}
783+
784+func (c *logLineCollector) addChunk(chunk string) {
785+ for _, part := range strings.SplitAfter(chunk, "\n") {
786+ c.pending += part
787+ if strings.HasSuffix(part, "\n") {
788+ c.flush()
789+ }
790+ }
791+}
792+
793+func (c *logLineCollector) addLine(line string) {
794+ line = strings.TrimRight(line, "\r")
795+ if line == "" {
796+ return
797+ }
798+ c.lines = append(c.lines, line)
799+ if c.maxLines > 0 && len(c.lines) > c.maxLines {
800+ c.lines = c.lines[len(c.lines)-c.maxLines:]
801+ }
802+}
803+
804+func (c *logLineCollector) flush() {
805+ if c.pending == "" {
806+ return
807+ }
808+ c.addLine(strings.TrimRight(c.pending, "\r\n"))
809+ c.pending = ""
810+}
811+
812+func inferDeployFailureHint(err error) string {
813+ if err == nil {
814+ return ""
815+ }
816+
817+ msg := strings.ToLower(err.Error())
818+ switch {
819+ case strings.Contains(msg, "healthcheck timeout"):
820+ return "check startup logs and confirm the health endpoint becomes ready within the configured timeout"
821+ case strings.Contains(msg, "unhealthy"):
822+ return "inspect recent app logs and verify the healthcheck command reflects actual readiness"
823+ case strings.Contains(msg, "no healthcheck detected"):
824+ return "add a Docker healthcheck or switch readiness mode"
825+ case isPullFailure(err), strings.Contains(msg, "access denied"), strings.Contains(msg, "unauthorized"):
826+ return "verify registry auth and confirm the image exists at the requested tag"
827+ default:
828+ return ""
829+ }
830+}
831+
832+func isPullFailure(err error) bool {
833+ if err == nil {
834+ return false
835+ }
836+
837+ msg := strings.ToLower(err.Error())
838+ return strings.Contains(msg, "failed to pull image") ||
839+ strings.Contains(msg, "pull access denied") ||
840+ strings.Contains(msg, "unauthorized")
841+}
842+
647843 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
648844 s.mu.Lock()
649845 _, wasTracked := s.containers[domainName]
@@ -2828,6 +3024,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
28283024 if status == "healthy" {
28293025 return nil
28303026 }
3027+ if status == "unhealthy" {
3028+ return errors.New("container reported unhealthy")
3029+ }
28313030 lastStatus = status
28323031
28333032 select {
@@ -2,9 +2,12 @@
2 package container2 package container
3 3
4 import (4 import (
5+ "bufio"
5 "context"6 "context"
7+ "encoding/binary"
6 "errors"8 "errors"
7 "fmt"9 "fmt"
10+ "io"
8 "maps"11 "maps"
9 "slices"12 "slices"
10 "strconv"13 "strconv"
@@ -69,6 +72,7 @@ const (
69 // negatives during short startup flaps.72 // negatives during short startup flaps.
70 readinessRecoveryWindow = 30 * time.Second73 readinessRecoveryWindow = 30 * time.Second
71 internalPullMaxAttempts = 374 internalPullMaxAttempts = 3
75+ deployFailureLogLimit = 20
72 )76 )
73 77
74 // Service implements the ContainerService interface.78 // Service implements the ContainerService interface.
@@ -315,6 +319,9 @@ 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+ return nil, deployErr
324+ }
318 return nil, err325 return nil, err
319 }326 }
320 327
@@ -630,8 +637,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
630 }637 }
631 if !domain.IsSkipReadiness(ctx) {638 if !domain.IsSkipReadiness(ctx) {
632 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {639 if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
640+ deployErr := s.newDeployFailure(newContainer, "container failed readiness check", err, s.captureRecentContainerLogs(ctx, newContainer.ID))
633 s.cleanupFailedContainer(ctx, newContainer.ID)641 s.cleanupFailedContainer(ctx, newContainer.ID)
634- return nil, log.WrapErr(err, "container failed readiness check")642+ return nil, deployErr
635 }643 }
636 }644 }
637 645
@@ -644,6 +652,194 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
644 return inspected, nil652 return inspected, nil
645 }653 }
646 654
655+func (s *Service) newDeployFailure(container *domain.Container, cause string, err error, logs []string) error {
656+ deployErr := &domain.DeployFailureError{
657+ Summary: "failed to deploy",
658+ Cause: cause,
659+ Hint: inferDeployFailureHint(err),
660+ Logs: logs,
661+ Err: err,
662+ }
663+ if container != nil {
664+ deployErr.ContainerName = container.Name
665+ deployErr.ContainerID = container.ID
666+ }
667+
668+ return deployErr
669+}
670+
671+func (s *Service) wrapPullDeployFailure(route domain.Route, existing *domain.Container, err error) error {
672+ if !isPullFailure(err) {
673+ return nil
674+ }
675+
676+ return s.newDeployFailure(&domain.Container{Name: s.deploymentContainerName(route.Domain, existing)}, "failed to pull image", err, nil)
677+}
678+
679+func (s *Service) captureRecentContainerLogs(ctx context.Context, containerID string) []string {
680+ if ctx.Err() != nil {
681+ return nil
682+ }
683+
684+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, false)
685+ if err != nil {
686+ return nil
687+ }
688+ defer func() {
689+ _ = logStream.Close()
690+ }()
691+
692+ logs, err := parseDockerLogLines(logStream, deployFailureLogLimit)
693+ if err != nil {
694+ return nil
695+ }
696+
697+ return logs
698+}
699+
700+func parseDockerLogLines(r io.Reader, maxLines int) ([]string, error) {
701+ reader := bufio.NewReader(r)
702+ collector := newLogLineCollector(maxLines)
703+
704+ header, err := reader.Peek(8)
705+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {
706+ return nil, err
707+ }
708+
709+ if len(header) >= 8 && looksLikeDockerLogStream(header) {
710+ return parseDockerFramedLogLines(reader, collector)
711+ }
712+
713+ return parsePlainLogLines(reader, collector)
714+}
715+
716+func parseDockerFramedLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
717+ for {
718+ frameHeader := make([]byte, 8)
719+ if _, err := io.ReadFull(reader, frameHeader); err != nil {
720+ if isLogStreamEOF(err) {
721+ collector.flush()
722+ return collector.lines, nil
723+ }
724+ return nil, err
725+ }
726+
727+ size := int(binary.BigEndian.Uint32(frameHeader[4:8]))
728+ if size <= 0 {
729+ continue
730+ }
731+
732+ payload, err := readLogPayload(reader, size)
733+ if err != nil {
734+ collector.addChunk(string(payload))
735+ collector.flush()
736+ if isLogStreamEOF(err) {
737+ return collector.lines, nil
738+ }
739+ return nil, err
740+ }
741+
742+ collector.addChunk(string(payload))
743+ }
744+}
745+
746+func parsePlainLogLines(reader *bufio.Reader, collector *logLineCollector) ([]string, error) {
747+
748+ scanner := bufio.NewScanner(reader)
749+ for scanner.Scan() {
750+ collector.addLine(scanner.Text())
751+ }
752+ if err := scanner.Err(); err != nil {
753+ return nil, err
754+ }
755+
756+ collector.flush()
757+ return collector.lines, nil
758+}
759+
760+func readLogPayload(reader *bufio.Reader, size int) ([]byte, error) {
761+ payload := make([]byte, size)
762+ _, err := io.ReadFull(reader, payload)
763+ return payload, err
764+}
765+
766+func isLogStreamEOF(err error) bool {
767+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
768+}
769+
770+func looksLikeDockerLogStream(data []byte) bool {
771+ return len(data) >= 8 && data[1] == 0 && data[2] == 0 && data[3] == 0
772+}
773+
774+type logLineCollector struct {
775+ lines []string
776+ pending string
777+ maxLines int
778+}
779+
780+func newLogLineCollector(maxLines int) *logLineCollector {
781+ return &logLineCollector{maxLines: maxLines}
782+}
783+
784+func (c *logLineCollector) addChunk(chunk string) {
785+ for _, part := range strings.SplitAfter(chunk, "\n") {
786+ c.pending += part
787+ if strings.HasSuffix(part, "\n") {
788+ c.flush()
789+ }
790+ }
791+}
792+
793+func (c *logLineCollector) addLine(line string) {
794+ line = strings.TrimRight(line, "\r")
795+ if line == "" {
796+ return
797+ }
798+ c.lines = append(c.lines, line)
799+ if c.maxLines > 0 && len(c.lines) > c.maxLines {
800+ c.lines = c.lines[len(c.lines)-c.maxLines:]
801+ }
802+}
803+
804+func (c *logLineCollector) flush() {
805+ if c.pending == "" {
806+ return
807+ }
808+ c.addLine(strings.TrimRight(c.pending, "\r\n"))
809+ c.pending = ""
810+}
811+
812+func inferDeployFailureHint(err error) string {
813+ if err == nil {
814+ return ""
815+ }
816+
817+ msg := strings.ToLower(err.Error())
818+ switch {
819+ case strings.Contains(msg, "healthcheck timeout"):
820+ return "check startup logs and confirm the health endpoint becomes ready within the configured timeout"
821+ case strings.Contains(msg, "unhealthy"):
822+ return "inspect recent app logs and verify the healthcheck command reflects actual readiness"
823+ case strings.Contains(msg, "no healthcheck detected"):
824+ return "add a Docker healthcheck or switch readiness mode"
825+ case isPullFailure(err), strings.Contains(msg, "access denied"), strings.Contains(msg, "unauthorized"):
826+ return "verify registry auth and confirm the image exists at the requested tag"
827+ default:
828+ return ""
829+ }
830+}
831+
832+func isPullFailure(err error) bool {
833+ if err == nil {
834+ return false
835+ }
836+
837+ msg := strings.ToLower(err.Error())
838+ return strings.Contains(msg, "failed to pull image") ||
839+ strings.Contains(msg, "pull access denied") ||
840+ strings.Contains(msg, "unauthorized")
841+}
842+
647 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {843 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
648 s.mu.Lock()844 s.mu.Lock()
649 _, wasTracked := s.containers[domainName]845 _, wasTracked := s.containers[domainName]
@@ -2828,6 +3024,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
2828 if status == "healthy" {3024 if status == "healthy" {
2829 return nil3025 return nil
2830 }3026 }
3027+ if status == "unhealthy" {
3028+ return errors.New("container reported unhealthy")
3029+ }
2831 lastStatus = status3030 lastStatus = status
2832 3031
2833 select {3032 select {
M internal/usecase/container/service_test.go +153 -2
@@ -1,10 +1,14 @@
11 package container
22
33 import (
4+ "bytes"
45 "context"
6+ "encoding/binary"
57 "errors"
68 "fmt"
9+ "io"
710 "net"
11+ "strings"
812 "sync"
913 "testing"
1014 "time"
@@ -27,6 +31,20 @@ func testContext() context.Context {
2731 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2832 }
2933
34+func dockerLogFrames(stream byte, lines ...string) io.ReadCloser {
35+ var buf bytes.Buffer
36+ for _, line := range lines {
37+ payload := []byte(line)
38+ header := make([]byte, 8)
39+ header[0] = stream
40+ binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
41+ _, _ = buf.Write(header)
42+ _, _ = buf.Write(payload)
43+ }
44+
45+ return io.NopCloser(bytes.NewReader(buf.Bytes()))
46+}
47+
3048 // testMinDelayConfig returns a Config with all timing delays set to 1ms,
3149 // suitable for unit tests that don't want to wait for real timeouts.
3250 func testMinDelayConfig() Config {
@@ -474,7 +492,132 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
474492
475493 assert.Error(t, err)
476494 assert.Nil(t, result)
477- assert.Contains(t, err.Error(), "failed to pull image")
495+ var deployErr *domain.DeployFailureError
496+ require.ErrorAs(t, err, &deployErr)
497+ assert.Equal(t, "failed to deploy", deployErr.Error())
498+ assert.Equal(t, "failed to pull image", deployErr.Cause)
499+ assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501+ assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503+}
504+
505+func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
508+
509+ ctx, cancel := context.WithCancel(testContext())
510+ cancel()
511+
512+ logs := svc.captureRecentContainerLogs(ctx, "container-123")
513+
514+ assert.Nil(t, logs)
515+}
516+
517+func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
518+ runtime := mocks.NewMockContainerRuntime(t)
519+ envLoader := mocks.NewMockEnvLoader(t)
520+ eventBus := mocks.NewMockEventPublisher(t)
521+
522+ config := Config{
523+ ReadinessMode: "docker-health",
524+ HealthTimeout: 10 * time.Millisecond,
525+ ReadinessDelay: time.Millisecond,
526+ DrainDelay: time.Millisecond,
527+ DrainDelayConfigured: true,
528+ }
529+
530+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
531+ ctx := testContext()
532+
533+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
534+
535+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
536+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
537+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
538+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
539+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
541+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
542+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
543+
544+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
545+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
546+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
547+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
548+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("starting", true, nil).Twice()
549+
550+ logLines := make([]string, 0, 25)
551+ for i := 1; i <= 25; i++ {
552+ logLines = append(logLines, fmt.Sprintf("line-%02d\n", i))
553+ }
554+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, logLines...), nil)
555+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
556+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
557+
558+ result, err := svc.Deploy(ctx, route)
559+
560+ assert.Nil(t, result)
561+ var deployErr *domain.DeployFailureError
562+ require.ErrorAs(t, err, &deployErr)
563+ assert.Equal(t, "failed to deploy", deployErr.Summary)
564+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
565+ assert.Equal(t, "check startup logs and confirm the health endpoint becomes ready within the configured timeout", deployErr.Hint)
566+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
567+ assert.Equal(t, "new-container", deployErr.ContainerID)
568+ require.Len(t, deployErr.Logs, 20)
569+ assert.Equal(t, "line-06", deployErr.Logs[0])
570+ assert.Equal(t, "line-25", deployErr.Logs[19])
571+ assert.True(t, strings.Contains(err.Error(), "failed to deploy"))
572+ assert.ErrorContains(t, deployErr.Err, "last status: starting")
573+}
574+
575+func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
576+ runtime := mocks.NewMockContainerRuntime(t)
577+ envLoader := mocks.NewMockEnvLoader(t)
578+ eventBus := mocks.NewMockEventPublisher(t)
579+
580+ config := Config{
581+ ReadinessMode: "docker-health",
582+ ReadinessDelay: time.Millisecond,
583+ DrainDelay: time.Millisecond,
584+ DrainDelayConfigured: true,
585+ }
586+
587+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
588+ ctx := testContext()
589+
590+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
591+
592+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
593+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
594+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
595+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
596+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
597+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
598+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
599+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
600+
601+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
602+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
603+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
604+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
605+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("", false, nil).Once()
606+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "booting\n"), nil)
607+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
608+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
609+
610+ result, err := svc.Deploy(ctx, route)
611+
612+ assert.Nil(t, result)
613+ var deployErr *domain.DeployFailureError
614+ require.ErrorAs(t, err, &deployErr)
615+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
616+ assert.Equal(t, "add a Docker healthcheck or switch readiness mode", deployErr.Hint)
617+ assert.Equal(t, []string{"booting"}, deployErr.Logs)
618+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
619+ assert.Equal(t, "new-container", deployErr.ContainerID)
620+ assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
478621 }
479622
480623 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
@@ -3601,6 +3744,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
36013744 // Recovery window poll: return error to fail fast instead of waiting 30s
36023745 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
36033746 Return(false, errors.New("container exited")).Once()
3747+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
36043748
36053749 // cleanupFailedContainer: stop and remove the failed new container
36063750 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
@@ -3615,7 +3759,14 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
36153759 // Deploy should return an error (readiness failure)
36163760 assert.Error(t, err)
36173761 assert.Nil(t, result)
3618- assert.Contains(t, err.Error(), "readiness check")
3762+ var deployErr *domain.DeployFailureError
3763+ require.ErrorAs(t, err, &deployErr)
3764+ assert.Equal(t, "failed to deploy", deployErr.Error())
3765+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
3766+ assert.Equal(t, []string{"crash line"}, deployErr.Logs)
3767+ assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
3768+ assert.Equal(t, "new-container", deployErr.ContainerID)
3769+ assert.ErrorContains(t, deployErr.Err, "container exited")
36193770
36203771 // Old container must still be tracked — completely untouched
36213772 tracked, exists := svc.Get(ctx, "test.example.com")
@@ -1,10 +1,14 @@
1 package container1 package container
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "encoding/binary"
5 "errors"7 "errors"
6 "fmt"8 "fmt"
9+ "io"
7 "net"10 "net"
11+ "strings"
8 "sync"12 "sync"
9 "testing"13 "testing"
10 "time"14 "time"
@@ -27,6 +31,20 @@ func testContext() context.Context {
27 return zerowrap.WithCtx(context.Background(), zerowrap.Default())31 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
28 }32 }
29 33
34+func dockerLogFrames(stream byte, lines ...string) io.ReadCloser {
35+ var buf bytes.Buffer
36+ for _, line := range lines {
37+ payload := []byte(line)
38+ header := make([]byte, 8)
39+ header[0] = stream
40+ binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
41+ _, _ = buf.Write(header)
42+ _, _ = buf.Write(payload)
43+ }
44+
45+ return io.NopCloser(bytes.NewReader(buf.Bytes()))
46+}
47+
30 // testMinDelayConfig returns a Config with all timing delays set to 1ms,48 // testMinDelayConfig returns a Config with all timing delays set to 1ms,
31 // suitable for unit tests that don't want to wait for real timeouts.49 // suitable for unit tests that don't want to wait for real timeouts.
32 func testMinDelayConfig() Config {50 func testMinDelayConfig() Config {
@@ -474,7 +492,132 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
474 492
475 assert.Error(t, err)493 assert.Error(t, err)
476 assert.Nil(t, result)494 assert.Nil(t, result)
477- assert.Contains(t, err.Error(), "failed to pull image")495+ var deployErr *domain.DeployFailureError
496+ require.ErrorAs(t, err, &deployErr)
497+ assert.Equal(t, "failed to deploy", deployErr.Error())
498+ assert.Equal(t, "failed to pull image", deployErr.Cause)
499+ assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501+ assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503+}
504+
505+func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
508+
509+ ctx, cancel := context.WithCancel(testContext())
510+ cancel()
511+
512+ logs := svc.captureRecentContainerLogs(ctx, "container-123")
513+
514+ assert.Nil(t, logs)
515+}
516+
517+func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
518+ runtime := mocks.NewMockContainerRuntime(t)
519+ envLoader := mocks.NewMockEnvLoader(t)
520+ eventBus := mocks.NewMockEventPublisher(t)
521+
522+ config := Config{
523+ ReadinessMode: "docker-health",
524+ HealthTimeout: 10 * time.Millisecond,
525+ ReadinessDelay: time.Millisecond,
526+ DrainDelay: time.Millisecond,
527+ DrainDelayConfigured: true,
528+ }
529+
530+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
531+ ctx := testContext()
532+
533+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
534+
535+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
536+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
537+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
538+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
539+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
541+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
542+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
543+
544+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
545+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
546+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
547+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
548+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("starting", true, nil).Twice()
549+
550+ logLines := make([]string, 0, 25)
551+ for i := 1; i <= 25; i++ {
552+ logLines = append(logLines, fmt.Sprintf("line-%02d\n", i))
553+ }
554+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, logLines...), nil)
555+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
556+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
557+
558+ result, err := svc.Deploy(ctx, route)
559+
560+ assert.Nil(t, result)
561+ var deployErr *domain.DeployFailureError
562+ require.ErrorAs(t, err, &deployErr)
563+ assert.Equal(t, "failed to deploy", deployErr.Summary)
564+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
565+ assert.Equal(t, "check startup logs and confirm the health endpoint becomes ready within the configured timeout", deployErr.Hint)
566+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
567+ assert.Equal(t, "new-container", deployErr.ContainerID)
568+ require.Len(t, deployErr.Logs, 20)
569+ assert.Equal(t, "line-06", deployErr.Logs[0])
570+ assert.Equal(t, "line-25", deployErr.Logs[19])
571+ assert.True(t, strings.Contains(err.Error(), "failed to deploy"))
572+ assert.ErrorContains(t, deployErr.Err, "last status: starting")
573+}
574+
575+func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
576+ runtime := mocks.NewMockContainerRuntime(t)
577+ envLoader := mocks.NewMockEnvLoader(t)
578+ eventBus := mocks.NewMockEventPublisher(t)
579+
580+ config := Config{
581+ ReadinessMode: "docker-health",
582+ ReadinessDelay: time.Millisecond,
583+ DrainDelay: time.Millisecond,
584+ DrainDelayConfigured: true,
585+ }
586+
587+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
588+ ctx := testContext()
589+
590+ route := domain.Route{Domain: "test.example.com", Image: "myapp:latest"}
591+
592+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
593+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
594+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
595+ runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
596+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
597+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
598+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
599+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
600+
601+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
602+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
603+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
604+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
605+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-container").Return("", false, nil).Once()
606+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "booting\n"), nil)
607+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
608+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
609+
610+ result, err := svc.Deploy(ctx, route)
611+
612+ assert.Nil(t, result)
613+ var deployErr *domain.DeployFailureError
614+ require.ErrorAs(t, err, &deployErr)
615+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
616+ assert.Equal(t, "add a Docker healthcheck or switch readiness mode", deployErr.Hint)
617+ assert.Equal(t, []string{"booting"}, deployErr.Logs)
618+ assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
619+ assert.Equal(t, "new-container", deployErr.ContainerID)
620+ assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
478 }621 }
479 622
480 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {623 func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
@@ -3601,6 +3744,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3601 // Recovery window poll: return error to fail fast instead of waiting 30s3744 // Recovery window poll: return error to fail fast instead of waiting 30s
3602 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").3745 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
3603 Return(false, errors.New("container exited")).Once()3746 Return(false, errors.New("container exited")).Once()
3747+ runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
3604 3748
3605 // cleanupFailedContainer: stop and remove the failed new container3749 // cleanupFailedContainer: stop and remove the failed new container
3606 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)3750 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
@@ -3615,7 +3759,14 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3615 // Deploy should return an error (readiness failure)3759 // Deploy should return an error (readiness failure)
3616 assert.Error(t, err)3760 assert.Error(t, err)
3617 assert.Nil(t, result)3761 assert.Nil(t, result)
3618- assert.Contains(t, err.Error(), "readiness check")3762+ var deployErr *domain.DeployFailureError
3763+ require.ErrorAs(t, err, &deployErr)
3764+ assert.Equal(t, "failed to deploy", deployErr.Error())
3765+ assert.Equal(t, "container failed readiness check", deployErr.Cause)
3766+ assert.Equal(t, []string{"crash line"}, deployErr.Logs)
3767+ assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
3768+ assert.Equal(t, "new-container", deployErr.ContainerID)
3769+ assert.ErrorContains(t, deployErr.Err, "container exited")
3619 3770
3620 // Old container must still be tracked — completely untouched3771 // Old container must still be tracked — completely untouched
3621 tracked, exists := svc.Get(ctx, "test.example.com")3772 tracked, exists := svc.Get(ctx, "test.example.com")

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close