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}