main bac21c6

capability_policy_internal_test.go RawHistoryFolder

1package deployment
2
3import (
4	"context"
5	"fmt"
6	"testing"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14	"github.com/bnema/gordon/internal/domain"
15)
16
17func capabilityPolicyFor(apps, services []string) domain.AppCapabilityPolicy {
18	return domain.AppCapabilityPolicy{
19		Name:            "net-admin",
20		Add:             []string{"NET_ADMIN"},
21		AllowedApps:     apps,
22		AllowedServices: services,
23	}
24}
25
26func TestResolveServiceCapabilities(t *testing.T) {
27	spec := domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}
28
29	t.Run("exact resolution", func(t *testing.T) {
30		svc := NewService(Deps{}, zerowrap.Default()).
31			WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
32				"net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
33			})
34		got, err := svc.resolveServiceCapabilities("blog", spec)
35		require.NoError(t, err)
36		assert.Equal(t, []string{"NET_ADMIN"}, got)
37	})
38
39	t.Run("empty is nil", func(t *testing.T) {
40		svc := NewService(Deps{}, zerowrap.Default())
41		got, err := svc.resolveServiceCapabilities("blog", domain.AppService{Name: "web"})
42		require.NoError(t, err)
43		assert.Nil(t, got)
44	})
45
46	t.Run("unknown policy", func(t *testing.T) {
47		svc := NewService(Deps{}, zerowrap.Default())
48		_, err := svc.resolveServiceCapabilities("blog", spec)
49		require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
50		assert.Contains(t, err.Error(), "net-admin")
51	})
52
53	t.Run("revoked policy", func(t *testing.T) {
54		svc := NewService(Deps{}, zerowrap.Default()).
55			WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
56				"net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
57			})
58		_, err := svc.resolveServiceCapabilities("blog", spec)
59		require.NoError(t, err)
60		svc.SetCapabilityPolicies(nil)
61		_, err = svc.resolveServiceCapabilities("blog", spec)
62		require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
63	})
64
65	t.Run("refused omits capability names", func(t *testing.T) {
66		svc := NewService(Deps{}, zerowrap.Default()).
67			WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
68				"net-admin": capabilityPolicyFor([]string{"other"}, []string{"web"}),
69			})
70		_, err := svc.resolveServiceCapabilities("blog", spec)
71		require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
72		assert.NotContains(t, err.Error(), "NET_ADMIN")
73	})
74}
75
76// TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation proves
77// the pre-create re-resolution fails closed before any runtime call.
78func TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation(t *testing.T) {
79	runtime := outmocks.NewMockContainerRuntime(t)
80	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
81	p := pinnedService{
82		name: "web",
83		spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
84	}
85
86	_, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
87
88	require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
89	runtime.AssertNotCalled(t, "CreateContainer")
90	runtime.AssertNotCalled(t, "CreateVolume")
91	runtime.AssertNotCalled(t, "StartContainer")
92	runtime.AssertExpectations(t)
93}
94
95// TestCreateAndStart_PassesResolvedCapabilitiesToRuntime proves resolved
96// capabilities reach ContainerConfig.AddedCapabilities and never CapAdd /
97// CapDrop, which stay with the runtime defaults.
98func TestCreateAndStart_PassesResolvedCapabilitiesToRuntime(t *testing.T) {
99	ctx := context.Background()
100	state := outmocks.NewMockAppState(t)
101	runtime := outmocks.NewMockContainerRuntime(t)
102
103	private := domain.AppPrivateNetworkName("gordon", "app-1")
104	state.EXPECT().LoadOwnership(mock.Anything, "blog").
105		Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
106	runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
107	runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
108	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
109		return assert.Equal(t, []string{"NET_ADMIN"}, cfg.AddedCapabilities) &&
110			assert.Nil(t, cfg.CapAdd, "base capability set stays with the runtime defaults") &&
111			assert.Nil(t, cfg.CapDrop, "the default drop is never overridden")
112	})).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
113	runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
114	runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
115
116	svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
117		WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
118			"net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
119		})
120	candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
121		name: "web",
122		spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
123	}, "op-1", nil)
124	require.NoError(t, err)
125	require.NotNil(t, candidate.Container)
126	runtime.AssertExpectations(t)
127}
128
129func TestRestartOneService_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
130	runtime := outmocks.NewMockContainerRuntime(t)
131	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
132	eff := domain.AppEffectiveService{
133		Container: "c1",
134		Spec:      domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
135	}
136	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
137		ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
138	}}}
139	result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
140
141	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
142	assert.Contains(t, op.Steps[0].Error, "net-admin")
143	assert.Equal(t, "failed", result.Result)
144	runtime.AssertNotCalled(t, "RestartContainer")
145	runtime.AssertExpectations(t)
146}
147
148func TestEnsureServiceRunning_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
149	runtime := outmocks.NewMockContainerRuntime(t)
150	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
151	eff := domain.AppEffectiveService{
152		Container: "c1",
153		Spec:      domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
154	}
155	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
156		ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
157	}}}
158	result := &LifecycleResult{Services: map[string]ServiceResult{}}
159
160	svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
161
162	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
163	assert.Contains(t, op.Steps[0].Error, "net-admin")
164	runtime.AssertNotCalled(t, "StartContainer")
165	runtime.AssertExpectations(t)
166}
167
168// TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer proves a
169// service with capability grants is always replaced on deploy: the resolved
170// capabilities depend on host policy that ACTIVE does not record, so a
171// changed policy must reach the container on the next deploy.
172func TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer(t *testing.T) {
173	svc := NewService(Deps{}, zerowrap.Default())
174	p := pinnedService{name: "web", spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
175	reason := svc.unchangedRefusal(context.Background(), "blog", p, domain.AppEffectiveService{Spec: p.spec})
176	assert.Equal(t, "administrative capabilities", reason)
177}
178
179// narrowedFixture models a policy narrowed from {NET_ADMIN, IPC_LOCK} to
180// {IPC_LOCK} after a container was created with both: policy resolution
181// still succeeds, but the existing container holds the revoked NET_ADMIN.
182func narrowedFixture(t *testing.T, containerCaps []string) (*Service, *outmocks.MockContainerRuntime, domain.AppEffectiveService) {
183	t.Helper()
184	runtime := outmocks.NewMockContainerRuntime(t)
185	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
186		WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
187			"net-admin": {Name: "net-admin", Add: []string{"IPC_LOCK"}, AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}},
188		})
189	eff := domain.AppEffectiveService{
190		Container: "c1",
191		Spec:      domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
192	}
193	runtime.EXPECT().InspectContainer(mock.Anything, "c1").
194		Return(&domain.Container{ID: "c1", CapAdd: containerCaps}, nil).Maybe()
195	return svc, runtime, eff
196}
197
198var staleCaps = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE", "NET_ADMIN", "IPC_LOCK"}
199
200func TestVerifyReusableCapabilities(t *testing.T) {
201	t.Run("narrowed policy refuses a container holding a revoked capability", func(t *testing.T) {
202		svc, _, eff := narrowedFixture(t, staleCaps)
203		err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
204		require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
205		assert.Contains(t, err.Error(), "NET_ADMIN")
206		assert.NotContains(t, err.Error(), "IPC_LOCK")
207	})
208	t.Run("CAP_ prefixed engine names compare equal", func(t *testing.T) {
209		svc, _, eff := narrowedFixture(t, []string{"CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_IPC_LOCK"})
210		require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
211	})
212	t.Run("fewer capabilities than authorized is allowed", func(t *testing.T) {
213		svc, _, eff := narrowedFixture(t, []string{"CHOWN"})
214		require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
215	})
216	t.Run("prefixed revoked capability is still caught", func(t *testing.T) {
217		svc, _, eff := narrowedFixture(t, []string{"CAP_NET_ADMIN"})
218		require.ErrorIs(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff), domain.ErrCapabilityPolicy)
219	})
220	t.Run("uninspectable container fails closed", func(t *testing.T) {
221		runtime := outmocks.NewMockContainerRuntime(t)
222		runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, fmt.Errorf("daemon down at /run/podman.sock")).Once()
223		svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
224			WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
225				"net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
226			})
227		eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
228		err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
229		require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
230		assert.NotContains(t, err.Error(), "podman.sock")
231	})
232	t.Run("missing container is left to the rebuild path", func(t *testing.T) {
233		runtime := outmocks.NewMockContainerRuntime(t)
234		runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, domain.ErrContainerNotFound).Once()
235		svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
236			WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
237				"net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
238			})
239		eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
240		require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
241	})
242	t.Run("service without capabilities never inspects", func(t *testing.T) {
243		runtime := outmocks.NewMockContainerRuntime(t)
244		svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
245		eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web"}}
246		require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
247	})
248}
249
250func TestRestartOneService_NarrowedCapabilityPolicyRefusesInPlaceRestart(t *testing.T) {
251	svc, runtime, eff := narrowedFixture(t, staleCaps)
252	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
253		ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
254	}}}
255	result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
256
257	assert.Equal(t, "failed", result.Result)
258	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
259	assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
260	runtime.AssertNotCalled(t, "RestartContainer")
261	runtime.AssertNotCalled(t, "StartContainer")
262}
263
264func TestEnsureServiceRunning_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
265	svc, runtime, eff := narrowedFixture(t, staleCaps)
266	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
267		ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
268	}}}
269	result := &LifecycleResult{Services: map[string]ServiceResult{}}
270
271	svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
272
273	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
274	assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
275	runtime.AssertNotCalled(t, "StartContainer")
276	runtime.AssertNotCalled(t, "IsContainerRunning")
277}
278
279func TestStartRecovered_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
280	svc, runtime, eff := narrowedFixture(t, staleCaps)
281	started, err := svc.startRecovered(context.Background(), "blog", "web", eff, recoveryKey{app: "blog", service: "web", container: "c1"})
282
283	require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
284	assert.False(t, started)
285	runtime.AssertNotCalled(t, "StartContainer")
286}
287
288func TestRecoverHealth_NarrowedCapabilityPolicyRefusesRestart(t *testing.T) {
289	svc, runtime, eff := narrowedFixture(t, staleCaps)
290	runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
291	key := recoveryKey{app: "blog", service: "web", container: "c1"}
292	for i := 0; i < unhealthyThreshold; i++ {
293		_, _, err := svc.recoverHealth(context.Background(), "blog", "web", eff, key)
294		if err != nil {
295			require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
296			runtime.AssertNotCalled(t, "RestartContainer")
297			return
298		}
299		if i < unhealthyThreshold-1 {
300			runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
301		}
302	}
303	t.Fatal("expected recoverHealth to refuse the restart")
304}

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