1package docker
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10
11 "github.com/moby/moby/client"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/domain"
16)
17
18func TestRuntime_CreateContainerPublishesExplicitUDPPort(t *testing.T) {
19 createBody := createTestContainer(t, &domain.ContainerConfig{
20 Image: "rust:latest",
21 Name: "gordon-rust",
22 PortPublishes: []domain.ContainerPortPublish{
23 {
24 HostIP: "127.0.0.1",
25 HostPort: 38015,
26 ContainerPort: 28015,
27 Protocol: domain.NetworkProtocolUDP,
28 },
29 },
30 })
31
32 exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
33 require.True(t, ok)
34 assert.Contains(t, exposedPorts, "28015/udp")
35
36 hostConfig, ok := createBody["HostConfig"].(map[string]any)
37 require.True(t, ok)
38 portBindings, ok := hostConfig["PortBindings"].(map[string]any)
39 require.True(t, ok)
40 bindings, ok := portBindings["28015/udp"].([]any)
41 require.True(t, ok)
42 require.Len(t, bindings, 1)
43 binding, ok := bindings[0].(map[string]any)
44 require.True(t, ok)
45 assert.Equal(t, "127.0.0.1", binding["HostIp"])
46 assert.Equal(t, "38015", binding["HostPort"])
47}
48
49func TestRuntime_CreateContainerKeepsLegacyPortsRandomLoopbackTCP(t *testing.T) {
50 createBody := createTestContainer(t, &domain.ContainerConfig{
51 Image: "nginx:latest",
52 Name: "gordon-app.example.com",
53 Ports: []int{8080},
54 })
55
56 exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
57 require.True(t, ok)
58 assert.Contains(t, exposedPorts, "8080/tcp")
59
60 hostConfig, ok := createBody["HostConfig"].(map[string]any)
61 require.True(t, ok)
62 portBindings, ok := hostConfig["PortBindings"].(map[string]any)
63 require.True(t, ok)
64 bindings, ok := portBindings["8080/tcp"].([]any)
65 require.True(t, ok)
66 require.Len(t, bindings, 1)
67 binding, ok := bindings[0].(map[string]any)
68 require.True(t, ok)
69 assert.Equal(t, "127.0.0.1", binding["HostIp"])
70 assert.Equal(t, "0", binding["HostPort"])
71}
72
73func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
74 createBody := createTestContainer(t, &domain.ContainerConfig{
75 Image: "nginx:latest",
76 Name: "gordon-app.example.com",
77 RestartPolicy: domain.RestartPolicyAlways,
78 })
79
80 hostConfig, ok := createBody["HostConfig"].(map[string]any)
81 require.True(t, ok)
82 restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
83 require.True(t, ok)
84 assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
85}
86
87func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[string]any {
88 t.Helper()
89
90 var createBody map[string]any
91 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92 switch {
93 case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/create":
94 require.NoError(t, json.NewDecoder(r.Body).Decode(&createBody))
95 w.Header().Set("Content-Type", "application/json")
96 _, _ = w.Write([]byte(`{"Id":"abc123","Warnings":null}`))
97 case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
98 w.Header().Set("Content-Type", "application/json")
99 _, _ = w.Write([]byte(`{
100 "Id":"abc123",
101 "Name":"/gordon-app.example.com",
102 "Image":"sha256:image",
103 "Created":"2026-05-05T00:00:00Z",
104 "Config":{"Image":"nginx:latest","Labels":{}},
105 "State":{"Status":"created","ExitCode":0},
106 "NetworkSettings":{"Ports":{}}
107 }`))
108 default:
109 t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
110 }
111 }))
112 defer server.Close()
113
114 host := strings.TrimPrefix(server.URL, "http://")
115 cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
116 require.NoError(t, err)
117
118 runtime := NewRuntimeWithClient(cli)
119 created, err := runtime.CreateContainer(context.Background(), config)
120 require.NoError(t, err)
121 assert.Equal(t, "abc123", created.ID)
122 require.NotNil(t, createBody)
123
124 return createBody
125}
126
127// TestRuntime_CreateContainerMountsDeclaredReadOnlyVolumes proves a mount
128// declared read-only reaches the real Docker create request as read-only.
129func TestRuntime_CreateContainerMountsDeclaredReadOnlyVolumes(t *testing.T) {
130 createBody := createTestContainer(t, &domain.ContainerConfig{
131 Image: "nginx:latest",
132 Name: "gordon-app",
133 Volumes: map[string]string{"/data": "gordon-vol-data"},
134 ReadOnlyVolumes: map[string]string{"/config": "gordon-vol-config"},
135 })
136
137 hostConfig, ok := createBody["HostConfig"].(map[string]any)
138 require.True(t, ok)
139 binds, ok := hostConfig["Binds"].([]any)
140 require.True(t, ok)
141 assert.Contains(t, binds, "gordon-vol-data:/data")
142 assert.Contains(t, binds, "gordon-vol-config:/config:ro")
143}
144
145// TestRuntime_CreateContainerAppliesNetworkAndResourceLimits proves the
146// isolation contract reaches the real Docker create request: the container
147// joins the named private network and carries the configured memory, CPU,
148// and PID limits.
149func TestRuntime_CreateContainerAppliesNetworkAndResourceLimits(t *testing.T) {
150 createBody := createTestContainer(t, &domain.ContainerConfig{
151 Image: "nginx:latest",
152 Name: "gordon-app",
153 NetworkMode: "gordon-app-abc123",
154 Aliases: []string{"web"},
155 Hostname: "web",
156 MemoryLimit: 512 << 20,
157 NanoCPUs: 1_500_000_000,
158 PidsLimit: 256,
159 })
160
161 hostConfig, ok := createBody["HostConfig"].(map[string]any)
162 require.True(t, ok)
163 assert.Equal(t, "gordon-app-abc123", hostConfig["NetworkMode"])
164 assert.Equal(t, float64(512<<20), hostConfig["Memory"])
165 assert.Equal(t, float64(1_500_000_000), hostConfig["NanoCpus"])
166 assert.Equal(t, float64(256), hostConfig["PidsLimit"])
167
168 networking, ok := createBody["NetworkingConfig"].(map[string]any)
169 require.True(t, ok)
170 endpoints, ok := networking["EndpointsConfig"].(map[string]any)
171 require.True(t, ok)
172 assert.Contains(t, endpoints, "gordon-app-abc123")
173}
174
175// TestRuntime_CreateContainerTranslatesBindMounts proves ephemeral host binds
176// reach the Docker create request as TypeBind mounts in deterministic
177// destination order, read-only preserved, alongside named-volume Binds.
178func TestRuntime_CreateContainerTranslatesBindMounts(t *testing.T) {
179 createBody := createTestContainer(t, &domain.ContainerConfig{
180 Image: "nginx:latest",
181 Name: "gordon-app",
182 Volumes: map[string]string{"/data": "gordon-vol-data"},
183 Binds: []domain.ContainerBind{
184 {Name: "rw", Source: "/srv/binds/rw", Destination: "/etc/app.conf"},
185 {Name: "ro", Source: "/srv/binds/ro", Destination: "/var/lib/data", ReadOnly: true},
186 },
187 })
188
189 hostConfig, ok := createBody["HostConfig"].(map[string]any)
190 require.True(t, ok)
191
192 // Existing named-volume Binds are preserved alongside explicit mounts.
193 binds, ok := hostConfig["Binds"].([]any)
194 require.True(t, ok)
195 assert.Contains(t, binds, "gordon-vol-data:/data")
196
197 mounts, ok := hostConfig["Mounts"].([]any)
198 require.True(t, ok)
199 require.Len(t, mounts, 2)
200
201 first, ok := mounts[0].(map[string]any)
202 require.True(t, ok)
203 assert.Equal(t, "bind", first["Type"])
204 assert.Equal(t, "/srv/binds/rw", first["Source"])
205 assert.Equal(t, "/etc/app.conf", first["Target"])
206 assert.NotContains(t, first, "ReadOnly")
207
208 second, ok := mounts[1].(map[string]any)
209 require.True(t, ok)
210 assert.Equal(t, "bind", second["Type"])
211 assert.Equal(t, "/srv/binds/ro", second["Source"])
212 assert.Equal(t, "/var/lib/data", second["Target"])
213 assert.Equal(t, true, second["ReadOnly"])
214}
215
216func TestBuildBindMounts(t *testing.T) {
217 t.Run("sorts by destination", func(t *testing.T) {
218 mounts, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{
219 {Name: "z", Source: "/srv/binds/z", Destination: "/z"},
220 {Name: "a", Source: "/srv/binds/a", Destination: "/a"},
221 }})
222 require.NoError(t, err)
223 require.Len(t, mounts, 2)
224 assert.Equal(t, "/a", mounts[0].Target)
225 assert.Equal(t, "/z", mounts[1].Target)
226 })
227
228 t.Run("invalid paths rejected without echoing source", func(t *testing.T) {
229 cases := []struct {
230 name string
231 bind domain.ContainerBind
232 }{
233 {"relative source", domain.ContainerBind{Name: "b", Source: "srv/binds", Destination: "/etc/app.conf"}},
234 {"empty source", domain.ContainerBind{Name: "b", Destination: "/etc/app.conf"}},
235 {"unclean source", domain.ContainerBind{Name: "b", Source: "/srv/../binds", Destination: "/etc/app.conf"}},
236 {"relative destination", domain.ContainerBind{Name: "b", Source: "/srv/binds", Destination: "etc/app.conf"}},
237 {"sensitive destination", domain.ContainerBind{Name: "b", Source: "/srv/binds", Destination: "/dev"}},
238 }
239 for _, tc := range cases {
240 t.Run(tc.name, func(t *testing.T) {
241 _, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{tc.bind}})
242 require.Error(t, err)
243 if tc.bind.Source != "" {
244 assert.NotContains(t, err.Error(), tc.bind.Source, "errors must never echo the host source")
245 }
246 })
247 }
248 })
249
250 t.Run("duplicate destination rejected", func(t *testing.T) {
251 _, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{
252 {Name: "one", Source: "/srv/binds/one", Destination: "/etc/app.conf"},
253 {Name: "two", Source: "/srv/binds/two", Destination: "/etc/app.conf"},
254 }})
255 require.Error(t, err)
256 })
257}