feat: readiness cascade — healthcheck > HTTP probe > TCP probe > delay
waitForReady now auto-detects the strongest available readiness signal in auto mode. Docker healthcheck is preferred, then HTTP probe via gordon.health label, then TCP connect probe to exposed port, then delay fallback as last resort.
- commit
- d95917d61f42c3fe4442055630217aca5ffeee3d
- author
- brice · · 2026-03-02 16:29 UTC
- parent
- d293fa6
- signature
- unsigned
2 files · +219 -9
M internal/usecase/container/service.go +68 -8
| @@ -507,7 +507,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 507 | 507 | return nil, log.WrapErr(err, "failed to start container") | |
| 508 | 508 | } | |
| 509 | 509 | if !domain.IsSkipReadiness(ctx) { | |
| 510 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 510 | + | if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil { | |
| 511 | 511 | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 512 | 512 | return nil, log.WrapErr(err, "container failed readiness check") | |
| 513 | 513 | } | |
| @@ -2146,7 +2146,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||
| 2146 | 2146 | return nil | |
| 2147 | 2147 | } | |
| 2148 | 2148 | ||
| 2149 | - | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |
| 2149 | + | func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |
| 2150 | 2150 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 2151 | 2151 | return err | |
| 2152 | 2152 | } | |
| @@ -2173,15 +2173,75 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 2173 | 2173 | } | |
| 2174 | 2174 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2175 | 2175 | default: // auto | |
| 2176 | - | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2177 | - | if err != nil { | |
| 2178 | - | return err | |
| 2176 | + | return s.readinessCascade(ctx, containerID, containerConfig, cfg) | |
| 2177 | + | } | |
| 2178 | + | } | |
| 2179 | + | ||
| 2180 | + | // readinessCascade auto-detects the strongest available readiness signal: | |
| 2181 | + | // 1. Docker healthcheck (if present) → wait for healthy status | |
| 2182 | + | // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx | |
| 2183 | + | // 3. TCP probe (if port info available) → connect until accepted | |
| 2184 | + | // 4. Delay fallback (last resort) → waitForReadyByDelay | |
| 2185 | + | func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error { | |
| 2186 | + | log := zerowrap.FromCtx(ctx) | |
| 2187 | + | ||
| 2188 | + | // 1. Docker healthcheck | |
| 2189 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2190 | + | if err != nil { | |
| 2191 | + | return err | |
| 2192 | + | } | |
| 2193 | + | if hasHealthcheck { | |
| 2194 | + | log.Info().Msg("readiness cascade: using Docker healthcheck") | |
| 2195 | + | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2196 | + | } | |
| 2197 | + | ||
| 2198 | + | // 2. HTTP probe via gordon.health label | |
| 2199 | + | if containerConfig != nil { | |
| 2200 | + | if healthPath, ok := containerConfig.Labels[domain.LabelHealth]; ok && healthPath != "" { | |
| 2201 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID, containerConfig) | |
| 2202 | + | if probeErr == nil && ip != "" && port > 0 { | |
| 2203 | + | url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath) | |
| 2204 | + | timeout := cfg.HealthTimeout | |
| 2205 | + | if timeout == 0 { | |
| 2206 | + | timeout = 60 * time.Second | |
| 2207 | + | } | |
| 2208 | + | log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe") | |
| 2209 | + | return httpProbe(ctx, url, timeout) | |
| 2210 | + | } | |
| 2211 | + | // Could not resolve endpoint; fall through to next level | |
| 2212 | + | log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint") | |
| 2179 | 2213 | } | |
| 2180 | - | if hasHealthcheck { | |
| 2181 | - | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2214 | + | } | |
| 2215 | + | ||
| 2216 | + | // 3. TCP probe (if port info available) | |
| 2217 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |
| 2218 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID, containerConfig) | |
| 2219 | + | if probeErr == nil && ip != "" && port > 0 { | |
| 2220 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |
| 2221 | + | timeout := cfg.HealthTimeout | |
| 2222 | + | if timeout == 0 { | |
| 2223 | + | timeout = 30 * time.Second | |
| 2224 | + | } | |
| 2225 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe") | |
| 2226 | + | return tcpProbe(ctx, addr, timeout) | |
| 2182 | 2227 | } | |
| 2183 | - | return s.waitForReadyByDelay(ctx, containerID) | |
| 2228 | + | // Could not resolve endpoint; fall through to delay | |
| 2229 | + | log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint") | |
| 2230 | + | } | |
| 2231 | + | ||
| 2232 | + | // 4. Delay fallback | |
| 2233 | + | log.Info().Msg("readiness cascade: using delay fallback") | |
| 2234 | + | return s.waitForReadyByDelay(ctx, containerID) | |
| 2235 | + | } | |
| 2236 | + | ||
| 2237 | + | // resolveContainerEndpoint retrieves the container's IP and first port via | |
| 2238 | + | // GetContainerNetworkInfo. The result can be used for HTTP or TCP probes. | |
| 2239 | + | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) (string, int, error) { | |
| 2240 | + | ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2241 | + | if err != nil { | |
| 2242 | + | return "", 0, err | |
| 2184 | 2243 | } | |
| 2244 | + | return ip, port, nil | |
| 2185 | 2245 | } | |
| 2186 | 2246 | ||
| 2187 | 2247 | // waitForReadyByDelay waits using the legacy running+delay strategy. | |
| @@ -507,7 +507,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 507 | return nil, log.WrapErr(err, "failed to start container") | 507 | return nil, log.WrapErr(err, "failed to start container") | ||
| 508 | } | 508 | } | ||
| 509 | if !domain.IsSkipReadiness(ctx) { | 509 | if !domain.IsSkipReadiness(ctx) { | ||
| 510 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | 510 | + | if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil { |
| 511 | s.cleanupFailedContainer(ctx, newContainer.ID) | 511 | s.cleanupFailedContainer(ctx, newContainer.ID) | ||
| 512 | return nil, log.WrapErr(err, "container failed readiness check") | 512 | return nil, log.WrapErr(err, "container failed readiness check") | ||
| 513 | } | 513 | } | ||
| @@ -2146,7 +2146,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||||
| 2146 | return nil | 2146 | return nil | ||
| 2147 | } | 2147 | } | ||
| 2148 | 2148 | ||||
| 2149 | - | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | 2149 | + | func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { |
| 2150 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | 2150 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | ||
| 2151 | return err | 2151 | return err | ||
| 2152 | } | 2152 | } | ||
| @@ -2173,15 +2173,75 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||||
| 2173 | } | 2173 | } | ||
| 2174 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | 2174 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | ||
| 2175 | default: // auto | 2175 | default: // auto | ||
| 2176 | - | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | 2176 | + | return s.readinessCascade(ctx, containerID, containerConfig, cfg) |
| 2177 | - | if err != nil { | 2177 | + | } |
| 2178 | - | return err | 2178 | + | } |
| 2179 | + | ||||
| 2180 | + | // readinessCascade auto-detects the strongest available readiness signal: | |||
| 2181 | + | // 1. Docker healthcheck (if present) → wait for healthy status | |||
| 2182 | + | // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx | |||
| 2183 | + | // 3. TCP probe (if port info available) → connect until accepted | |||
| 2184 | + | // 4. Delay fallback (last resort) → waitForReadyByDelay | |||
| 2185 | + | func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error { | |||
| 2186 | + | log := zerowrap.FromCtx(ctx) | |||
| 2187 | + | ||||
| 2188 | + | // 1. Docker healthcheck | |||
| 2189 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 2190 | + | if err != nil { | |||
| 2191 | + | return err | |||
| 2192 | + | } | |||
| 2193 | + | if hasHealthcheck { | |||
| 2194 | + | log.Info().Msg("readiness cascade: using Docker healthcheck") | |||
| 2195 | + | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |||
| 2196 | + | } | |||
| 2197 | + | ||||
| 2198 | + | // 2. HTTP probe via gordon.health label | |||
| 2199 | + | if containerConfig != nil { | |||
| 2200 | + | if healthPath, ok := containerConfig.Labels[domain.LabelHealth]; ok && healthPath != "" { | |||
| 2201 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID, containerConfig) | |||
| 2202 | + | if probeErr == nil && ip != "" && port > 0 { | |||
| 2203 | + | url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath) | |||
| 2204 | + | timeout := cfg.HealthTimeout | |||
| 2205 | + | if timeout == 0 { | |||
| 2206 | + | timeout = 60 * time.Second | |||
| 2207 | + | } | |||
| 2208 | + | log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe") | |||
| 2209 | + | return httpProbe(ctx, url, timeout) | |||
| 2210 | + | } | |||
| 2211 | + | // Could not resolve endpoint; fall through to next level | |||
| 2212 | + | log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint") | |||
| 2179 | } | 2213 | } | ||
| 2180 | - | if hasHealthcheck { | 2214 | + | } |
| 2181 | - | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | 2215 | + | |
| 2216 | + | // 3. TCP probe (if port info available) | |||
| 2217 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |||
| 2218 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID, containerConfig) | |||
| 2219 | + | if probeErr == nil && ip != "" && port > 0 { | |||
| 2220 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |||
| 2221 | + | timeout := cfg.HealthTimeout | |||
| 2222 | + | if timeout == 0 { | |||
| 2223 | + | timeout = 30 * time.Second | |||
| 2224 | + | } | |||
| 2225 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe") | |||
| 2226 | + | return tcpProbe(ctx, addr, timeout) | |||
| 2182 | } | 2227 | } | ||
| 2183 | - | return s.waitForReadyByDelay(ctx, containerID) | 2228 | + | // Could not resolve endpoint; fall through to delay |
| 2229 | + | log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint") | |||
| 2230 | + | } | |||
| 2231 | + | ||||
| 2232 | + | // 4. Delay fallback | |||
| 2233 | + | log.Info().Msg("readiness cascade: using delay fallback") | |||
| 2234 | + | return s.waitForReadyByDelay(ctx, containerID) | |||
| 2235 | + | } | |||
| 2236 | + | ||||
| 2237 | + | // resolveContainerEndpoint retrieves the container's IP and first port via | |||
| 2238 | + | // GetContainerNetworkInfo. The result can be used for HTTP or TCP probes. | |||
| 2239 | + | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) (string, int, error) { | |||
| 2240 | + | ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |||
| 2241 | + | if err != nil { | |||
| 2242 | + | return "", 0, err | |||
| 2184 | } | 2243 | } | ||
| 2244 | + | return ip, port, nil | |||
| 2185 | } | 2245 | } | ||
| 2186 | 2246 | ||||
| 2187 | // waitForReadyByDelay waits using the legacy running+delay strategy. | 2247 | // waitForReadyByDelay waits using the legacy running+delay strategy. | ||
M internal/usecase/container/service_readiness_test.go +151 -1
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/stretchr/testify/mock" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | |
| @@ -28,7 +29,156 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T | |||
| 28 | 29 | // Delay-mode fallback verification | |
| 29 | 30 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 30 | 31 | ||
| 31 | - | err := svc.waitForReady(ctx, containerID) | |
| 32 | + | err := svc.waitForReady(ctx, containerID, nil) | |
| 33 | + | assert.NoError(t, err) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) { | |
| 37 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 38 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 39 | + | ReadinessMode: "auto", | |
| 40 | + | }) | |
| 41 | + | ||
| 42 | + | ctx := testContext() | |
| 43 | + | containerID := "container-1" | |
| 44 | + | ||
| 45 | + | containerConfig := &domain.ContainerConfig{ | |
| 46 | + | Ports: []int{8080}, | |
| 47 | + | Labels: map[string]string{}, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Initial running poll | |
| 51 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 52 | + | // No Docker healthcheck | |
| 53 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 54 | + | // Cascade resolves container endpoint for TCP probe | |
| 55 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("172.17.0.5", 8080, nil).Once() | |
| 56 | + | ||
| 57 | + | // TCP probe will try to connect to 172.17.0.5:8080 — which will fail since | |
| 58 | + | // there's nothing listening. Use a short health timeout so the test doesn't hang. | |
| 59 | + | svc.mu.Lock() | |
| 60 | + | svc.config.HealthTimeout = 50 * time.Millisecond | |
| 61 | + | svc.mu.Unlock() | |
| 62 | + | ||
| 63 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 64 | + | // Expect TCP probe timeout (no server listening at 172.17.0.5:8080) | |
| 65 | + | assert.Error(t, err) | |
| 66 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) { | |
| 70 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 71 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 72 | + | ReadinessMode: "auto", | |
| 73 | + | HealthTimeout: 50 * time.Millisecond, | |
| 74 | + | }) | |
| 75 | + | ||
| 76 | + | ctx := testContext() | |
| 77 | + | containerID := "container-1" | |
| 78 | + | ||
| 79 | + | containerConfig := &domain.ContainerConfig{ | |
| 80 | + | Ports: []int{8080}, | |
| 81 | + | Labels: map[string]string{ | |
| 82 | + | domain.LabelHealth: "/healthz", | |
| 83 | + | }, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Initial running poll | |
| 87 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 88 | + | // No Docker healthcheck | |
| 89 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 90 | + | // Cascade resolves container endpoint for HTTP probe | |
| 91 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("172.17.0.5", 8080, nil).Once() | |
| 92 | + | ||
| 93 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 94 | + | // Expect HTTP probe timeout (no server listening) | |
| 95 | + | assert.Error(t, err) | |
| 96 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) { | |
| 100 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 101 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 102 | + | ReadinessMode: "auto", | |
| 103 | + | HealthTimeout: 50 * time.Millisecond, | |
| 104 | + | }) | |
| 105 | + | ||
| 106 | + | ctx := testContext() | |
| 107 | + | containerID := "container-1" | |
| 108 | + | ||
| 109 | + | containerConfig := &domain.ContainerConfig{ | |
| 110 | + | Ports: []int{8080}, | |
| 111 | + | Labels: map[string]string{ | |
| 112 | + | domain.LabelHealth: "/healthz", | |
| 113 | + | }, | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Initial running poll | |
| 117 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 118 | + | // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it | |
| 119 | + | // First call: cascade detection (hasHealthcheck=true) | |
| 120 | + | // Second call: waitForHealthy loop (status=healthy) | |
| 121 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once() | |
| 122 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once() | |
| 123 | + | ||
| 124 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 125 | + | assert.NoError(t, err) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) { | |
| 129 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 130 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 131 | + | ReadinessMode: "auto", | |
| 132 | + | ReadinessDelay: time.Millisecond, | |
| 133 | + | }) | |
| 134 | + | ||
| 135 | + | ctx := testContext() | |
| 136 | + | containerID := "container-1" | |
| 137 | + | ||
| 138 | + | // Container has ports but network info is unavailable | |
| 139 | + | containerConfig := &domain.ContainerConfig{ | |
| 140 | + | Ports: []int{8080}, | |
| 141 | + | Labels: map[string]string{}, | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Initial running poll | |
| 145 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 146 | + | // No Docker healthcheck | |
| 147 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 148 | + | // Network info fails — can't do TCP probe, fall through to delay | |
| 149 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once() | |
| 150 | + | // Delay-mode fallback verification | |
| 151 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 152 | + | ||
| 153 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 154 | + | assert.NoError(t, err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) { | |
| 158 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 159 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 160 | + | ReadinessMode: "delay", | |
| 161 | + | ReadinessDelay: time.Millisecond, | |
| 162 | + | }) | |
| 163 | + | ||
| 164 | + | ctx := testContext() | |
| 165 | + | containerID := "container-1" | |
| 166 | + | ||
| 167 | + | // Even with health label and ports, "delay" mode skips cascade entirely | |
| 168 | + | containerConfig := &domain.ContainerConfig{ | |
| 169 | + | Ports: []int{8080}, | |
| 170 | + | Labels: map[string]string{ | |
| 171 | + | domain.LabelHealth: "/healthz", | |
| 172 | + | }, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Initial running poll | |
| 176 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 177 | + | // No GetContainerHealthStatus call — delay mode skips it | |
| 178 | + | // Delay-mode verification | |
| 179 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 180 | + | ||
| 181 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 32 | 182 | assert.NoError(t, err) | |
| 33 | 183 | } | |
| 34 | 184 | ||
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/stretchr/testify/mock" | 9 | "github.com/stretchr/testify/mock" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 11 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | 15 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | ||
| @@ -28,7 +29,156 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T | |||||
| 28 | // Delay-mode fallback verification | 29 | // Delay-mode fallback verification | ||
| 29 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | 30 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | ||
| 30 | 31 | ||||
| 31 | - | err := svc.waitForReady(ctx, containerID) | 32 | + | err := svc.waitForReady(ctx, containerID, nil) |
| 33 | + | assert.NoError(t, err) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) { | |||
| 37 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 38 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 39 | + | ReadinessMode: "auto", | |||
| 40 | + | }) | |||
| 41 | + | ||||
| 42 | + | ctx := testContext() | |||
| 43 | + | containerID := "container-1" | |||
| 44 | + | ||||
| 45 | + | containerConfig := &domain.ContainerConfig{ | |||
| 46 | + | Ports: []int{8080}, | |||
| 47 | + | Labels: map[string]string{}, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Initial running poll | |||
| 51 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 52 | + | // No Docker healthcheck | |||
| 53 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 54 | + | // Cascade resolves container endpoint for TCP probe | |||
| 55 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("172.17.0.5", 8080, nil).Once() | |||
| 56 | + | ||||
| 57 | + | // TCP probe will try to connect to 172.17.0.5:8080 — which will fail since | |||
| 58 | + | // there's nothing listening. Use a short health timeout so the test doesn't hang. | |||
| 59 | + | svc.mu.Lock() | |||
| 60 | + | svc.config.HealthTimeout = 50 * time.Millisecond | |||
| 61 | + | svc.mu.Unlock() | |||
| 62 | + | ||||
| 63 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 64 | + | // Expect TCP probe timeout (no server listening at 172.17.0.5:8080) | |||
| 65 | + | assert.Error(t, err) | |||
| 66 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) { | |||
| 70 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 71 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 72 | + | ReadinessMode: "auto", | |||
| 73 | + | HealthTimeout: 50 * time.Millisecond, | |||
| 74 | + | }) | |||
| 75 | + | ||||
| 76 | + | ctx := testContext() | |||
| 77 | + | containerID := "container-1" | |||
| 78 | + | ||||
| 79 | + | containerConfig := &domain.ContainerConfig{ | |||
| 80 | + | Ports: []int{8080}, | |||
| 81 | + | Labels: map[string]string{ | |||
| 82 | + | domain.LabelHealth: "/healthz", | |||
| 83 | + | }, | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Initial running poll | |||
| 87 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 88 | + | // No Docker healthcheck | |||
| 89 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 90 | + | // Cascade resolves container endpoint for HTTP probe | |||
| 91 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("172.17.0.5", 8080, nil).Once() | |||
| 92 | + | ||||
| 93 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 94 | + | // Expect HTTP probe timeout (no server listening) | |||
| 95 | + | assert.Error(t, err) | |||
| 96 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) { | |||
| 100 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 101 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 102 | + | ReadinessMode: "auto", | |||
| 103 | + | HealthTimeout: 50 * time.Millisecond, | |||
| 104 | + | }) | |||
| 105 | + | ||||
| 106 | + | ctx := testContext() | |||
| 107 | + | containerID := "container-1" | |||
| 108 | + | ||||
| 109 | + | containerConfig := &domain.ContainerConfig{ | |||
| 110 | + | Ports: []int{8080}, | |||
| 111 | + | Labels: map[string]string{ | |||
| 112 | + | domain.LabelHealth: "/healthz", | |||
| 113 | + | }, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Initial running poll | |||
| 117 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 118 | + | // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it | |||
| 119 | + | // First call: cascade detection (hasHealthcheck=true) | |||
| 120 | + | // Second call: waitForHealthy loop (status=healthy) | |||
| 121 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once() | |||
| 122 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once() | |||
| 123 | + | ||||
| 124 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 125 | + | assert.NoError(t, err) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) { | |||
| 129 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 130 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 131 | + | ReadinessMode: "auto", | |||
| 132 | + | ReadinessDelay: time.Millisecond, | |||
| 133 | + | }) | |||
| 134 | + | ||||
| 135 | + | ctx := testContext() | |||
| 136 | + | containerID := "container-1" | |||
| 137 | + | ||||
| 138 | + | // Container has ports but network info is unavailable | |||
| 139 | + | containerConfig := &domain.ContainerConfig{ | |||
| 140 | + | Ports: []int{8080}, | |||
| 141 | + | Labels: map[string]string{}, | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Initial running poll | |||
| 145 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 146 | + | // No Docker healthcheck | |||
| 147 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 148 | + | // Network info fails — can't do TCP probe, fall through to delay | |||
| 149 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once() | |||
| 150 | + | // Delay-mode fallback verification | |||
| 151 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 152 | + | ||||
| 153 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 154 | + | assert.NoError(t, err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) { | |||
| 158 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 159 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 160 | + | ReadinessMode: "delay", | |||
| 161 | + | ReadinessDelay: time.Millisecond, | |||
| 162 | + | }) | |||
| 163 | + | ||||
| 164 | + | ctx := testContext() | |||
| 165 | + | containerID := "container-1" | |||
| 166 | + | ||||
| 167 | + | // Even with health label and ports, "delay" mode skips cascade entirely | |||
| 168 | + | containerConfig := &domain.ContainerConfig{ | |||
| 169 | + | Ports: []int{8080}, | |||
| 170 | + | Labels: map[string]string{ | |||
| 171 | + | domain.LabelHealth: "/healthz", | |||
| 172 | + | }, | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // Initial running poll | |||
| 176 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 177 | + | // No GetContainerHealthStatus call — delay mode skips it | |||
| 178 | + | // Delay-mode verification | |||
| 179 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 180 | + | ||||
| 181 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 32 | assert.NoError(t, err) | 182 | assert.NoError(t, err) | ||
| 33 | } | 183 | } | ||
| 34 | 184 | ||||