v3.0.0 f19f453

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		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}

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