v3.0.0-alpha.1 6fdddb8

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

Tags 106

↑↓ moveenter openesc close