1package deployment
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func bindPolicyFor(source, root string, readOnly bool, apps, services []string) domain.AppBindPolicy {
20 return domain.AppBindPolicy{
21 Name: "config",
22 Source: source,
23 Root: root,
24 ReadOnly: readOnly,
25 AllowedApps: apps,
26 AllowedServices: services,
27 }
28}
29
30func TestResolveServiceBinds_ExactTranslation(t *testing.T) {
31 root := t.TempDir()
32 source := filepath.Join(root, "binds", "config")
33 require.NoError(t, os.MkdirAll(source, 0o755))
34 realSource, err := filepath.EvalSymlinks(source)
35 require.NoError(t, err)
36
37 svc := NewService(Deps{}, zerowrap.Default()).
38 WithBindPolicies(map[string]domain.AppBindPolicy{
39 "config": bindPolicyFor(source, root, false, []string{"blog"}, []string{"web"}),
40 })
41
42 got, err := svc.resolveServiceBinds("blog", domain.AppService{
43 Name: "web",
44 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}},
45 })
46 require.NoError(t, err)
47 require.Len(t, got, 1)
48 assert.Equal(t, domain.ContainerBind{
49 Name: "config",
50 Source: realSource,
51 Destination: "/etc/app.conf",
52 ReadOnly: true,
53 }, got[0])
54}
55
56func TestResolveServiceBinds_ReadOnlyNonWeakening(t *testing.T) {
57 root := t.TempDir()
58 source := filepath.Join(root, "binds")
59 require.NoError(t, os.MkdirAll(source, 0o755))
60
61 svc := NewService(Deps{}, zerowrap.Default()).
62 WithBindPolicies(map[string]domain.AppBindPolicy{
63 "config": bindPolicyFor(source, root, true, []string{"blog"}, []string{"web"}),
64 })
65
66 got, err := svc.resolveServiceBinds("blog", domain.AppService{
67 Name: "web",
68 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
69 })
70 require.NoError(t, err)
71 require.Len(t, got, 1)
72 assert.True(t, got[0].ReadOnly, "a read-only policy must not be weakened by the manifest")
73}
74
75func TestResolveServiceBinds_UnknownAndRevokedPolicy(t *testing.T) {
76 spec := domain.AppService{
77 Name: "web",
78 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
79 }
80
81 t.Run("unknown policy", func(t *testing.T) {
82 svc := NewService(Deps{}, zerowrap.Default())
83 _, err := svc.resolveServiceBinds("blog", spec)
84 require.ErrorIs(t, err, domain.ErrBindPolicy)
85 assert.Contains(t, err.Error(), "blog")
86 assert.Contains(t, err.Error(), "web")
87 assert.Contains(t, err.Error(), "config")
88 })
89
90 t.Run("revoked policy", func(t *testing.T) {
91 root := t.TempDir()
92 source := filepath.Join(root, "binds")
93 require.NoError(t, os.MkdirAll(source, 0o755))
94 svc := NewService(Deps{}, zerowrap.Default()).
95 WithBindPolicies(map[string]domain.AppBindPolicy{
96 "config": bindPolicyFor(source, root, false, []string{"blog"}, []string{"web"}),
97 })
98 _, err := svc.resolveServiceBinds("blog", spec)
99 require.NoError(t, err)
100
101 svc.SetBindPolicies(nil)
102 _, err = svc.resolveServiceBinds("blog", spec)
103 require.ErrorIs(t, err, domain.ErrBindPolicy)
104 })
105
106 t.Run("refused policy omits source", func(t *testing.T) {
107 const secretSource = "/srv/gordon/secret-source"
108 svc := NewService(Deps{}, zerowrap.Default()).
109 WithBindPolicies(map[string]domain.AppBindPolicy{
110 "config": bindPolicyFor(secretSource, "/srv/gordon", false, []string{"other"}, []string{"web"}),
111 })
112 _, err := svc.resolveServiceBinds("blog", spec)
113 require.ErrorIs(t, err, domain.ErrBindPolicy)
114 assert.NotContains(t, err.Error(), secretSource, "errors must never leak the host source")
115 })
116}
117
118// TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation proves the
119// immediate-pre-create re-resolution fails before any runtime call when the
120// policy is missing or revoked.
121func TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation(t *testing.T) {
122 runtime := outmocks.NewMockContainerRuntime(t)
123 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
124 p := pinnedService{
125 name: "web",
126 spec: domain.AppService{
127 Name: "web",
128 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
129 },
130 }
131
132 _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
133
134 require.ErrorIs(t, err, domain.ErrBindPolicy)
135 assert.Contains(t, err.Error(), "config")
136 runtime.AssertNotCalled(t, "CreateContainer")
137 runtime.AssertNotCalled(t, "CreateVolume")
138 runtime.AssertNotCalled(t, "StartContainer")
139 runtime.AssertNotCalled(t, "ConnectContainerToNetwork")
140 runtime.AssertExpectations(t)
141}
142
143// TestCreateContainer_BindBearingRuntimeErrorIsRedactedNotBindPolicy proves a
144// CreateContainer failure on a bind-bearing service is reported as a generic
145// redacted runtime error, never as a bind policy violation, and never leaks
146// the resolved host source embedded in the runtime error.
147func TestCreateContainer_BindBearingRuntimeErrorIsRedactedNotBindPolicy(t *testing.T) {
148 const secretSource = "/srv/gordon/secret-source"
149 runtime := outmocks.NewMockContainerRuntime(t)
150 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
151 Return(nil, fmt.Errorf("mount %s: permission denied", secretSource)).Once()
152 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
153
154 _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
155 Binds: []domain.ContainerBind{{Name: "config", Source: secretSource, Destination: "/etc/app.conf"}},
156 })
157
158 require.Error(t, err)
159 require.NotErrorIs(t, err, domain.ErrBindPolicy, "a runtime failure is not a policy refusal")
160 assert.NotContains(t, err.Error(), secretSource, "errors must never leak the resolved host source")
161 assert.NotContains(t, err.Error(), "permission denied", "no runtime text may reach the caller")
162 runtime.AssertExpectations(t)
163}
164
165func TestCheckImageVolumes_IncludesBindDestinations(t *testing.T) {
166 t.Run("bind destination maps image volume", func(t *testing.T) {
167 runtime := outmocks.NewMockContainerRuntime(t)
168 runtime.EXPECT().InspectImageVolumes(context.Background(), "img:1").Return([]string{"/data"}, nil).Once()
169 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
170
171 err := svc.checkImageVolumes(context.Background(), domain.AppService{
172 Name: "web",
173 Binds: []domain.AppBind{{Name: "config", Path: "/data"}},
174 }, "img:1")
175
176 require.NoError(t, err)
177 })
178
179 t.Run("unmapped image volume still refused", func(t *testing.T) {
180 runtime := outmocks.NewMockContainerRuntime(t)
181 runtime.EXPECT().InspectImageVolumes(context.Background(), "img:1").Return([]string{"/data"}, nil).Once()
182 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
183
184 err := svc.checkImageVolumes(context.Background(), domain.AppService{Name: "web"}, "img:1")
185
186 require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
187 })
188}
189
190// TestSingleWriterRequiredAndImplicitHTTPReadiness proves the two
191// independent service rules: singleWriterRequired marks the stateful
192// services whose superseded generation must stay recovery-inhibited, and
193// implicitHTTPProbe decides which undeclared readiness checks keep the
194// implicit HTTP probe.
195func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) {
196 httpSpec := func() domain.AppService {
197 return domain.AppService{
198 Name: "web",
199 HTTP: []domain.AppHTTPInterface{{Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto}},
200 }
201 }
202
203 t.Run("stateless public HTTP service keeps the implicit HTTP probe", func(t *testing.T) {
204 assert.True(t, implicitHTTPProbe(httpSpec()))
205 assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(httpSpec()).Readiness.Type)
206 assert.False(t, singleWriterRequired(httpSpec()))
207 })
208
209 t.Run("undeclared probe is not guessed for internal, mixed, or L4 services", func(t *testing.T) {
210 internal := domain.AppService{
211 Name: "api",
212 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
213 }
214 assert.False(t, implicitHTTPProbe(internal),
215 "an internal-only interface is not reachable through a loopback publish, so no HTTP probe is guessed")
216 assert.Empty(t, deploymentReadiness(internal).Readiness.Type)
217 assert.False(t, singleWriterRequired(internal))
218
219 mixed := httpSpec()
220 mixed.HTTP = append(mixed.HTTP, domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal})
221 assert.False(t, implicitHTTPProbe(mixed))
222 assert.Empty(t, deploymentReadiness(mixed).Readiness.Type)
223 assert.False(t, singleWriterRequired(mixed))
224
225 l4Only := domain.AppService{
226 Name: "db",
227 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
228 }
229 assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload")
230 })
231
232 t.Run("undeclared probe on a stateless single TCP service is the implicit TCP probe", func(t *testing.T) {
233 for _, readiness := range []string{"", domain.AppReadinessNone} {
234 spec := domain.AppService{
235 Name: "relay",
236 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}},
237 Readiness: domain.AppReadiness{Type: readiness},
238 }
239 assert.True(t, implicitTCPProbe(spec))
240 assert.Equal(t, domain.AppReadinessTCP, deploymentReadiness(spec).Readiness.Type)
241 }
242 })
243
244 t.Run("no implicit TCP probe on stateful, multi-port, mixed, or UDP services", func(t *testing.T) {
245 tcp := func() domain.AppService {
246 return domain.AppService{
247 Name: "db",
248 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
249 }
250 }
251 withVolume := tcp()
252 withVolume.Volumes = []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}}
253 withBind := tcp()
254 withBind.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
255 twoPorts := tcp()
256 twoPorts.TCP = append(twoPorts.TCP, domain.AppTCPInterface{Entrypoint: "tcp2", Port: 5433, Publish: "5433"})
257 withUDP := tcp()
258 withUDP.UDP = []domain.AppUDPInterface{{Entrypoint: "udp", Port: 5432}}
259 mixed := tcp()
260 mixed.HTTP = []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}}
261 declaredLog := tcp()
262 declaredLog.Readiness = domain.AppReadiness{Type: domain.AppReadinessLog}
263
264 for name, spec := range map[string]domain.AppService{
265 "volume": withVolume, "bind": withBind, "two TCP ports": twoPorts,
266 "UDP": withUDP, "HTTP interface": mixed, "declared log": declaredLog,
267 } {
268 assert.False(t, implicitTCPProbe(spec), name)
269 assert.Equal(t, spec.Readiness.Type, deploymentReadiness(spec).Readiness.Type, name)
270 }
271 })
272
273 t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) {
274 for _, declared := range []string{domain.AppReadinessHTTP, domain.AppReadinessTCP, domain.AppReadinessLog} {
275 spec := httpSpec()
276 spec.Readiness = domain.AppReadiness{Type: declared}
277 assert.False(t, implicitHTTPProbe(spec), "%s is an explicit declaration", declared)
278 assert.Equal(t, declared, deploymentReadiness(spec).Readiness.Type)
279 }
280 })
281
282 t.Run("an explicit none on a probeable service keeps the implicit HTTP probe", func(t *testing.T) {
283 spec := httpSpec()
284 spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessNone}
285 assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type)
286 })
287
288 t.Run("an explicit none on a stateful L4 service stays unchecked", func(t *testing.T) {
289 spec := domain.AppService{
290 Name: "db",
291 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
292 Volumes: []domain.AppVolume{{Name: "data", Path: "/var/lib/postgresql/data"}},
293 Readiness: domain.AppReadiness{Type: domain.AppReadinessNone},
294 }
295 assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type)
296 })
297
298 t.Run("writable bind is single-writer", func(t *testing.T) {
299 spec := httpSpec()
300 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
301 assert.False(t, implicitHTTPProbe(spec), "a stateful service must declare its own readiness")
302 assert.True(t, singleWriterRequired(spec))
303 })
304
305 t.Run("read-only bind is also single-writer", func(t *testing.T) {
306 spec := httpSpec()
307 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}
308 assert.False(t, implicitHTTPProbe(spec))
309 assert.True(t, singleWriterRequired(spec))
310 })
311
312 t.Run("volume is single-writer", func(t *testing.T) {
313 spec := httpSpec()
314 spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
315 assert.False(t, implicitHTTPProbe(spec))
316 assert.True(t, singleWriterRequired(spec))
317 })
318}
319
320// TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation proves an
321// in-place restart fails closed when the bind policy is missing/revoked,
322// before touching the runtime.
323func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
324 runtime := outmocks.NewMockContainerRuntime(t)
325 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
326 eff := domain.AppEffectiveService{
327 Container: "c1",
328 Spec: domain.AppService{
329 Name: "web",
330 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
331 },
332 }
333
334 op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
335 ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
336 }}}
337 result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
338
339 assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
340 assert.Contains(t, op.Steps[0].Error, "config")
341 assert.Equal(t, "failed", result.Result)
342 runtime.AssertNotCalled(t, "RestartContainer")
343 runtime.AssertExpectations(t)
344}
345
346// TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation proves the
347// boot/start recovery path fails closed before starting a container when the
348// bind policy is missing/revoked.
349func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
350 runtime := outmocks.NewMockContainerRuntime(t)
351 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
352 eff := domain.AppEffectiveService{
353 Container: "c1",
354 Spec: domain.AppService{
355 Name: "web",
356 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
357 },
358 }
359 op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
360 ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
361 }}}
362 result := &LifecycleResult{Services: map[string]ServiceResult{}}
363
364 svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
365
366 assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
367 assert.Contains(t, op.Steps[0].Error, "config")
368 runtime.AssertNotCalled(t, "StartContainer")
369 runtime.AssertExpectations(t)
370}