1//go:build live
2
3// Live bounded-network-readiness verification against a real Docker
4// daemon. These tests are opt-in: build with `-tags live` and run with the
5// daemon reachable. They create and remove their own networks and
6// containers, and assert no helper is ever left behind.
7//
8// go test -tags live ./internal/adapters/out/docker/ -run TestLiveNetworkProbe -count=1 -v
9
10package docker
11
12import (
13 "context"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 "github.com/bnema/gordon/internal/domain"
21)
22
23// liveProbeNetwork creates an owned private network for one probe scenario.
24func liveProbeNetwork(t *testing.T, runtime *Runtime, ctx context.Context) string {
25 t.Helper()
26 name := liveName("gordon-live-probe")
27 require.NoError(t, runtime.CreateNetwork(ctx, name, domain.NetworkConfig{
28 Driver: "bridge", Labels: domain.AppPrivateNetworkLabels("live", name),
29 }))
30 t.Cleanup(func() {
31 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
32 defer cancel()
33 _ = runtime.RemoveNetwork(cleanup, name)
34 })
35 return name
36}
37
38// liveProbeHelpers counts leftover bounded-readiness helpers.
39func liveProbeHelpers(t *testing.T, runtime *Runtime, ctx context.Context) int {
40 t.Helper()
41 containers, err := runtime.ListContainers(ctx, true)
42 require.NoError(t, err)
43 count := 0
44 for _, c := range containers {
45 if c != nil && c.Labels[domain.LabelPurpose] == domain.PurposeNetworkProbe {
46 count++
47 }
48 }
49 return count
50}
51
52// liveProbeTarget starts one listener container on the network.
53func liveProbeTarget(t *testing.T, runtime *Runtime, ctx context.Context, network, listenScript string) *domain.Container {
54 t.Helper()
55 return liveCreate(t, runtime, ctx, &domain.ContainerConfig{
56 Image: liveImage,
57 Name: liveName("live-probe-target"),
58 NetworkMode: network,
59 Entrypoint: []string{"sh", "-c", listenScript},
60 AutoRemove: false,
61 })
62}
63
64// TestLiveNetworkProbe_PeerFacingReadyAndCleanedUp proves a published-free
65// internal listener answers a bounded probe, the helper is removed, and the
66// target exposes no host port.
67func TestLiveNetworkProbe_PeerFacingReadyAndCleanedUp(t *testing.T) {
68 runtime, ctx := liveRuntime(t)
69 network := liveProbeNetwork(t, runtime, ctx)
70 target := liveProbeTarget(t, runtime, ctx, network,
71 `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
72
73 before := liveProbeHelpers(t, runtime, ctx)
74 inspect, err := runtime.InspectContainer(ctx, target.ID)
75 require.NoError(t, err)
76 require.False(t, inspect.StartedAt.IsZero(), "the target must report an execution start")
77 assert.Empty(t, inspect.Ports, "an internal listener must not publish a host port")
78
79 result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
80 TargetContainerID: target.ID,
81 ExpectedStartedAt: inspect.StartedAt,
82 Network: network,
83 Protocol: domain.ProbeProtocolHTTP,
84 Port: 8080,
85 Path: "/healthz",
86 Timeout: 10 * time.Second,
87 })
88 require.NoError(t, err)
89 assert.True(t, result.Ready)
90 assert.Equal(t, 200, result.Status)
91
92 assert.Equal(t, before, liveProbeHelpers(t, runtime, ctx), "the helper must always be removed")
93}
94
95// TestLiveNetworkProbe_LoopbackOnlyListenerFails proves a target listening
96// only on its own loopback never reports ready from the network.
97func TestLiveNetworkProbe_LoopbackOnlyListenerFails(t *testing.T) {
98 runtime, ctx := liveRuntime(t)
99 network := liveProbeNetwork(t, runtime, ctx)
100 target := liveProbeTarget(t, runtime, ctx, network,
101 `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8081 -s 127.0.0.1; done`)
102
103 inspect, err := runtime.InspectContainer(ctx, target.ID)
104 require.NoError(t, err)
105
106 result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
107 TargetContainerID: target.ID,
108 ExpectedStartedAt: inspect.StartedAt,
109 Network: network,
110 Protocol: domain.ProbeProtocolHTTP,
111 Port: 8081,
112 Path: "/",
113 Timeout: 5 * time.Second,
114 })
115 require.NoError(t, err)
116 assert.False(t, result.Ready, "a loopback-only listener must never be reachable over the private network")
117 assert.NotEmpty(t, result.Diagnostic)
118}
119
120// TestLiveNetworkProbe_TCPReady proves a TCP readiness session accepts an
121// open port and rejects a closed one.
122func TestLiveNetworkProbe_TCPReady(t *testing.T) {
123 runtime, ctx := liveRuntime(t)
124 network := liveProbeNetwork(t, runtime, ctx)
125 target := liveProbeTarget(t, runtime, ctx, network,
126 `while true; do nc -l -p 5432 >/dev/null 2>&1; done`)
127
128 inspect, err := runtime.InspectContainer(ctx, target.ID)
129 require.NoError(t, err)
130
131 ready, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
132 TargetContainerID: target.ID,
133 ExpectedStartedAt: inspect.StartedAt,
134 Network: network,
135 Protocol: domain.ProbeProtocolTCP,
136 Port: 5432,
137 Timeout: 5 * time.Second,
138 })
139 require.NoError(t, err)
140 assert.True(t, ready.Ready)
141
142 closed, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
143 TargetContainerID: target.ID,
144 ExpectedStartedAt: inspect.StartedAt,
145 Network: network,
146 Protocol: domain.ProbeProtocolTCP,
147 Port: 5499,
148 Timeout: 2 * time.Second,
149 })
150 require.NoError(t, err)
151 assert.False(t, closed.Ready)
152}
153
154// TestLiveNetworkProbe_RejectsStaleGeneration proves a probe whose expected
155// execution start no longer matches the target fails instead of reporting
156// readiness for a generation that restarted.
157func TestLiveNetworkProbe_RejectsStaleGeneration(t *testing.T) {
158 runtime, ctx := liveRuntime(t)
159 network := liveProbeNetwork(t, runtime, ctx)
160 target := liveProbeTarget(t, runtime, ctx, network,
161 `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
162
163 _, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
164 TargetContainerID: target.ID,
165 ExpectedStartedAt: time.Now().Add(-time.Hour).UTC(),
166 Network: network,
167 Protocol: domain.ProbeProtocolHTTP,
168 Port: 8080,
169 Path: "/",
170 Timeout: 5 * time.Second,
171 })
172 require.Error(t, err)
173 assert.ErrorIs(t, err, domain.ErrAppStateConflict)
174}
175
176// TestLiveNetworkProbe_NonHTTPGreetingIsNotReady proves a listener whose
177// first line merely starts with 2 or 3 (a non-HTTP service on a
178// misdirected port) is never reported ready.
179func TestLiveNetworkProbe_NonHTTPGreetingIsNotReady(t *testing.T) {
180 runtime, ctx := liveRuntime(t)
181 network := liveProbeNetwork(t, runtime, ctx)
182 target := liveProbeTarget(t, runtime, ctx, network,
183 `while true; do printf '300 ready\r\n' | nc -l -p 8083; done`)
184
185 inspect, err := runtime.InspectContainer(ctx, target.ID)
186 require.NoError(t, err)
187
188 result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
189 TargetContainerID: target.ID,
190 ExpectedStartedAt: inspect.StartedAt,
191 Network: network,
192 Protocol: domain.ProbeProtocolHTTP,
193 Port: 8083,
194 Path: "/",
195 Timeout: 5 * time.Second,
196 })
197 require.NoError(t, err)
198 assert.False(t, result.Ready, "a non-HTTP greeting must never satisfy HTTP readiness")
199}
200
201// TestLiveNetworkProbe_HungTargetIsUnhealthyNotInfrastructure proves a
202// target that accepts the connection but never answers yields an unhealthy
203// result rather than an infrastructure error: the retry loop must keep
204// polling instead of aborting the wait.
205func TestLiveNetworkProbe_HungTargetIsUnhealthyNotInfrastructure(t *testing.T) {
206 runtime, ctx := liveRuntime(t)
207 network := liveProbeNetwork(t, runtime, ctx)
208 target := liveProbeTarget(t, runtime, ctx, network,
209 `while true; do sleep 3600 | nc -l -p 8084; done`)
210
211 inspect, err := runtime.InspectContainer(ctx, target.ID)
212 require.NoError(t, err)
213
214 result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
215 TargetContainerID: target.ID,
216 ExpectedStartedAt: inspect.StartedAt,
217 Network: network,
218 Protocol: domain.ProbeProtocolHTTP,
219 Port: 8084,
220 Path: "/",
221 Timeout: 4 * time.Second,
222 })
223 require.NoError(t, err, "a hanging target is a readiness failure, not an infrastructure error")
224 assert.False(t, result.Ready)
225 assert.NotEmpty(t, result.Diagnostic)
226}
227
228// TestLiveNetworkProbe_RejectsForeignNetwork proves the helper is bound to
229// exactly one network: a target attached elsewhere is never probed across
230// networks.
231func TestLiveNetworkProbe_RejectsForeignNetwork(t *testing.T) {
232 runtime, ctx := liveRuntime(t)
233 ownerNetwork := liveProbeNetwork(t, runtime, ctx)
234 foreignNetwork := liveProbeNetwork(t, runtime, ctx)
235 target := liveProbeTarget(t, runtime, ctx, ownerNetwork,
236 `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
237
238 before := liveProbeHelpers(t, runtime, ctx)
239 inspect, err := runtime.InspectContainer(ctx, target.ID)
240 require.NoError(t, err)
241
242 _, err = runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
243 TargetContainerID: target.ID,
244 ExpectedStartedAt: inspect.StartedAt,
245 Network: foreignNetwork,
246 Protocol: domain.ProbeProtocolHTTP,
247 Port: 8080,
248 Path: "/",
249 Timeout: 5 * time.Second,
250 })
251 require.Error(t, err)
252 assert.ErrorIs(t, err, domain.ErrAppStateConflict)
253
254 assert.Equal(t, before, liveProbeHelpers(t, runtime, ctx), "no helper may survive a rejected probe")
255}