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}