1package deployment_test
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/internal/usecase/deployment"
16)
17
18// recordSavedOperations captures every journal write so a test can assert the
19// terminal preflight outcome and that no effect ran before the gate failed.
20func recordSavedOperations(state *outmocks.MockAppState) *[]domain.AppOperation {
21 saved := &[]domain.AppOperation{}
22 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
23 func(_ context.Context, op domain.AppOperation) error {
24 *saved = append(*saved, op)
25 return nil
26 })
27 return saved
28}
29
30// assertNoDeployMutation proves a gate that failed before execution never
31// touched the workload: no container is created, started, stopped, or
32// removed, no volume is created, and ACTIVE is never republished.
33func assertNoDeployMutation(t *testing.T, runtime *outmocks.MockContainerRuntime, state *outmocks.MockAppState) {
34 t.Helper()
35 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
36 runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
37 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
38 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
39 runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything)
40 state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
41}
42
43// assertPreflightGateJournal proves a failed preflight gate left a terminal
44// failed journal whose preflight step carries the failure.
45func assertPreflightGateJournal(t *testing.T, saved []domain.AppOperation) {
46 t.Helper()
47 require.NotEmpty(t, saved, "a failed gate must journal the claim and its failure")
48 last := saved[len(saved)-1]
49 require.True(t, last.Terminal(), "a failed preflight gate must persist a terminal journal")
50 assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
51 step, ok := opStep(last, "preflight")
52 require.True(t, ok)
53 assert.Equal(t, domain.AppStepFailed, step.State)
54}
55
56// TestDeploy_MissingRequiredSecretFailsClosed proves a deploy whose required
57// secret cannot be resolved fails closed with ErrAppSecretMissing before any
58// workload mutation, and journals the terminal failure.
59func TestDeploy_MissingRequiredSecretFailsClosed(t *testing.T) {
60 ctx := context.Background()
61 state, runtime, images, secrets := mockDeps(t)
62 rev := mockRevision()
63
64 state.EXPECT().Recover(mock.Anything).Return(nil)
65 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
66 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
67 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
68 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("", assert.AnError).Once()
69 saved := recordSavedOperations(state)
70
71 svc := keyedService(t, state, runtime, images, secrets)
72 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
73
74 require.ErrorIs(t, err, domain.ErrAppSecretMissing)
75 assertNoDeployMutation(t, runtime, state)
76 assertPreflightGateJournal(t, *saved)
77}
78
79// TestDeploy_EnvSecretCollisionFailsClosed proves a stored revision whose
80// public env collides with one of the service's secret keys is refused
81// before any image resolution or workload mutation.
82func TestDeploy_EnvSecretCollisionFailsClosed(t *testing.T) {
83 ctx := context.Background()
84 state, runtime, images, secrets := mockDeps(t)
85 rev := mockRevision()
86 rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"}
87
88 state.EXPECT().Recover(mock.Anything).Return(nil)
89 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
90 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
91 saved := recordSavedOperations(state)
92
93 svc := keyedService(t, state, runtime, images, secrets)
94 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
95
96 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
97 images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
98 secrets.AssertNotCalled(t, "GetSecret", mock.Anything, mock.Anything)
99 assertNoDeployMutation(t, runtime, state)
100 assertPreflightGateJournal(t, *saved)
101}
102
103// TestDeploy_UnmanagedImageVolumeRejected proves an image that declares a
104// VOLUME with no operator mapping is refused before any workload mutation,
105// and journals the terminal failure.
106func TestDeploy_UnmanagedImageVolumeRejected(t *testing.T) {
107 ctx := context.Background()
108 state, runtime, images, secrets := mockDeps(t)
109 rev := mockRevision()
110
111 state.EXPECT().Recover(mock.Anything).Return(nil)
112 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
113 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
114 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
115 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
116 runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return([]string{"/data"}, nil).Once()
117 saved := recordSavedOperations(state)
118
119 svc := keyedService(t, state, runtime, images, secrets)
120 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
121
122 require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
123 assertNoDeployMutation(t, runtime, state)
124 assertPreflightGateJournal(t, *saved)
125}
126
127// TestDeploy_RefusesUnownedExistingVolume proves R2: a generated runtime
128// volume that already exists but is NOT recorded in the app's ownership is
129// refused fail-closed — the deploy must never implicitly adopt (and modify)
130// foreign data under a reused name.
131func TestDeploy_RefusesUnownedExistingVolume(t *testing.T) {
132 ctx := context.Background()
133 state, runtime, images, secrets := mockDeps(t)
134 svcSpec := webService()
135 svcSpec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
136 rev := testRevision("blog", svcSpec)
137 runtimeName := domain.RuntimeVolumeName("blog", "web", "data")
138
139 state.EXPECT().Recover(mock.Anything).Return(nil)
140 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
141 // Ownership records no volume, so the runtime one is foreign.
142 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
143 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil).Once()
144 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
145 runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once()
146 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
147 runtime.EXPECT().VolumeExists(mock.Anything, runtimeName).Return(true, nil).Once()
148 saved := recordSavedOperations(state)
149
150 svc := keyedService(t, state, runtime, images, secrets)
151 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
152
153 require.ErrorIs(t, err, domain.ErrAppStateConflict)
154 require.Contains(t, err.Error(), "not owned by app")
155 assertNoDeployMutation(t, runtime, state)
156 assertPreflightGateJournal(t, *saved)
157}
158
159// TestStartDeploy_TargetedDiffPolicy proves the targeted-deploy convergence
160// rules live in the claim phase: a requested-service image change is allowed
161// and claims the operation, while app-wide or other-service divergence is
162// refused with ErrAppStateConflict before any claim is written and without
163// resolving an image or touching the runtime.
164func TestStartDeploy_TargetedDiffPolicy(t *testing.T) {
165 baseService := webService()
166 worker := webService()
167 worker.Name = "worker"
168 worker.HTTP = []domain.AppHTTPInterface{{Host: "worker.example.com", Port: 8080, TLS: domain.AppTLSAuto}}
169 base := domain.AppActive{
170 App: "blog", ConvergedRevision: "rev-1", Converged: true,
171 Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}},
172 Services: map[string]domain.AppEffectiveService{
173 "web": {Spec: baseService},
174 "worker": {Spec: worker},
175 },
176 }
177
178 tests := []struct {
179 name string
180 mutate func(*domain.AppSpec)
181 wantPath string
182 }{
183 {name: "requested service image", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
184 {name: "identical spec", mutate: func(*domain.AppSpec) {}},
185 {name: "other service", wantPath: "service/worker/image", mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
186 {name: "environment", wantPath: "also changes env", mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
187 {name: "network", wantPath: "network/shared/aliases", mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
188 {name: "service added", wantPath: "service/job (added)", mutate: func(spec *domain.AppSpec) {
189 spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
190 }},
191 {name: "service removed", wantPath: "service/worker (removed)", mutate: func(spec *domain.AppSpec) {
192 spec.Services = spec.Services[:1]
193 spec.Networks = nil
194 }},
195 }
196 for _, tc := range tests {
197 t.Run(tc.name, func(t *testing.T) {
198 ctx := context.Background()
199 state, runtime, images, secrets := mockDeps(t)
200 spec := domain.AppSpec{
201 Name: "blog",
202 Networks: append([]domain.AppSharedNetwork(nil), base.Networks...),
203 Services: []domain.AppService{baseService, worker},
204 }
205 spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
206 tc.mutate(&spec)
207 rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
208
209 state.EXPECT().Recover(mock.Anything).Return(nil)
210 state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
211 state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
212 if tc.wantPath == "" {
213 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
214 }
215
216 svc := keyedService(t, state, runtime, images, secrets)
217 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
218
219 if tc.wantPath != "" {
220 require.ErrorIs(t, err, domain.ErrAppStateConflict)
221 assert.Contains(t, err.Error(), tc.wantPath)
222 state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
223 } else {
224 require.NoError(t, err)
225 require.True(t, started.Owned)
226 }
227 images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
228 assertNoDeployMutation(t, runtime, state)
229 })
230 }
231}
232
233// TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection proves the shared
234// GC lease is taken by the execution phase before resource selection and
235// released only when the mutation ends. Prune can therefore never observe a
236// resource that was selected but whose protection is not yet durable.
237func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
238 ctx := context.Background()
239 state, runtime, images, secrets := mockDeps(t)
240 svcSpec := webService()
241 svcSpec.HTTP = nil
242 svcSpec.Readiness = domain.AppReadiness{}
243 rev := testRevision("blog", svcSpec)
244
245 barrier := outmocks.NewMockGCBarrier(t)
246 lease := outmocks.NewMockGCLease(t)
247 held := false
248 barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
249 held = true
250 }).Return(lease, nil).Once()
251 lease.EXPECT().Release().Run(func() {
252 held = false
253 }).Once()
254
255 state.EXPECT().Recover(mock.Anything).Return(nil)
256 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
257 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
258 // Image resolution and image-volume inspection are the resource-selection
259 // steps: they must happen inside the shared lease.
260 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
261 require.True(t, held, "image resolution must run inside the shared GC lease")
262 }).Return(restartTestDigest, nil).Once()
263 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
264 runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
265 require.True(t, held, "image-volume inspection must run inside the shared GC lease")
266 }).Return(nil, nil).Once()
267 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
268 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
269 App: "blog", Services: map[string]domain.AppEffectiveService{},
270 }, true, nil)
271 expectNetworkProvision(runtime, "app-blog", 1)
272 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
273 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
274 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
275 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
276 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
277 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
278
279 svc := deployment.NewService(deployment.Deps{
280 State: state, Runtime: runtime, Images: images, Secrets: secrets,
281 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
282 func(context.Context, string, string) (int, error) { return 200, nil },
283 func(context.Context, string) error { return nil },
284 )).WithGCBarrier(barrier)
285
286 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
287 require.NoError(t, err)
288 require.True(t, started.Owned)
289 require.False(t, held, "the claim phase takes no shared GC lease")
290
291 result, err := svc.ExecuteDeploy(ctx, started.Claim)
292 require.NoError(t, err)
293 require.NotNil(t, result)
294 assert.Equal(t, "deployed", result.Services["web"].Result)
295 assert.False(t, held, "the shared lease must be released when the operation ends")
296}
297
298// deviceRevision returns a device-bearing revision over the standard
299// mockRevision shape with device policies authorizing it.
300func deviceRevision() domain.AppDesiredRevision {
301 rev := mockRevision()
302 rev.Spec.Services[0].Devices = []string{"test_gpu"}
303 return rev
304}
305
306func devicePolicies() map[string]domain.AppDevicePolicy {
307 return map[string]domain.AppDevicePolicy{
308 "test_gpu": {
309 Name: "test_gpu",
310 CDI: []string{"example.com/gpu=GPU-test-uuid"},
311 AllowedApps: []string{"blog"},
312 AllowedServices: []string{"web"},
313 },
314 }
315}
316
317// TestDeploy_UnsupportedEngineFailsBeforeMutation proves a device-bearing
318// revision on an engine that cannot serve CDI fails in preflight with
319// ErrRuntimeUnsupported: the serving generation is never withdrawn and no
320// container is created, started, stopped, or removed.
321func TestDeploy_UnsupportedEngineFailsBeforeMutation(t *testing.T) {
322 ctx := context.Background()
323 state, runtime, images, secrets := mockDeps(t)
324 rev := deviceRevision()
325
326 state.EXPECT().Recover(mock.Anything).Return(nil)
327 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
328 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
329 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
330 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
331 runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil)
332 runtime.EXPECT().SupportsCDIDevices(mock.Anything).Return(domain.ErrRuntimeUnsupported).Once()
333 saved := recordSavedOperations(state)
334
335 svc := keyedService(t, state, runtime, images, secrets).WithDevicePolicies(devicePolicies())
336 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
337
338 require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
339 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
340 assertNoDeployMutation(t, runtime, state)
341 assertPreflightGateJournal(t, *saved)
342}