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
131	svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
132		WithDevicePolicies(map[string]domain.AppDevicePolicy{
133			"test_gpu": devicePolicyFor([]string{"blog"}, []string{"web"}),
134		})
135	candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
136		name: "web",
137		spec: domain.AppService{Name: "web", Devices: []string{"test_gpu"}},
138	}, "op-1", nil)
139	created := candidate.Container
140	require.NoError(t, err)
141	require.NotNil(t, created)
142	runtime.AssertExpectations(t)
143}
144
145// TestCreateContainer_DeviceBearingRuntimeErrorIsRedactedNotDevicePolicy
146// proves a CreateContainer failure on a device-bearing service is reported
147// as a generic redacted runtime error, never as a device policy violation,
148// and never leaks the resolved CDI IDs embedded in the runtime error.
149func TestCreateContainer_DeviceBearingRuntimeErrorIsRedactedNotDevicePolicy(t *testing.T) {
150	const deviceID = "example.com/gpu=GPU-test-uuid"
151	runtime := outmocks.NewMockContainerRuntime(t)
152	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
153		Return(nil, fmt.Errorf("device %s: permission denied", deviceID)).Once()
154	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
155
156	_, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
157		CDIDevices: []string{deviceID},
158	})
159
160	require.Error(t, err)
161	require.NotErrorIs(t, err, domain.ErrDevicePolicy, "a runtime failure is not a policy refusal")
162	assert.NotContains(t, err.Error(), deviceID, "errors must never leak resolved CDI IDs")
163	assert.NotContains(t, err.Error(), "permission denied", "no runtime text may reach the caller")
164	runtime.AssertExpectations(t)
165}
166
167// TestRestartOneService_RevokedDeviceFailsWithoutRuntimeMutation proves an
168// in-place restart fails closed when the device policy is missing/revoked,
169// before touching the runtime.
170func TestRestartOneService_RevokedDeviceFailsWithoutRuntimeMutation(t *testing.T) {
171	runtime := outmocks.NewMockContainerRuntime(t)
172	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
173	eff := domain.AppEffectiveService{
174		Container: "c1",
175		Spec: domain.AppService{
176			Name:    "web",
177			Devices: []string{"test_gpu"},
178		},
179	}
180
181	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
182		ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
183	}}}
184	result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
185
186	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
187	assert.Contains(t, op.Steps[0].Error, "test_gpu")
188	assert.Equal(t, "failed", result.Result)
189	runtime.AssertNotCalled(t, "RestartContainer")
190	runtime.AssertExpectations(t)
191}
192
193// TestEnsureServiceRunning_RevokedDeviceFailsWithoutRuntimeMutation proves
194// the boot/start recovery path fails closed before starting a container
195// when the device policy is missing/revoked.
196func TestEnsureServiceRunning_RevokedDeviceFailsWithoutRuntimeMutation(t *testing.T) {
197	runtime := outmocks.NewMockContainerRuntime(t)
198	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
199	eff := domain.AppEffectiveService{
200		Container: "c1",
201		Spec: domain.AppService{
202			Name:    "web",
203			Devices: []string{"test_gpu"},
204		},
205	}
206	op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
207		ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
208	}}}
209	result := &LifecycleResult{Services: map[string]ServiceResult{}}
210
211	svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
212
213	assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
214	assert.Contains(t, op.Steps[0].Error, "test_gpu")
215	runtime.AssertNotCalled(t, "StartContainer")
216	runtime.AssertExpectations(t)
217}
218
219// TestPreflightServices_ProbesEngineOncePerRevision proves preflight calls
220// SupportsCDIDevices exactly once for a multi-service device-bearing
221// revision, and that pinned services carry their logical device requests.
222func TestPreflightServices_ProbesEngineOncePerRevision(t *testing.T) {
223	ctx := context.Background()
224	state := outmocks.NewMockAppState(t)
225	runtime := outmocks.NewMockContainerRuntime(t)
226	images := outmocks.NewMockImageResolver(t)
227	secrets := outmocks.NewMockSecretProvider(t)
228
229	rev := domain.AppDesiredRevision{
230		Revision: "rev-1",
231		App:      "blog",
232		Spec: domain.AppSpec{
233			Name: "blog",
234			Env:  map[string]string{},
235			Services: []domain.AppService{
236				{Name: "web", Image: "img:1", Devices: []string{"test_gpu"}},
237				{Name: "worker", Image: "img:1", Devices: []string{"test_gpu"}},
238			},
239		},
240	}
241	state.EXPECT().LoadOwnership(mock.Anything, "blog").
242		Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
243	images.EXPECT().ResolveDigest(mock.Anything, "img:1").Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Twice()
244	runtime.EXPECT().InspectImageVolumes(mock.Anything, "img:1").Return(nil, nil).Twice()
245	runtime.EXPECT().SupportsCDIDevices(mock.Anything).Return(nil).Once()
246	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
247
248	svc := NewService(Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()).
249		WithDevicePolicies(map[string]domain.AppDevicePolicy{
250			"test_gpu": {
251				Name:            "test_gpu",
252				CDI:             []string{"example.com/gpu=GPU-test-uuid"},
253				AllowedApps:     []string{"blog"},
254				AllowedServices: []string{"web", "worker"},
255			},
256		})
257	pinned, err := svc.preflightServices(ctx, "blog", rev, "")
258	require.NoError(t, err)
259	require.Len(t, pinned, 2)
260	runtime.AssertExpectations(t)
261}
262
263// TestCreateContainer_RuntimeUnsupportedSurvivesRedaction proves the
264// engine-unsupported sentinel from a device-bearing create survives the
265// host-inventory redaction so callers map it to the structured
266// runtime-unsupported envelope.
267func TestCreateContainer_RuntimeUnsupportedSurvivesRedaction(t *testing.T) {
268	runtime := outmocks.NewMockContainerRuntime(t)
269	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
270		Return(nil, domain.ErrRuntimeUnsupported).Once()
271	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
272
273	_, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
274		CDIDevices: []string{"example.com/gpu=GPU-test-uuid"},
275	})
276
277	require.Error(t, err)
278	require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
279	assert.NotContains(t, err.Error(), "GPU-test-uuid", "the sanitized error must not echo CDI IDs")
280	runtime.AssertExpectations(t)
281}
282
283// TestSanitizedEngineProbeError proves the capability-probe error keeps
284// cancellation meaningful, collapses every other cause to the unsupported
285// sentinel alone, and never echoes the adapter text.
286func TestSanitizedEngineProbeError(t *testing.T) {
287	t.Run("cancellation is preserved and reported as cancellation", func(t *testing.T) {
288		err := sanitizedEngineProbeError("web", fmt.Errorf("dial unix /run/podman/podman.sock: %w", context.Canceled))
289		require.ErrorIs(t, err, context.Canceled)
290		assert.False(t, errors.Is(err, domain.ErrRuntimeUnsupported), "an aborted probe is not an incapable engine")
291		assert.NotContains(t, err.Error(), "podman.sock")
292	})
293
294	t.Run("deadline is preserved", func(t *testing.T) {
295		err := sanitizedEngineProbeError("web", fmt.Errorf("probe: %w", context.DeadlineExceeded))
296		require.ErrorIs(t, err, context.DeadlineExceeded)
297		assert.NotContains(t, err.Error(), "probe")
298	})
299
300	t.Run("other causes collapse to the unsupported sentinel", func(t *testing.T) {
301		err := sanitizedEngineProbeError("web", errors.New("dial unix /run/podman/podman.sock: connection refused"))
302		require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
303		assert.NotContains(t, err.Error(), "podman.sock")
304	})
305}

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