1//go:build live
2
3// Live runtime verification against a real Docker daemon. These tests are
4// opt-in: build with `-tags live` and run with the daemon reachable. They
5// create and remove their own networks, volumes, and containers.
6//
7// go test -tags live ./internal/adapters/out/docker/ -run TestLive -count=1 -v
8
9package docker
10
11import (
12 "context"
13 "fmt"
14 "net"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/moby/moby/client"
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22
23 "github.com/bnema/gordon/internal/domain"
24)
25
26const liveImage = "golang:1.27-alpine"
27
28func liveRuntime(t *testing.T) (*Runtime, context.Context) {
29 t.Helper()
30 cli, err := client.New(
31 client.FromEnv,
32 client.WithAPIVersionNegotiation(),
33 )
34 require.NoError(t, err)
35 runtime := NewRuntimeWithClient(cli)
36
37 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
38 t.Cleanup(cancel)
39 require.NoError(t, runtime.Ping(ctx), "a live Docker daemon is required")
40 return runtime, ctx
41}
42
43func liveName(prefix string) string {
44 return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
45}
46
47func liveCreate(t *testing.T, runtime *Runtime, ctx context.Context, config *domain.ContainerConfig) *domain.Container {
48 t.Helper()
49 created, err := runtime.CreateContainer(ctx, config)
50 require.NoError(t, err)
51 t.Cleanup(func() {
52 cleanup, cancel := context.WithTimeout(context.Background(), 60*time.Second)
53 defer cancel()
54 // Force-remove in one step. A separate StopContainer would wait the
55 // full stop grace (30s) and then consume the cleanup budget, leaving
56 // the container behind and exhausting the daemon's address pool.
57 _ = runtime.RemoveContainer(cleanup, created.ID, true)
58 })
59 require.NoError(t, runtime.StartContainer(ctx, created.ID))
60 return created
61}
62
63// TestLiveRuntime_PrivateNetworksIsolateApps proves containers on two
64// app-owned networks cannot reach each other, while containers sharing one
65// network can.
66func TestLiveRuntime_PrivateNetworksIsolateApps(t *testing.T) {
67 runtime, ctx := liveRuntime(t)
68 netA := liveName("gordon-live-a")
69 netB := liveName("gordon-live-b")
70 for _, name := range []string{netA, netB} {
71 require.NoError(t, runtime.CreateNetwork(ctx, name, domain.NetworkConfig{
72 Driver: "bridge", Labels: domain.AppPrivateNetworkLabels("live", name),
73 }))
74 networkName := name
75 t.Cleanup(func() {
76 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
77 defer cancel()
78 _ = runtime.RemoveNetwork(cleanup, networkName)
79 })
80 }
81
82 listenerName := liveName("live-a")
83 peerName := liveName("live-b")
84 foreignName := liveName("live-c")
85 listenCmd := []string{"sh", "-c", "while true; do nc -l -p 1234 >/dev/null 2>&1; done"}
86 probeCmd := []string{"sh", "-c", "nc -z -w 2 " + listenerName + " 1234"}
87
88 liveCreate(t, runtime, ctx, &domain.ContainerConfig{
89 Image: liveImage, Name: listenerName, NetworkMode: netA,
90 Entrypoint: listenCmd, AutoRemove: false,
91 })
92 liveCreate(t, runtime, ctx, &domain.ContainerConfig{
93 Image: liveImage, Name: peerName, NetworkMode: netA,
94 Entrypoint: []string{"sleep", "120"}, AutoRemove: false,
95 })
96 liveCreate(t, runtime, ctx, &domain.ContainerConfig{
97 Image: liveImage, Name: foreignName, NetworkMode: netB,
98 Entrypoint: []string{"sleep", "120"}, AutoRemove: false,
99 })
100
101 // Give the listener a moment, then prove same-network reachability.
102 require.Eventually(t, func() bool {
103 result, err := runtime.ExecInContainer(ctx, peerName, probeCmd)
104 return err == nil && result.ExitCode == 0
105 }, 30*time.Second, time.Second, "containers on the same app network must reach each other")
106
107 // A container on another app network must not reach the listener: the
108 // name does not resolve and the network is not routable.
109 result, err := runtime.ExecInContainer(ctx, foreignName, probeCmd)
110 if err == nil {
111 assert.NotZero(t, result.ExitCode, "cross-app network traffic must be impossible")
112 }
113}
114
115// TestLiveRuntime_LoopbackPublishAndObservedBind proves a backend port is
116// published only on loopback and that the adapter reports exactly that
117// binding.
118func TestLiveRuntime_LoopbackPublishAndObservedBind(t *testing.T) {
119 runtime, ctx := liveRuntime(t)
120 network := liveName("gordon-live-l4")
121 require.NoError(t, runtime.CreateNetwork(ctx, network, domain.NetworkConfig{
122 Driver: "bridge", Labels: domain.AppPrivateNetworkLabels("live", network),
123 }))
124 t.Cleanup(func() {
125 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
126 defer cancel()
127 _ = runtime.RemoveNetwork(cleanup, network)
128 })
129
130 created := liveCreate(t, runtime, ctx, &domain.ContainerConfig{
131 Image: liveImage, Name: liveName("live-l4"), NetworkMode: network,
132 Entrypoint: []string{"sh", "-c", "while true; do nc -l -p 8080 >/dev/null 2>&1; done"},
133 PortPublishes: []domain.ContainerPortPublish{{
134 HostIP: "127.0.0.1", HostPort: 0, ContainerPort: 8080, Protocol: domain.NetworkProtocolTCP,
135 }},
136 AutoRemove: false,
137 })
138
139 binds, err := runtime.GetContainerBackendBinds(ctx, created.ID, []domain.ContainerBackendPort{
140 {ContainerPort: 8080, Protocol: domain.NetworkProtocolTCP},
141 })
142 require.NoError(t, err)
143 require.Len(t, binds, 1)
144 hostPort := binds[0].HostPort
145 require.Positive(t, hostPort)
146
147 // The loopback bind accepts a connection from the host. A wildcard or
148 // container-address bind would have been rejected by
149 // GetContainerBackendBinds above, which requires exactly one 127.0.0.1
150 // mapping.
151 require.Eventually(t, func() bool {
152 conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", hostPort), time.Second)
153 if dialErr != nil {
154 return false
155 }
156 _ = conn.Close()
157 return true
158 }, 30*time.Second, time.Second, "the loopback bind must accept connections")
159}
160
161// TestLiveRuntime_ReadOnlyVolumeIsMountedReadOnly proves a volume declared
162// read-only rejects writes while a writable volume accepts them.
163func TestLiveRuntime_ReadOnlyVolumeIsMountedReadOnly(t *testing.T) {
164 runtime, ctx := liveRuntime(t)
165 network := liveName("gordon-live-ro")
166 require.NoError(t, runtime.CreateNetwork(ctx, network, domain.NetworkConfig{
167 Driver: "bridge", Labels: domain.AppPrivateNetworkLabels("live", network),
168 }))
169 t.Cleanup(func() {
170 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
171 defer cancel()
172 _ = runtime.RemoveNetwork(cleanup, network)
173 })
174
175 roVolume := liveName("live-ro-vol")
176 rwVolume := liveName("live-rw-vol")
177 for _, name := range []string{roVolume, rwVolume} {
178 require.NoError(t, runtime.CreateVolume(ctx, name, map[string]string{domain.LabelManaged: "true"}))
179 volumeName := name
180 t.Cleanup(func() {
181 cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
182 defer cancel()
183 _ = runtime.RemoveVolume(cleanup, volumeName, true)
184 })
185 }
186
187 created := liveCreate(t, runtime, ctx, &domain.ContainerConfig{
188 Image: liveImage, Name: liveName("live-ro"), NetworkMode: network,
189 Entrypoint: []string{"sleep", "120"},
190 Volumes: map[string]string{"/rw": rwVolume},
191 ReadOnlyVolumes: map[string]string{
192 "/ro": roVolume,
193 },
194 AutoRemove: false,
195 })
196
197 writable, err := runtime.ExecInContainer(ctx, created.ID, []string{"sh", "-c", "echo data > /rw/file && cat /rw/file"})
198 require.NoError(t, err)
199 assert.Zero(t, writable.ExitCode, "a writable volume must accept writes")
200
201 readOnly, err := runtime.ExecInContainer(ctx, created.ID, []string{"sh", "-c", "echo data > /ro/file"})
202 require.NoError(t, err)
203 assert.NotZero(t, readOnly.ExitCode, "a read-only volume must reject writes")
204 assert.True(t, strings.Contains(string(readOnly.Stderr), "Read-only") ||
205 strings.Contains(string(readOnly.Stderr), "read-only") ||
206 strings.Contains(string(readOnly.Stderr), "Permission denied"),
207 "unexpected read-only failure output: %s", string(readOnly.Stderr))
208}