feat(deploy): capture structured deploy failures
- commit
- 770a7a280d29e95645589d59403a555bb6b628e2
- author
- brice · · 2026-04-06 12:42 UTC
- parent
- f8fb41e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 @@ | |||
| 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,9 @@ 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 | + | return nil, deployErr | |
| 324 | + | } | |
| 318 | 325 | return nil, err | |
| 319 | 326 | } | |
| 320 | 327 | ||
| @@ -630,8 +637,9 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 630 | 637 | } | |
| 631 | 638 | if !domain.IsSkipReadiness(ctx) { | |
| 632 | 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 | 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 | 652 | 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 | 843 | func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool { | |
| 648 | 844 | s.mu.Lock() | |
| 649 | 845 | _, wasTracked := s.containers[domainName] | |
| @@ -2828,6 +3024,9 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 2828 | 3024 | if status == "healthy" { | |
| 2829 | 3025 | return nil | |
| 2830 | 3026 | } | |
| 3027 | + | if status == "unhealthy" { | |
| 3028 | + | return errors.New("container reported unhealthy") | |
| 3029 | + | } | |
| 2831 | 3030 | lastStatus = status | |
| 2832 | 3031 | ||
| 2833 | 3032 | 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,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, err | 325 | 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, nil | 652 | 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 nil | 3025 | return nil | ||
| 2830 | } | 3026 | } | ||
| 3027 | + | if status == "unhealthy" { | |||
| 3028 | + | return errors.New("container reported unhealthy") | |||
| 3029 | + | } | |||
| 2831 | lastStatus = status | 3030 | lastStatus = status | ||
| 2832 | 3031 | ||||
| 2833 | select { | 3032 | 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") | ||