v3.4.1 de8f68e

live_runtime_test.go RawHistoryFolder

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}

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