detached d95917d

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
507507 return nil, log.WrapErr(err, "failed to start container")
508508 }
509509 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 {
511511 s.cleanupFailedContainer(ctx, newContainer.ID)
512512 return nil, log.WrapErr(err, "container failed readiness check")
513513 }
@@ -2146,7 +2146,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
21462146 return nil
21472147 }
21482148
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 {
21502150 if err := s.pollContainerRunning(ctx, containerID); err != nil {
21512151 return err
21522152 }
@@ -2173,15 +2173,75 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
21732173 }
21742174 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
21752175 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")
21792213 }
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)
21822227 }
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
21842243 }
2244+ return ip, port, nil
21852245 }
21862246
21872247 // 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 nil2146 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 err2151 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: // auto2175 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 err2178+}
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 (
99 "github.com/stretchr/testify/mock"
1010
1111 "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+ "github.com/bnema/gordon/internal/domain"
1213 )
1314
1415 func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
@@ -28,7 +29,156 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
2829 // Delay-mode fallback verification
2930 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
3031
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)
32182 assert.NoError(t, err)
33183 }
34184
@@ -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 verification29 // 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

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