Skip to content

Commit 72f379b

Browse files
authored
Merge 870ce06 into fc1aedf
2 parents fc1aedf + 870ce06 commit 72f379b

File tree

4 files changed

+732
-0
lines changed

4 files changed

+732
-0
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package docker
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/docker/api/types/container"
7+
"github.com/docker/docker/api/types/mount"
8+
"github.com/docker/go-connections/nat"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/stacklok/toolhive/pkg/container/runtime"
13+
)
14+
15+
func TestSetupExposedPorts_SetsPorts(t *testing.T) {
16+
t.Parallel()
17+
18+
cfg := &container.Config{}
19+
exposed := map[string]struct{}{
20+
"8080/tcp": {},
21+
"9090/tcp": {},
22+
}
23+
24+
err := setupExposedPorts(cfg, exposed)
25+
require.NoError(t, err)
26+
require.NotNil(t, cfg.ExposedPorts)
27+
28+
p8080, err := nat.NewPort("tcp", "8080")
29+
require.NoError(t, err)
30+
p9090, err := nat.NewPort("tcp", "9090")
31+
require.NoError(t, err)
32+
33+
assert.Contains(t, cfg.ExposedPorts, p8080)
34+
assert.Contains(t, cfg.ExposedPorts, p9090)
35+
assert.Len(t, cfg.ExposedPorts, 2)
36+
}
37+
38+
func TestSetupExposedPorts_EmptyNoChange(t *testing.T) {
39+
t.Parallel()
40+
41+
cfg := &container.Config{}
42+
err := setupExposedPorts(cfg, map[string]struct{}{})
43+
require.NoError(t, err)
44+
// No ports set at all
45+
assert.Nil(t, cfg.ExposedPorts)
46+
}
47+
48+
func TestSetupPortBindings_SetsBindings(t *testing.T) {
49+
t.Parallel()
50+
51+
hostCfg := &container.HostConfig{}
52+
bindings := map[string][]runtime.PortBinding{
53+
"8080/tcp": {
54+
{HostIP: "127.0.0.1", HostPort: "8081"},
55+
{HostIP: "", HostPort: "8082"},
56+
},
57+
}
58+
59+
err := setupPortBindings(hostCfg, bindings)
60+
require.NoError(t, err)
61+
62+
require.NotNil(t, hostCfg.PortBindings)
63+
p8080, err := nat.NewPort("tcp", "8080")
64+
require.NoError(t, err)
65+
66+
got, ok := hostCfg.PortBindings[p8080]
67+
require.True(t, ok)
68+
require.Len(t, got, 2)
69+
assert.Equal(t, nat.PortBinding{HostIP: "127.0.0.1", HostPort: "8081"}, got[0])
70+
assert.Equal(t, nat.PortBinding{HostIP: "", HostPort: "8082"}, got[1])
71+
}
72+
73+
func TestConvertMounts_BindMounts(t *testing.T) {
74+
t.Parallel()
75+
76+
in := []runtime.Mount{
77+
{Source: "/src1", Target: "/dst1", ReadOnly: true},
78+
{Source: "/src2", Target: "/dst2", ReadOnly: false},
79+
}
80+
out := convertMounts(in)
81+
82+
require.Len(t, out, 2)
83+
assert.Equal(t, mount.TypeBind, out[0].Type)
84+
assert.Equal(t, "/src1", out[0].Source)
85+
assert.Equal(t, "/dst1", out[0].Target)
86+
assert.Equal(t, true, out[0].ReadOnly)
87+
88+
assert.Equal(t, mount.TypeBind, out[1].Type)
89+
assert.Equal(t, "/src2", out[1].Source)
90+
assert.Equal(t, "/dst2", out[1].Target)
91+
assert.Equal(t, false, out[1].ReadOnly)
92+
}
93+
94+
func TestCompareEnvVars_SubsetMatches(t *testing.T) {
95+
t.Parallel()
96+
97+
existing := []string{"A=a", "B=b"}
98+
desired := []string{"A=a"} // subset must be OK
99+
assert.True(t, compareEnvVars(existing, desired))
100+
101+
assert.False(t, compareEnvVars(existing, []string{"A=x"})) // wrong value
102+
assert.False(t, compareEnvVars(existing, []string{"C=c"})) // missing key
103+
assert.True(t, compareEnvVars(existing, []string{})) // empty desired OK
104+
assert.True(t, compareEnvVars(existing, existing)) // exact match OK
105+
assert.True(t, compareEnvVars([]string{"A=a"}, []string{})) // empty desired
106+
assert.False(t, compareEnvVars([]string{}, []string{"A=a"})) // desired not subset
107+
}
108+
109+
func TestCompareLabels_SubsetMatches(t *testing.T) {
110+
t.Parallel()
111+
112+
existing := map[string]string{"k1": "v1", "k2": "v2"}
113+
assert.True(t, compareLabels(existing, map[string]string{"k1": "v1"})) // subset
114+
assert.False(t, compareLabels(existing, map[string]string{"k1": "x"})) // wrong value
115+
assert.False(t, compareLabels(existing, map[string]string{"k3": "v3"}))
116+
assert.True(t, compareLabels(existing, map[string]string{})) // empty desired OK
117+
}
118+
119+
func TestCompareHostConfig_EqualAndMismatch(t *testing.T) {
120+
t.Parallel()
121+
122+
existing := &container.InspectResponse{
123+
ContainerJSONBase: &container.ContainerJSONBase{
124+
HostConfig: &container.HostConfig{
125+
NetworkMode: "bridge",
126+
CapAdd: []string{"CAP_A"},
127+
CapDrop: []string{"ALL"},
128+
SecurityOpt: []string{"seccomp:unconfined"},
129+
Privileged: false,
130+
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
131+
},
132+
},
133+
}
134+
135+
desired := &container.HostConfig{
136+
NetworkMode: "bridge",
137+
CapAdd: []string{"CAP_A"},
138+
CapDrop: []string{"ALL"},
139+
SecurityOpt: []string{"seccomp:unconfined"},
140+
Privileged: false,
141+
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
142+
}
143+
144+
assert.True(t, compareHostConfig(existing, desired))
145+
146+
desired.Privileged = true
147+
assert.False(t, compareHostConfig(existing, desired))
148+
}
149+
150+
func TestComparePortConfig_EqualAndMismatch(t *testing.T) {
151+
t.Parallel()
152+
153+
// Build desired
154+
desiredCfg := &container.Config{}
155+
require.NoError(t, setupExposedPorts(desiredCfg, map[string]struct{}{
156+
"8080/tcp": {},
157+
}))
158+
desiredHost := &container.HostConfig{}
159+
require.NoError(t, setupPortBindings(desiredHost, map[string][]runtime.PortBinding{
160+
"8080/tcp": {{HostIP: "0.0.0.0", HostPort: "18080"}},
161+
}))
162+
163+
// Build existing to match desired
164+
p8080, err := nat.NewPort("tcp", "8080")
165+
require.NoError(t, err)
166+
167+
existing := &container.InspectResponse{
168+
Config: &container.Config{
169+
ExposedPorts: nat.PortSet{p8080: {}},
170+
},
171+
ContainerJSONBase: &container.ContainerJSONBase{
172+
HostConfig: &container.HostConfig{
173+
PortBindings: nat.PortMap{
174+
p8080: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "18080"}},
175+
},
176+
},
177+
},
178+
}
179+
180+
assert.True(t, comparePortConfig(existing, desiredCfg, desiredHost))
181+
182+
// Mismatch: different host port
183+
existing.HostConfig.PortBindings[p8080] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "18081"}}
184+
assert.False(t, comparePortConfig(existing, desiredCfg, desiredHost))
185+
}
186+
187+
func TestCompareContainerConfig_AllMatch(t *testing.T) {
188+
t.Parallel()
189+
190+
// Desired configuration
191+
desiredCfg := &container.Config{
192+
Image: "ghcr.io/stacklok/toolhive/mcp:latest",
193+
Cmd: []string{"serve"},
194+
Env: []string{"A=a", "B=b"},
195+
Labels: map[string]string{"toolhive": "true", "name": "w1"},
196+
AttachStdin: true,
197+
AttachStdout: true,
198+
AttachStderr: true,
199+
OpenStdin: true,
200+
Tty: false,
201+
}
202+
require.NoError(t, setupExposedPorts(desiredCfg, map[string]struct{}{
203+
"8080/tcp": {},
204+
}))
205+
desiredHost := &container.HostConfig{
206+
NetworkMode: "bridge",
207+
CapAdd: []string{"NET_BIND_SERVICE"},
208+
CapDrop: []string{"ALL"},
209+
SecurityOpt: []string{"seccomp:unconfined"},
210+
Privileged: false,
211+
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
212+
Mounts: []mount.Mount{
213+
{Type: mount.TypeBind, Source: "/src1", Target: "/dst1", ReadOnly: true},
214+
{Type: mount.TypeBind, Source: "/src2", Target: "/dst2", ReadOnly: false},
215+
},
216+
}
217+
require.NoError(t, setupPortBindings(desiredHost, map[string][]runtime.PortBinding{
218+
"8080/tcp": {{HostIP: "", HostPort: "18080"}},
219+
}))
220+
221+
// Existing configuration (must be a superset for env vars)
222+
p8080, err := nat.NewPort("tcp", "8080")
223+
require.NoError(t, err)
224+
225+
existing := &container.InspectResponse{
226+
Config: &container.Config{
227+
Image: desiredCfg.Image,
228+
Cmd: desiredCfg.Cmd,
229+
Env: []string{"A=a", "B=b", "EXTRA=x"}, // superset OK
230+
Labels: map[string]string{"toolhive": "true", "name": "w1"},
231+
AttachStdin: true,
232+
AttachStdout: true,
233+
AttachStderr: true,
234+
OpenStdin: true,
235+
Tty: false,
236+
ExposedPorts: nat.PortSet{p8080: {}},
237+
},
238+
ContainerJSONBase: &container.ContainerJSONBase{
239+
HostConfig: &container.HostConfig{
240+
NetworkMode: "bridge",
241+
CapAdd: []string{"NET_BIND_SERVICE"},
242+
CapDrop: []string{"ALL"},
243+
SecurityOpt: []string{"seccomp:unconfined"},
244+
Privileged: false,
245+
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
246+
Mounts: []mount.Mount{
247+
{Type: mount.TypeBind, Source: "/src1", Target: "/dst1", ReadOnly: true},
248+
{Type: mount.TypeBind, Source: "/src2", Target: "/dst2", ReadOnly: false},
249+
},
250+
PortBindings: nat.PortMap{
251+
p8080: []nat.PortBinding{{HostIP: "", HostPort: "18080"}},
252+
},
253+
},
254+
},
255+
}
256+
257+
assert.True(t, compareContainerConfig(existing, desiredCfg, desiredHost))
258+
259+
// Change image -> mismatch
260+
desiredCfg2 := *desiredCfg
261+
desiredCfg2.Image = "different"
262+
assert.False(t, compareContainerConfig(existing, &desiredCfg2, desiredHost))
263+
}

0 commit comments

Comments
 (0)