1package docker
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/moby/moby/api/types/container"
17
18 "github.com/bnema/gordon/internal/domain"
19)
20
21// TestBuildNetworkProbeCommand proves the helper argv carries the exact
22// target and clamps the timeout to whole seconds within its bound.
23func TestBuildNetworkProbeCommand(t *testing.T) {
24 t.Run("http carries path, whole-second timeout, and exact endpoint", func(t *testing.T) {
25 command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
26 Protocol: domain.ProbeProtocolHTTP,
27 Path: "/healthz",
28 Port: 8080,
29 Timeout: 1500 * time.Millisecond,
30 }, "172.18.0.5")
31 assert.Equal(t, httpProbeScript, command.script)
32 assert.Equal(t, []string{"/healthz", "1", "172.18.0.5", "8080"}, command.args)
33 })
34
35 t.Run("tcp carries no path", func(t *testing.T) {
36 command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
37 Protocol: domain.ProbeProtocolTCP,
38 Port: 5432,
39 Timeout: time.Second,
40 }, "172.18.0.6")
41 assert.Equal(t, tcpProbeScript, command.script)
42 assert.Equal(t, []string{"1", "172.18.0.6", "5432"}, command.args)
43 })
44
45 t.Run("nc bound stays strictly under the attempt bound", func(t *testing.T) {
46 for _, timeout := range []time.Duration{1500 * time.Millisecond, 5 * time.Second, 30 * time.Second} {
47 command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
48 Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: timeout,
49 }, "10.0.0.1")
50 seconds, err := strconv.Atoi(command.args[0])
51 require.NoError(t, err)
52 assert.Less(t, float64(seconds), timeout.Seconds(),
53 "the helper must exit before the caller context expires for %s", timeout)
54 }
55 })
56
57 t.Run("timeout is clamped into the bounded window", func(t *testing.T) {
58 tooSmall := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
59 Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: time.Millisecond,
60 }, "10.0.0.1")
61 assert.Equal(t, "1", tooSmall.args[0])
62
63 tooLarge := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
64 Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: time.Hour,
65 }, "10.0.0.1")
66 assert.Equal(t, "30", tooLarge.args[0])
67 })
68}
69
70// TestProbeHelperHostConfig_IsHardened proves the helper can reach its
71// network and nothing else: no ports, mounts, volumes, or extra capability.
72func TestProbeHelperHostConfig_IsHardened(t *testing.T) {
73 config := probeHelperHostConfig("gordon--blog--net")
74 require.NotNil(t, config)
75 assert.Equal(t, "gordon--blog--net", string(config.NetworkMode))
76 assert.True(t, config.ReadonlyRootfs)
77 assert.Equal(t, []string{"ALL"}, config.CapDrop)
78 assert.Empty(t, config.CapAdd)
79 assert.Equal(t, []string{"no-new-privileges:true"}, config.SecurityOpt)
80 assert.False(t, config.Privileged)
81
82 assert.NotZero(t, config.Memory)
83 require.NotNil(t, config.PidsLimit)
84 assert.Positive(t, *config.PidsLimit)
85 assert.NotZero(t, config.NanoCPUs)
86
87 assert.Empty(t, config.PortBindings)
88 assert.Empty(t, config.Binds)
89 assert.Empty(t, config.Mounts)
90 assert.Empty(t, config.VolumesFrom)
91 assert.Empty(t, config.Devices)
92 assert.NotEqual(t, container.NetworkMode("none"), config.NetworkMode, "the helper must reach its target network")
93}
94
95// TestProbeHelperConfig_NoSharedNetworks proves the helper is created with
96// exactly one network and no aliases.
97func TestProbeHelperConfig_NoSharedNetworks(t *testing.T) {
98 config := probeHelperHostConfig("gordon--blog--net")
99 assert.Empty(t, config.ExtraHosts)
100 assert.Empty(t, config.DNS)
101 assert.Empty(t, config.Links)
102}
103
104func TestParseProbeStatus(t *testing.T) {
105 assert.Equal(t, 200, parseProbeStatus("200\n"))
106 assert.Equal(t, 404, parseProbeStatus("404"))
107 assert.Equal(t, 0, parseProbeStatus(""))
108 assert.Equal(t, 0, parseProbeStatus("not-a-status"))
109 assert.Equal(t, 0, parseProbeStatus("1234"))
110}
111
112func TestCappedProbeBuffer(t *testing.T) {
113 var buffer cappedProbeBuffer
114 input := strings.Repeat("x", 1024)
115 written, err := buffer.Write([]byte(input))
116 require.NoError(t, err)
117 assert.Equal(t, len(input), written)
118 assert.Len(t, buffer.String(), 64)
119}
120
121func TestHTTPProbeScript_RequiresThreeDigitStatus(t *testing.T) {
122 assert.Contains(t, httpProbeScript, "$2 ~ /^[0-9][0-9][0-9]$/")
123 assert.NotContains(t, httpProbeScript, "2*|3*")
124 assert.Contains(t, httpProbeScript, "head -c 128")
125}
126
127func TestProbeCleanupResult_PriorErrorNeverHidesCleanupFailure(t *testing.T) {
128 cleanupErr := errors.New("remove failed")
129 priorErr := fmt.Errorf("stale: %w", domain.ErrAppStateConflict)
130 result, err := probeCleanupResult(zerowrap.Default(), func() error { return cleanupErr }, domain.ContainerNetworkProbeResult{Ready: true}, priorErr)
131 assert.False(t, result.Ready)
132 require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
133 require.ErrorIs(t, err, priorErr)
134}
135
136func TestProbeBoundExpired_OnlySessionDeadline(t *testing.T) {
137 parent, cancel := context.WithCancel(context.Background())
138 session, sessionCancel := context.WithDeadline(parent, time.Now().Add(-time.Second))
139 defer sessionCancel()
140 <-session.Done()
141
142 assert.True(t, probeBoundExpired(session, parent),
143 "the session's own bound expiring is a readiness timeout")
144
145 // Caller cancellation must never be reclassified as a readiness
146 // timeout: it has to stay an error so the deployment stops.
147 cancel()
148 <-session.Done()
149 assert.False(t, probeBoundExpired(session, parent),
150 "caller cancellation must stay an error")
151
152 assert.False(t, probeBoundExpired(context.Background(), context.Background()),
153 "a live session has not expired")
154}
155
156func TestProbeTimeoutFailure_IsUnhealthyNotError(t *testing.T) {
157 result := probeTimeoutFailure()
158 assert.False(t, result.Ready, "a bound expiry must not report readiness")
159 assert.NotEmpty(t, result.Diagnostic, "a bound expiry must carry a bounded diagnostic")
160}
161
162func TestProbeDiagnostic(t *testing.T) {
163 http := domain.ContainerNetworkProbeRequest{Protocol: domain.ProbeProtocolHTTP}
164 assert.Equal(t, "http status 503", probeDiagnostic(http, 503))
165 assert.Equal(t, "no http response", probeDiagnostic(http, 0))
166
167 tcp := domain.ContainerNetworkProbeRequest{Protocol: domain.ProbeProtocolTCP}
168 assert.Equal(t, "tcp connection refused or timed out", probeDiagnostic(tcp, 0))
169}
170
171func TestDefaultNetworkProbeImage_IsDigestPinned(t *testing.T) {
172 assert.Contains(t, DefaultNetworkProbeImage, "@sha256:", "the helper image must be pinned by digest")
173 assert.True(t, strings.HasPrefix(DefaultNetworkProbeImage, "alpine@"), "the helper needs only busybox shell tools")
174}