bind_policy_internal_test.go RawHistoryFolder

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}

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