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}