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