v2.30.0-pre4 15d9fe3

service_readiness_test.go RawHistoryFolder

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}

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