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}