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 assert.Empty(t, deploymentReadiness(l4Only).Readiness.Type)
231 })
232
233 t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) {
234 for _, declared := range []string{domain.AppReadinessHTTP, domain.AppReadinessTCP, domain.AppReadinessLog} {
235 spec := httpSpec()
236 spec.Readiness = domain.AppReadiness{Type: declared}
237 assert.False(t, implicitHTTPProbe(spec), "%s is an explicit declaration", declared)
238 assert.Equal(t, declared, deploymentReadiness(spec).Readiness.Type)
239 }
240 })
241
242 t.Run("an explicit none on a probeable service keeps the implicit HTTP probe", func(t *testing.T) {
243 spec := httpSpec()
244 spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessNone}
245 assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type)
246 })
247
248 t.Run("an explicit none on an L4 service stays unchecked", func(t *testing.T) {
249 spec := domain.AppService{
250 Name: "db",
251 TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
252 Readiness: domain.AppReadiness{Type: domain.AppReadinessNone},
253 }
254 assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type)
255 })
256
257 t.Run("writable bind is single-writer", func(t *testing.T) {
258 spec := httpSpec()
259 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
260 assert.False(t, implicitHTTPProbe(spec), "a stateful service must declare its own readiness")
261 assert.True(t, singleWriterRequired(spec))
262 })
263
264 t.Run("read-only bind is also single-writer", func(t *testing.T) {
265 spec := httpSpec()
266 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}
267 assert.False(t, implicitHTTPProbe(spec))
268 assert.True(t, singleWriterRequired(spec))
269 })
270
271 t.Run("volume is single-writer", func(t *testing.T) {
272 spec := httpSpec()
273 spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
274 assert.False(t, implicitHTTPProbe(spec))
275 assert.True(t, singleWriterRequired(spec))
276 })
277}
278
279// TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation proves an
280// in-place restart fails closed when the bind policy is missing/revoked,
281// before touching the runtime.
282func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
283 runtime := outmocks.NewMockContainerRuntime(t)
284 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
285 eff := domain.AppEffectiveService{
286 Container: "c1",
287 Spec: domain.AppService{
288 Name: "web",
289 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
290 },
291 }
292
293 op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
294 ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
295 }}}
296 result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
297
298 assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
299 assert.Contains(t, op.Steps[0].Error, "config")
300 assert.Equal(t, "failed", result.Result)
301 runtime.AssertNotCalled(t, "RestartContainer")
302 runtime.AssertExpectations(t)
303}
304
305// TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation proves the
306// boot/start recovery path fails closed before starting a container when the
307// bind policy is missing/revoked.
308func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
309 runtime := outmocks.NewMockContainerRuntime(t)
310 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
311 eff := domain.AppEffectiveService{
312 Container: "c1",
313 Spec: domain.AppService{
314 Name: "web",
315 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
316 },
317 }
318 op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
319 ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
320 }}}
321 result := &LifecycleResult{Services: map[string]ServiceResult{}}
322
323 svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
324
325 assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
326 assert.Contains(t, op.Steps[0].Error, "config")
327 runtime.AssertNotCalled(t, "StartContainer")
328 runtime.AssertExpectations(t)
329}