1package container
2
3import (
4 "net"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17// reserveEphemeralAddr binds on 127.0.0.1:0, captures the assigned port, closes
18// the listener, and returns the address string. The port is guaranteed to be
19// unreachable (no process listening) when the probe runs.
20func reserveEphemeralAddr(t *testing.T) (ip string, port int) {
21 t.Helper()
22 ln, err := net.Listen("tcp", "127.0.0.1:0")
23 require.NoError(t, err)
24 addr := ln.Addr().(*net.TCPAddr)
25 ln.Close()
26 return addr.IP.String(), addr.Port
27}
28
29func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
30 runtime := mocks.NewMockContainerRuntime(t)
31 svc := NewService(runtime, nil, nil, nil, Config{
32 ReadinessMode: "auto",
33 ReadinessDelay: time.Millisecond,
34 }, nil)
35
36 ctx := testContext()
37 containerID := "container-1"
38
39 // Initial running poll
40 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
41 // Auto mode probes for healthcheck support first
42 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
43 // Delay-mode fallback verification
44 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
45
46 err := svc.waitForReady(ctx, containerID, nil)
47 assert.NoError(t, err)
48}
49
50func TestService_WaitForReady_AutoCascadeUsesDefaultHTTPAliveProbeWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
51 runtime := mocks.NewMockContainerRuntime(t)
52 svc := NewService(runtime, nil, nil, nil, Config{
53 ReadinessMode: "auto",
54 HTTPProbeTimeout: 50 * time.Millisecond,
55 }, nil)
56
57 ctx := testContext()
58 containerID := "container-1"
59
60 containerConfig := &domain.ContainerConfig{
61 Ports: []int{8080},
62 Labels: map[string]string{},
63 }
64
65 // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
66 probeIP, probePort := reserveEphemeralAddr(t)
67
68 // Initial running poll
69 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
70 // No Docker healthcheck
71 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
72 // Cascade resolves container endpoint for default HTTP alive probe
73 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
74 // Host port binding resolution — return the same loopback addr so probe hits it
75 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
76
77 // Default HTTP alive probe will try to connect — which will fail since the port is closed.
78
79 err := svc.waitForReady(ctx, containerID, containerConfig)
80 // Expect HTTP alive probe timeout (no server listening)
81 assert.Error(t, err)
82 assert.Contains(t, err.Error(), "HTTP alive probe timeout")
83}
84
85func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {
86 runtime := mocks.NewMockContainerRuntime(t)
87 svc := NewService(runtime, nil, nil, nil, Config{
88 ReadinessMode: "auto",
89 HTTPProbeTimeout: 50 * time.Millisecond,
90 }, nil)
91
92 ctx := testContext()
93 containerID := "container-1"
94
95 containerConfig := &domain.ContainerConfig{
96 Ports: []int{8080},
97 Labels: map[string]string{
98 domain.LabelHealth: "/healthz",
99 },
100 }
101
102 // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
103 probeIP, probePort := reserveEphemeralAddr(t)
104
105 // Initial running poll
106 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
107 // No Docker healthcheck
108 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
109 // Cascade resolves container endpoint for HTTP probe
110 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
111 // Host port binding resolution — return the same loopback addr so probe hits it
112 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
113
114 err := svc.waitForReady(ctx, containerID, containerConfig)
115 // Expect HTTP probe timeout (no server listening)
116 assert.Error(t, err)
117 assert.Contains(t, err.Error(), "HTTP probe timeout")
118}
119
120func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) {
121 runtime := mocks.NewMockContainerRuntime(t)
122 svc := NewService(runtime, nil, nil, nil, Config{
123 ReadinessMode: "auto",
124 HealthTimeout: 50 * time.Millisecond,
125 }, nil)
126
127 ctx := testContext()
128 containerID := "container-1"
129
130 containerConfig := &domain.ContainerConfig{
131 Ports: []int{8080},
132 Labels: map[string]string{
133 domain.LabelHealth: "/healthz",
134 },
135 }
136
137 // Initial running poll
138 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
139 // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it
140 // First call: cascade detection (hasHealthcheck=true)
141 // Second call: waitForHealthy loop (status=healthy)
142 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once()
143 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once()
144
145 err := svc.waitForReady(ctx, containerID, containerConfig)
146 assert.NoError(t, err)
147}
148
149func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) {
150 runtime := mocks.NewMockContainerRuntime(t)
151 svc := NewService(runtime, nil, nil, nil, Config{
152 ReadinessMode: "auto",
153 ReadinessDelay: time.Millisecond,
154 }, nil)
155
156 ctx := testContext()
157 containerID := "container-1"
158
159 // Container has ports but network info is unavailable
160 containerConfig := &domain.ContainerConfig{
161 Ports: []int{8080},
162 Labels: map[string]string{},
163 }
164
165 // Initial running poll
166 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
167 // No Docker healthcheck
168 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
169 // Network info fails for default HTTP alive probe, then again for TCP fallback before delay
170 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Twice()
171 // Delay-mode fallback verification
172 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
173
174 err := svc.waitForReady(ctx, containerID, containerConfig)
175 assert.NoError(t, err)
176}
177
178func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
179 runtime := mocks.NewMockContainerRuntime(t)
180 svc := NewService(runtime, nil, nil, nil, Config{
181 ReadinessMode: "delay",
182 ReadinessDelay: time.Millisecond,
183 }, nil)
184
185 ctx := testContext()
186 containerID := "container-1"
187
188 // Even with health label and ports, "delay" mode skips cascade entirely
189 containerConfig := &domain.ContainerConfig{
190 Ports: []int{8080},
191 Labels: map[string]string{
192 domain.LabelHealth: "/healthz",
193 },
194 }
195
196 // Initial running poll
197 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
198 // No GetContainerHealthStatus call — delay mode skips it
199 // Delay-mode verification
200 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
201
202 err := svc.waitForReady(ctx, containerID, containerConfig)
203 assert.NoError(t, err)
204}
205
206func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
207 runtime := mocks.NewMockContainerRuntime(t)
208 svc := NewService(runtime, nil, nil, nil, Config{}, nil)
209
210 ctx := testContext()
211 containerID := "container-1"
212
213 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once()
214
215 err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond)
216 assert.Error(t, err)
217 assert.True(t, strings.Contains(err.Error(), "last status: starting"))
218}
219
220func TestService_WaitForReady_ProbeUsesGordonProxyPortLabel(t *testing.T) {
221 runtime := mocks.NewMockContainerRuntime(t)
222 svc := NewService(runtime, nil, nil, nil, Config{
223 ReadinessMode: "auto",
224 HTTPProbeTimeout: 50 * time.Millisecond,
225 }, nil)
226
227 ctx := testContext()
228 containerID := "container-port-label"
229 labelPort := 3000
230
231 probeIP, probePort := reserveEphemeralAddr(t)
232 _ = probeIP
233
234 // Container config has gordon.proxy.port label — should be used for probe endpoint
235 containerConfig := &domain.ContainerConfig{
236 Ports: []int{22, 3000},
237 Labels: map[string]string{
238 domain.LabelProxyPort: "3000",
239 },
240 }
241
242 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
243 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
244 // Should use label port (3000), NOT call GetContainerNetworkInfo
245 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
246
247 err := svc.waitForReady(ctx, containerID, containerConfig)
248 // HTTP probe will timeout (no server), but the important thing is it used port 3000
249 assert.Error(t, err)
250 assert.Contains(t, err.Error(), "HTTP alive probe timeout")
251}
252
253func TestService_WaitForReady_ProbeUsesDeprecatedPortLabel(t *testing.T) {
254 runtime := mocks.NewMockContainerRuntime(t)
255 svc := NewService(runtime, nil, nil, nil, Config{
256 ReadinessMode: "auto",
257 HTTPProbeTimeout: 50 * time.Millisecond,
258 }, nil)
259
260 ctx := testContext()
261 containerID := "container-proxy-port"
262 labelPort := 3000
263
264 probeIP, probePort := reserveEphemeralAddr(t)
265 _ = probeIP // probeIP not used directly but port is
266
267 // Container config has deprecated gordon.port — should still work
268 containerConfig := &domain.ContainerConfig{
269 Ports: []int{22, 3000},
270 Labels: map[string]string{
271 domain.LabelPort: "3000",
272 },
273 }
274
275 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
276 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
277 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
278
279 err := svc.waitForReady(ctx, containerID, containerConfig)
280 assert.Error(t, err)
281 assert.Contains(t, err.Error(), "HTTP alive probe timeout")
282}
283
284func TestService_WaitForReady_ProxyPortTakesPriorityOverPort(t *testing.T) {
285 runtime := mocks.NewMockContainerRuntime(t)
286 svc := NewService(runtime, nil, nil, nil, Config{
287 ReadinessMode: "auto",
288 HTTPProbeTimeout: 50 * time.Millisecond,
289 }, nil)
290
291 ctx := testContext()
292 containerID := "container-priority"
293
294 probeIP, probePort := reserveEphemeralAddr(t)
295 _ = probeIP
296
297 // Both labels set — gordon.proxy.port (8080) should win over gordon.port (3000)
298 containerConfig := &domain.ContainerConfig{
299 Ports: []int{22, 3000, 8080},
300 Labels: map[string]string{
301 domain.LabelProxyPort: "8080",
302 domain.LabelPort: "3000",
303 },
304 }
305
306 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
307 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
308 // Should use gordon.proxy.port=8080, NOT gordon.port=3000
309 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, 8080).Return(probePort, nil).Once()
310
311 err := svc.waitForReady(ctx, containerID, containerConfig)
312 assert.Error(t, err)
313 assert.Contains(t, err.Error(), "HTTP alive probe timeout")
314}