main bac21c6

device_policy_internal_test.go RawHistoryFolder

1package deployment
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"testing"
8
9	"github.com/bnema/zerowrap"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18func devicePolicyFor(apps, services []string) domain.AppDevicePolicy {
19	return domain.AppDevicePolicy{
20		Name:            "test_gpu",
21		CDI:             []string{"example.com/gpu=GPU-test-uuid"},
22		AllowedApps:     apps,
23		AllowedServices: services,
24	}
25}
26
27func TestResolveServiceDevices_ExactResolution(t *testing.T) {
28	svc := NewService(Deps{}, zerowrap.Default()).
29		WithDevicePolicies(map[string]domain.AppDevicePolicy{
30			"test_gpu": devicePolicyFor([]string{"blog"}, []string{"web"}),
31		})
32
33	got, err := svc.resolveServiceDevices("blog", domain.AppService{
34		Name:    "web",
35		Devices: []string{"test_gpu"},
36	})
37	require.NoError(t, err)
38	assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, got)
39}
40
41func TestResolveServiceDevices_EmptyIsNil(t *testing.T) {
42	svc := NewService(Deps{}, zerowrap.Default())
43	got, err := svc.resolveServiceDevices("blog", domain.AppService{Name: "web"})
44	require.NoError(t, err)
45	assert.Nil(t, got)
46}
47
48func TestResolveServiceDevices_UnknownAndRevokedPolicy(t *testing.T) {
49	spec := domain.AppService{
50		Name:    "web",
51		Devices: []string{"test_gpu"},
52	}
53
54	t.Run("unknown policy", func(t *testing.T) {
55		svc := NewService(Deps{}, zerowrap.Default())
56		_, err := svc.resolveServiceDevices("blog", spec)
57		require.ErrorIs(t, err, domain.ErrDevicePolicy)
58		assert.Contains(t, err.Error(), "blog")
59		assert.Contains(t, err.Error(), "web")
60		assert.Contains(t, err.Error(), "test_gpu")
61	})
62
63	t.Run("revoked policy", func(t *testing.T) {
64		svc := NewService(Deps{}, zerowrap.Default()).
65			WithDevicePolicies(map[string]domain.AppDevicePolicy{
66				"test_gpu": devicePolicyFor([]string{"blog"}, []string{"web"}),
67			})
68		_, err := svc.resolveServiceDevices("blog", spec)
69		require.NoError(t, err)
70
71		svc.SetDevicePolicies(nil)
72		_, err = svc.resolveServiceDevices("blog", spec)
73		require.ErrorIs(t, err, domain.ErrDevicePolicy)
74	})
75
76	t.Run("refused policy omits device IDs", func(t *testing.T) {
77		svc := NewService(Deps{}, zerowrap.Default()).
78			WithDevicePolicies(map[string]domain.AppDevicePolicy{
79				"test_gpu": devicePolicyFor([]string{"other"}, []string{"web"}),
80			})
81		_, err := svc.resolveServiceDevices("blog", spec)
82		require.ErrorIs(t, err, domain.ErrDevicePolicy)
83		assert.NotContains(t, err.Error(), "GPU-test-uuid", "errors must never leak CDI IDs")
84	})
85}
86
87// TestCreateAndStart_RefusesRevokedDeviceBeforeRuntimeMutation proves the
88// immediate-pre-create re-resolution fails before any runtime call when the
89// device policy is missing or revoked.
90func TestCreateAndStart_RefusesRevokedDeviceBeforeRuntimeMutation(t *testing.T) {
91	runtime := outmocks.NewMockContainerRuntime(t)
92	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
93	p := pinnedService{
94		name: "web",
95		spec: domain.AppService{
96			Name:    "web",
97			Devices: []string{"test_gpu"},
98		},
99	}
100
101	_, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
102
103	require.ErrorIs(t, err, domain.ErrDevicePolicy)
104	assert.Contains(t, err.Error(), "test_gpu")
105	runtime.AssertNotCalled(t, "CreateContainer")
106	runtime.AssertNotCalled(t, "CreateVolume")
107	runtime.AssertNotCalled(t, "StartContainer")
108	runtime.AssertNotCalled(t, "ConnectContainerToNetwork")
109	runtime.AssertExpectations(t)
110}
111
112// TestCreateAndStart_PassesResolvedCDIIDsToRuntime proves the deploy path
113// carries authorized policy resolution into ContainerConfig.CDIDevices:
114// only the granted service receives IDs, helpers and ordinary services
115// receive none.
116func TestCreateAndStart_PassesResolvedCDIIDsToRuntime(t *testing.T) {
117	ctx := context.Background()
118	state := outmocks.NewMockAppState(t)
119	runtime := outmocks.NewMockContainerRuntime(t)
120
121	private := domain.AppPrivateNetworkName("gordon", "app-1")
122	state.EXPECT().LoadOwnership(mock.Anything, "blog").
123		Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
124	runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
125	runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
126	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
127		return assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, cfg.CDIDevices)
128	})).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
129	runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
130	runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
131
132	svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
133		WithDevicePolicies(map[string]domain.AppDevicePolicy{
134			"test_gpu": devicePolicyFor([]string{"blog"}, []string{"web"}),
135		})
136	candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
137		name: "web",
138		spec: domain.AppService{Name: "web", Devices: []string{"test_gpu"}},
139	}, "op-1", nil)
140	created := candidate.Container
141	require.NoError(t, err)
142	require.NotNil(t, created)
143	runtime.AssertExpectations(t)
144}
145
146// TestCreateContainer_DeviceBearingRuntimeErrorIsRedactedNotDevicePolicy
147// proves a CreateContainer failure on a device-bearing service is reported
148// as a generic redacted runtime error, never as a device policy violation,
149// and never leaks the resolved CDI IDs embedded in the runtime error.
150func TestCreateContainer_DeviceBearingRuntimeErrorIsRedactedNotDevicePolicy(t *testing.T) {
151	const deviceID = "example.com/gpu=GPU-test-uuid"
152	runtime := outmocks.NewMockContainerRuntime(t)
153	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
154		Return(nil, fmt.Errorf("device %s: permission denied", deviceID)).Once()
155	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
156
157	_, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
158		CDIDevices: []string{deviceID},
159	})
160
161	require.Error(t, err)
162	require.NotErrorIs(t, err, domain.ErrDevicePolicy, "a runtime failure is not a policy refusal")
163	assert.NotContains(t, err.Error(), deviceID, "errors must never leak resolved CDI IDs")
164	assert.NotContains(t, err.Error(), "permission denied", "no runtime text may reach the caller")
165	runtime.AssertExpectations(t)
166}
167
168// TestRestartOneService_RevokedDeviceFailsWithoutRuntimeMutation proves an
169// in-place restart fails closed when the device policy is missing/revoked,
170// before touching the runtime.
171func TestRestartOneService_RevokedDeviceFailsWithoutRuntimeMutation(t *testing.T) {
172	runtime := outmocks.NewMockContainerRuntime(t)
173	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
174	eff := domain.AppEffectiveService{
175		Container: "c1",
176		Spec: domain.AppService{
177			Name:    "web",
178			Devices: []string{"test_gpu"},
179		},
180	}
181
182	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
183		ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
184	}}}
185	result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
186
187	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
188	assert.Contains(t, op.Steps[0].Error, "test_gpu")
189	assert.Equal(t, "failed", result.Result)
190	runtime.AssertNotCalled(t, "RestartContainer")
191	runtime.AssertExpectations(t)
192}
193
194// TestEnsureServiceRunning_RevokedDeviceFailsWithoutRuntimeMutation proves
195// the boot/start recovery path fails closed before starting a container
196// when the device policy is missing/revoked.
197func TestEnsureServiceRunning_RevokedDeviceFailsWithoutRuntimeMutation(t *testing.T) {
198	runtime := outmocks.NewMockContainerRuntime(t)
199	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
200	eff := domain.AppEffectiveService{
201		Container: "c1",
202		Spec: domain.AppService{
203			Name:    "web",
204			Devices: []string{"test_gpu"},
205		},
206	}
207	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
208		ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
209	}}}
210	result := &LifecycleResult{Services: map[string]ServiceResult{}}
211
212	svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
213
214	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
215	assert.Contains(t, op.Steps[0].Error, "test_gpu")
216	runtime.AssertNotCalled(t, "StartContainer")
217	runtime.AssertExpectations(t)
218}
219
220// TestPreflightServices_ProbesEngineOncePerRevision proves preflight calls
221// SupportsCDIDevices exactly once for a multi-service device-bearing
222// revision, and that pinned services carry their logical device requests.
223func TestPreflightServices_ProbesEngineOncePerRevision(t *testing.T) {
224	ctx := context.Background()
225	state := outmocks.NewMockAppState(t)
226	runtime := outmocks.NewMockContainerRuntime(t)
227	images := outmocks.NewMockImageResolver(t)
228	secrets := outmocks.NewMockSecretProvider(t)
229
230	rev := domain.AppDesiredRevision{
231		Revision: "rev-1",
232		App:      "blog",
233		Spec: domain.AppSpec{
234			Name: "blog",
235			Env:  map[string]string{},
236			Services: []domain.AppService{
237				{Name: "web", Image: "img:1", Devices: []string{"test_gpu"}},
238				{Name: "worker", Image: "img:1", Devices: []string{"test_gpu"}},
239			},
240		},
241	}
242	state.EXPECT().LoadOwnership(mock.Anything, "blog").
243		Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
244	images.EXPECT().ResolveDigest(mock.Anything, "img:1").Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Twice()
245	runtime.EXPECT().InspectImageVolumes(mock.Anything, "img:1").Return(nil, nil).Twice()
246	runtime.EXPECT().SupportsCDIDevices(mock.Anything).Return(nil).Once()
247	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
248
249	svc := NewService(Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()).
250		WithDevicePolicies(map[string]domain.AppDevicePolicy{
251			"test_gpu": {
252				Name:            "test_gpu",
253				CDI:             []string{"example.com/gpu=GPU-test-uuid"},
254				AllowedApps:     []string{"blog"},
255				AllowedServices: []string{"web", "worker"},
256			},
257		})
258	pinned, err := svc.preflightServices(ctx, "blog", rev, "")
259	require.NoError(t, err)
260	require.Len(t, pinned, 2)
261	runtime.AssertExpectations(t)
262}
263
264// TestCreateContainer_RuntimeUnsupportedSurvivesRedaction proves the
265// engine-unsupported sentinel from a device-bearing create survives the
266// host-inventory redaction so callers map it to the structured
267// runtime-unsupported envelope.
268func TestCreateContainer_RuntimeUnsupportedSurvivesRedaction(t *testing.T) {
269	runtime := outmocks.NewMockContainerRuntime(t)
270	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
271		Return(nil, domain.ErrRuntimeUnsupported).Once()
272	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
273
274	_, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
275		CDIDevices: []string{"example.com/gpu=GPU-test-uuid"},
276	})
277
278	require.Error(t, err)
279	require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
280	assert.NotContains(t, err.Error(), "GPU-test-uuid", "the sanitized error must not echo CDI IDs")
281	runtime.AssertExpectations(t)
282}
283
284// TestSanitizedEngineProbeError proves the capability-probe error keeps
285// cancellation meaningful, collapses every other cause to the unsupported
286// sentinel alone, and never echoes the adapter text.
287func TestSanitizedEngineProbeError(t *testing.T) {
288	t.Run("cancellation is preserved and reported as cancellation", func(t *testing.T) {
289		err := sanitizedEngineProbeError("web", fmt.Errorf("dial unix /run/podman/podman.sock: %w", context.Canceled))
290		require.ErrorIs(t, err, context.Canceled)
291		assert.False(t, errors.Is(err, domain.ErrRuntimeUnsupported), "an aborted probe is not an incapable engine")
292		assert.NotContains(t, err.Error(), "podman.sock")
293	})
294
295	t.Run("deadline is preserved", func(t *testing.T) {
296		err := sanitizedEngineProbeError("web", fmt.Errorf("probe: %w", context.DeadlineExceeded))
297		require.ErrorIs(t, err, context.DeadlineExceeded)
298		assert.NotContains(t, err.Error(), "probe")
299	})
300
301	t.Run("other causes collapse to the unsupported sentinel", func(t *testing.T) {
302		err := sanitizedEngineProbeError("web", errors.New("dial unix /run/podman/podman.sock: connection refused"))
303		require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
304		assert.NotContains(t, err.Error(), "podman.sock")
305	})
306}

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