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}