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 wantErr bool
182 }{
183 {name: "requested service image", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
184 {name: "other service", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
185 {name: "environment", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
186 {name: "network", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
187 {name: "service added", wantErr: true, mutate: func(spec *domain.AppSpec) {
188 spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
189 }},
190 }
191 for _, tc := range tests {
192 t.Run(tc.name, func(t *testing.T) {
193 ctx := context.Background()
194 state, runtime, images, secrets := mockDeps(t)
195 spec := domain.AppSpec{
196 Name: "blog",
197 Networks: append([]domain.AppSharedNetwork(nil), base.Networks...),
198 Services: []domain.AppService{baseService, worker},
199 }
200 spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
201 tc.mutate(&spec)
202 rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
203
204 state.EXPECT().Recover(mock.Anything).Return(nil)
205 state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
206 state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
207 if !tc.wantErr {
208 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
209 }
210
211 svc := keyedService(t, state, runtime, images, secrets)
212 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
213
214 if tc.wantErr {
215 require.ErrorIs(t, err, domain.ErrAppStateConflict)
216 state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
217 } else {
218 require.NoError(t, err)
219 require.True(t, started.Owned)
220 }
221 images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
222 assertNoDeployMutation(t, runtime, state)
223 })
224 }
225}
226
227// TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection proves the shared
228// GC lease is taken by the execution phase before resource selection and
229// released only when the mutation ends. Prune can therefore never observe a
230// resource that was selected but whose protection is not yet durable.
231func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
232 ctx := context.Background()
233 state, runtime, images, secrets := mockDeps(t)
234 svcSpec := webService()
235 svcSpec.HTTP = nil
236 svcSpec.Readiness = domain.AppReadiness{}
237 rev := testRevision("blog", svcSpec)
238
239 barrier := outmocks.NewMockGCBarrier(t)
240 lease := outmocks.NewMockGCLease(t)
241 held := false
242 barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
243 held = true
244 }).Return(lease, nil).Once()
245 lease.EXPECT().Release().Run(func() {
246 held = false
247 }).Once()
248
249 state.EXPECT().Recover(mock.Anything).Return(nil)
250 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
251 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
252 // Image resolution and image-volume inspection are the resource-selection
253 // steps: they must happen inside the shared lease.
254 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
255 require.True(t, held, "image resolution must run inside the shared GC lease")
256 }).Return(restartTestDigest, nil).Once()
257 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
258 runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
259 require.True(t, held, "image-volume inspection must run inside the shared GC lease")
260 }).Return(nil, nil).Once()
261 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
262 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
263 App: "blog", Services: map[string]domain.AppEffectiveService{},
264 }, true, nil)
265 expectNetworkProvision(runtime, "app-blog", 1)
266 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
267 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
268 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
269 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
270 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
271
272 svc := deployment.NewService(deployment.Deps{
273 State: state, Runtime: runtime, Images: images, Secrets: secrets,
274 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
275 func(context.Context, string, string) (int, error) { return 200, nil },
276 func(context.Context, string) error { return nil },
277 )).WithGCBarrier(barrier)
278
279 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
280 require.NoError(t, err)
281 require.True(t, started.Owned)
282 require.False(t, held, "the claim phase takes no shared GC lease")
283
284 result, err := svc.ExecuteDeploy(ctx, started.Claim)
285 require.NoError(t, err)
286 require.NotNil(t, result)
287 assert.Equal(t, "deployed", result.Services["web"].Result)
288 assert.False(t, held, "the shared lease must be released when the operation ends")
289}
290
291// deviceRevision returns a device-bearing revision over the standard
292// mockRevision shape with device policies authorizing it.
293func deviceRevision() domain.AppDesiredRevision {
294 rev := mockRevision()
295 rev.Spec.Services[0].Devices = []string{"test_gpu"}
296 return rev
297}
298
299func devicePolicies() map[string]domain.AppDevicePolicy {
300 return map[string]domain.AppDevicePolicy{
301 "test_gpu": {
302 Name: "test_gpu",
303 CDI: []string{"example.com/gpu=GPU-test-uuid"},
304 AllowedApps: []string{"blog"},
305 AllowedServices: []string{"web"},
306 },
307 }
308}
309
310// TestDeploy_UnsupportedEngineFailsBeforeMutation proves a device-bearing
311// revision on an engine that cannot serve CDI fails in preflight with
312// ErrRuntimeUnsupported: the serving generation is never withdrawn and no
313// container is created, started, stopped, or removed.
314func TestDeploy_UnsupportedEngineFailsBeforeMutation(t *testing.T) {
315 ctx := context.Background()
316 state, runtime, images, secrets := mockDeps(t)
317 rev := deviceRevision()
318
319 state.EXPECT().Recover(mock.Anything).Return(nil)
320 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
321 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
322 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
323 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
324 runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil)
325 runtime.EXPECT().SupportsCDIDevices(mock.Anything).Return(domain.ErrRuntimeUnsupported).Once()
326 saved := recordSavedOperations(state)
327
328 svc := keyedService(t, state, runtime, images, secrets).WithDevicePolicies(devicePolicies())
329 _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
330
331 require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
332 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
333 assertNoDeployMutation(t, runtime, state)
334 assertPreflightGateJournal(t, *saved)
335}