main bac21c6

internal_readiness_test.go RawHistoryFolder

1package deployment
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"testing"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19// internalSpec is an HTTP service whose only interface is internal.
20func internalSpec() domain.AppService {
21	return domain.AppService{
22		Name:      "api",
23		Image:     "registry.example.com/blog/api:1.0.0",
24		HTTP:      []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
25		Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 2 * time.Second},
26	}
27}
28
29// TestBackendPorts_InternalHTTPIsNeverPublished proves internal HTTP ports
30// gain no loopback publication, including through readiness metadata, while
31// public interfaces keep theirs.
32func TestBackendPorts_InternalHTTPIsNeverPublished(t *testing.T) {
33	internal := internalSpec()
34	assert.Empty(t, backendPorts(internal), "internal http must not be published")
35
36	public := domain.AppService{
37		Name: "web",
38		HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
39	}
40	assert.Equal(t, []int{8080}, containerPorts(backendPorts(public)))
41
42	mixed := domain.AppService{
43		HTTP: []domain.AppHTTPInterface{
44			{Host: "blog.example.com", Port: 8080, TLS: "auto"},
45			{Port: 9090, Visibility: domain.AppVisibilityInternal},
46		},
47		Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Port: 9090},
48	}
49	assert.Equal(t, []int{8080}, containerPorts(backendPorts(mixed)),
50		"the internal readiness port must not add a second publication")
51
52	tcp := domain.AppService{
53		TCP:       []domain.AppTCPInterface{{Port: 5432}},
54		Readiness: domain.AppReadiness{Type: domain.AppReadinessTCP},
55	}
56	assert.Equal(t, []int{5432}, containerPorts(backendPorts(tcp)))
57}
58
59func containerPorts(ports []domain.ContainerBackendPort) []int {
60	result := make([]int, 0, len(ports))
61	for _, port := range ports {
62		result = append(result, port.ContainerPort)
63	}
64	return result
65}
66
67// TestInternalProbeShape proves readiness type selects the probe transport.
68func TestInternalProbeShape(t *testing.T) {
69	httpProtocol, path := internalProbeShape(internalSpec())
70	assert.Equal(t, domain.ProbeProtocolHTTP, httpProtocol)
71	assert.Equal(t, "/healthz", path)
72
73	tcpSpec := internalSpec()
74	tcpSpec.Readiness = domain.AppReadiness{Type: domain.AppReadinessTCP}
75	tcpProtocol, tcpPath := internalProbeShape(tcpSpec)
76	assert.Equal(t, domain.ProbeProtocolTCP, tcpProtocol)
77	assert.Empty(t, tcpPath)
78}
79
80// TestWaitInternalReady_RetriesUntilReady proves an unhealthy attempt is
81// retried and the request carries the exact identity, network, and shape.
82func TestWaitInternalReady_RetriesUntilReady(t *testing.T) {
83	var requests []domain.ContainerNetworkProbeRequest
84	attempts := 0
85	deps := ProbeDeps{
86		containerStart: func(context.Context, string) (time.Time, error) {
87			return time.Unix(1700000000, 0).UTC(), nil
88		},
89		networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
90			requests = append(requests, request)
91			attempts++
92			if attempts < 3 {
93				return domain.ContainerNetworkProbeResult{Diagnostic: "no http response"}, nil
94			}
95			return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
96		},
97	}
98	require.NoError(t, waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "gordon--blog--net", 8080))
99	require.Len(t, requests, 3)
100	assert.Equal(t, "c-api", requests[0].TargetContainerID)
101	assert.Equal(t, "gordon--blog--net", requests[0].Network)
102	assert.Equal(t, domain.ProbeProtocolHTTP, requests[0].Protocol)
103	assert.Equal(t, "/healthz", requests[0].Path)
104	assert.Equal(t, 8080, requests[0].Port)
105	assert.Equal(t, time.Unix(1700000000, 0).UTC(), requests[0].ExpectedStartedAt)
106}
107
108// TestWaitInternalReady_InfrastructureErrorFailsImmediately proves a helper
109// that cannot run is never retried as an unhealthy attempt.
110func TestWaitInternalReady_InfrastructureErrorFailsImmediately(t *testing.T) {
111	attempts := 0
112	deps := ProbeDeps{
113		containerStart: func(context.Context, string) (time.Time, error) {
114			return time.Unix(1700000000, 0).UTC(), nil
115		},
116		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
117			attempts++
118			return domain.ContainerNetworkProbeResult{}, errors.New("helper create failed")
119		},
120	}
121	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
122	require.ErrorContains(t, err, "infrastructure error")
123	assert.Equal(t, 1, attempts, "an infrastructure failure must not be retried")
124}
125
126func TestRunInternalProbeAttempt_CleanupFailureIsNeverSuppressed(t *testing.T) {
127	request := domain.ContainerNetworkProbeRequest{Timeout: 10 * time.Millisecond}
128	deps := ProbeDeps{networkProbe: func(ctx context.Context, _ domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
129		<-ctx.Done()
130		return domain.ContainerNetworkProbeResult{}, fmt.Errorf("%w: remove failed", domain.ErrNetworkProbeCleanup)
131	}}
132
133	_, err := runInternalProbeAttempt(context.Background(), deps, request)
134	require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
135}
136
137// TestWaitInternalReady_CombinedCleanupAndStaleErrorAbortsAfterOneAttempt
138// proves a helper cleanup failure wins over the stale-generation retry: a
139// probe error wrapping both a stale sentinel and ErrNetworkProbeCleanup
140// aborts the wait after the first attempt instead of polling until the
141// readiness deadline.
142func TestWaitInternalReady_CombinedCleanupAndStaleErrorAbortsAfterOneAttempt(t *testing.T) {
143	attempts := 0
144	startReads := 0
145	deps := ProbeDeps{
146		containerStart: func(context.Context, string) (time.Time, error) {
147			startReads++
148			return time.Unix(1700000000, 0).UTC(), nil
149		},
150		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
151			attempts++
152			return domain.ContainerNetworkProbeResult{}, fmt.Errorf("stale candidate: %w (helper not removed: %w)", domain.ErrAppStateConflict, domain.ErrNetworkProbeCleanup)
153		},
154	}
155	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
156	require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
157	assert.NotContains(t, err.Error(), "timeout")
158	assert.Equal(t, 1, attempts, "a cleanup failure must abort after one attempt")
159	assert.Equal(t, 1, startReads, "a cleanup failure must not re-read the execution boundary")
160}
161
162// TestWaitInternalReady_Timeout proves a never-ready target fails at the
163// readiness deadline.
164func TestWaitInternalReady_Timeout(t *testing.T) {
165	spec := internalSpec()
166	spec.Readiness.Timeout = 50 * time.Millisecond
167	deps := ProbeDeps{
168		containerStart: func(context.Context, string) (time.Time, error) {
169			return time.Unix(1700000000, 0).UTC(), nil
170		},
171		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
172			return domain.ContainerNetworkProbeResult{Diagnostic: "http status 503"}, nil
173		},
174	}
175	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
176	require.ErrorContains(t, err, "timeout")
177	assert.ErrorContains(t, err, "http status 503")
178}
179
180// TestWaitInternalReady_Cancellation proves a canceled deployment context
181// stops polling immediately.
182func TestWaitInternalReady_Cancellation(t *testing.T) {
183	ctx, cancel := context.WithCancel(context.Background())
184	deps := ProbeDeps{
185		containerStart: func(context.Context, string) (time.Time, error) {
186			return time.Unix(1700000000, 0).UTC(), nil
187		},
188		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
189			cancel()
190			return domain.ContainerNetworkProbeResult{}, nil
191		},
192	}
193	err := waitInternalReadyWithDeps(ctx, deps, "c-api", internalSpec(), "net", 8080)
194	require.ErrorIs(t, err, context.Canceled)
195}
196
197// TestWaitInternalReady_MissingExecutionStart proves a runtime that reports
198// no execution boundary fails closed rather than probing an unidentified
199// generation.
200func TestWaitInternalReady_MissingExecutionStart(t *testing.T) {
201	deps := ProbeDeps{
202		containerStart: func(context.Context, string) (time.Time, error) {
203			return time.Time{}, nil
204		},
205		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
206			t.Fatal("must not probe without an execution start")
207			return domain.ContainerNetworkProbeResult{}, nil
208		},
209	}
210	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
211	require.ErrorContains(t, err, "execution start")
212}
213
214// TestWaitInternalReady_StaleGenerationIsRetried proves a candidate that
215// restarts during the readiness window is re-probed against its new
216// execution boundary instead of aborting the wait as an infrastructure
217// error.
218func TestWaitInternalReady_StaleGenerationIsRetried(t *testing.T) {
219	first := time.Unix(1700000000, 0).UTC()
220	second := time.Unix(1700000100, 0).UTC()
221	starts := []time.Time{first, second, second}
222	startReads := 0
223	attempts := 0
224	deps := ProbeDeps{
225		containerStart: func(context.Context, string) (time.Time, error) {
226			start := starts[min(startReads, len(starts)-1)]
227			startReads++
228			return start, nil
229		},
230		networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
231			attempts++
232			if attempts == 1 {
233				return domain.ContainerNetworkProbeResult{}, fmt.Errorf("execution changed: %w", domain.ErrAppStateConflict)
234			}
235			assert.Equal(t, second, request.ExpectedStartedAt, "the retry must use the new execution boundary")
236			return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
237		},
238	}
239	require.NoError(t, waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080))
240	assert.Equal(t, 2, attempts)
241}
242
243// TestWaitInternalReady_StaleGenerationUntilDeadlineTimesOut proves a
244// target that keeps restarting never gets reported ready: the wait ends at
245// the readiness deadline, not with a false infrastructure error.
246func TestWaitInternalReady_StaleGenerationUntilDeadlineTimesOut(t *testing.T) {
247	spec := internalSpec()
248	spec.Readiness.Timeout = 50 * time.Millisecond
249	deps := ProbeDeps{
250		containerStart: func(context.Context, string) (time.Time, error) {
251			return time.Unix(1700000000, 0).UTC(), nil
252		},
253		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
254			return domain.ContainerNetworkProbeResult{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
255		},
256	}
257	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
258	require.Error(t, err)
259	assert.NotContains(t, err.Error(), "infrastructure error")
260}
261
262// TestWaitInternalReady_VanishedCandidateKeepsPolling proves a candidate
263// that is gone on the boundary re-read is still "not ready yet": the wait
264// keeps its previous execution boundary and polls to the readiness
265// deadline instead of aborting as an infrastructure error.
266func TestWaitInternalReady_VanishedCandidateKeepsPolling(t *testing.T) {
267	spec := internalSpec()
268	spec.Readiness.Timeout = 600 * time.Millisecond
269	startReads := 0
270	attempts := 0
271	deps := ProbeDeps{
272		containerStart: func(context.Context, string) (time.Time, error) {
273			startReads++
274			if startReads == 1 {
275				return time.Unix(1700000000, 0).UTC(), nil
276			}
277			return time.Time{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
278		},
279		networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
280			attempts++
281			return domain.ContainerNetworkProbeResult{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
282		},
283	}
284	err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
285	require.ErrorContains(t, err, "timeout")
286	assert.NotContains(t, err.Error(), "infrastructure error")
287	assert.GreaterOrEqual(t, attempts, 2, "a vanished candidate must be retried, not abort the wait")
288	assert.GreaterOrEqual(t, startReads, 2, "a vanished candidate must be re-read, not abort the wait")
289}
290
291// TestReadinessContainerPort_PrefersSinglePublicHTTP proves a public+
292// internal service with no explicit readiness port probes the public HTTP
293// backend instead of failing on an unresolved port.
294func TestReadinessContainerPort_PrefersSinglePublicHTTP(t *testing.T) {
295	mixed := domain.AppService{
296		HTTP: []domain.AppHTTPInterface{
297			{Host: "blog.example.com", Port: 3000, TLS: "auto"},
298			{Port: 8080, Visibility: domain.AppVisibilityInternal},
299		},
300	}
301	assert.Equal(t, 3000, readinessContainerPort(mixed))
302
303	single := domain.AppService{HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 3000, TLS: "auto"}}}
304	assert.Equal(t, 3000, readinessContainerPort(single))
305
306	tcpOnly := domain.AppService{TCP: []domain.AppTCPInterface{{Port: 5432}}}
307	assert.Equal(t, 5432, readinessContainerPort(tcpOnly))
308}
309
310// TestWaitServiceReady_InternalUsesNetworkPath proves the dispatcher routes
311// an internal readiness port to the bounded network probe and never to the
312// loopback path.
313func TestWaitServiceReady_InternalUsesNetworkPath(t *testing.T) {
314	ctx := context.Background()
315	state := outmocks.NewMockAppState(t)
316	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "inc-1"}, nil)
317
318	networkCalls := 0
319	loopbackCalls := 0
320	service := NewService(Deps{State: state, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
321		WithProbeDeps(ProbeDeps{
322			containerStart: func(context.Context, string) (time.Time, error) {
323				return time.Unix(1700000000, 0).UTC(), nil
324			},
325			networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
326				networkCalls++
327				assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network)
328				return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
329			},
330			httpGet: func(context.Context, string, string) (int, error) {
331				loopbackCalls++
332				return 200, nil
333			},
334		})
335
336	require.NoError(t, service.waitServiceReady(ctx, "blog", "c-api", internalSpec(), nil))
337	assert.Equal(t, 1, networkCalls)
338	assert.Zero(t, loopbackCalls, "an internal port must never take the loopback path")
339}
340
341// TestWaitServiceReady_PublicKeepsLoopback proves a public readiness port
342// still probes the loopback bind and never the network path.
343func TestWaitServiceReady_PublicKeepsLoopback(t *testing.T) {
344	ctx := context.Background()
345	networkCalls := 0
346	loopbackCalls := 0
347	service := NewService(Deps{}, zerowrap.Default()).
348		WithProbeDeps(ProbeDeps{
349			httpGet: func(context.Context, string, string) (int, error) {
350				loopbackCalls++
351				return 200, nil
352			},
353			networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
354				networkCalls++
355				return domain.ContainerNetworkProbeResult{}, nil
356			},
357		})
358
359	spec := readySpec()
360	require.NoError(t, service.waitServiceReady(ctx, "blog", "c-web", spec, map[int]int{8080: 18080}))
361	assert.Equal(t, 1, loopbackCalls)
362	assert.Zero(t, networkCalls, "a public port must never take the network path")
363}

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