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// TestStartDeploy_PersistsClaimBeforeAnyExecution proves the first phase
19// durably claims the key with a non-terminal journal and touches no runtime
20// resource: no image resolution, no pull, no workload mutation. A crash right
21// after StartDeploy therefore leaves a claim reconciliation can converge.
22func TestStartDeploy_PersistsClaimBeforeAnyExecution(t *testing.T) {
23 t.Run("keyed", func(t *testing.T) {
24 ctx := context.Background()
25 state, runtime, images, secrets := mockDeps(t)
26 rev := mockRevision()
27
28 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
29 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
30 state.EXPECT().ClaimOperation(mock.Anything, mock.MatchedBy(func(op domain.AppOperation) bool {
31 return op.Op == "k1" && !op.Terminal()
32 })).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
33 return op, true, nil
34 }).Once()
35
36 svc := keyedService(t, state, runtime, images, secrets)
37 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
38
39 require.NoError(t, err)
40 require.True(t, started.Owned)
41 require.Equal(t, "k1", started.Claim.Op)
42 require.False(t, started.Claim.Journal.Terminal(), "the claim is non-terminal before execution")
43 require.Len(t, started.Claim.Journal.Steps, 1)
44 assert.Equal(t, domain.AppStepPending, started.Claim.Journal.Steps[0].State)
45
46 state.AssertNotCalled(t, "LoadOwnership", mock.Anything, mock.Anything)
47 images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
48 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
49 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
50 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
51 })
52
53 t.Run("unkeyed", func(t *testing.T) {
54 ctx := context.Background()
55 state, runtime, images, secrets := mockDeps(t)
56 rev := mockRevision()
57
58 var saved []domain.AppOperation
59 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
60 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
61 saved = append(saved, op)
62 return nil
63 }).Maybe()
64 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
65
66 svc := keyedService(t, state, runtime, images, secrets)
67 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
68
69 require.NoError(t, err)
70 require.True(t, started.Owned)
71 require.Len(t, saved, 1, "the claim journal is persisted before any effect")
72 require.False(t, saved[0].Terminal())
73 assert.Equal(t, domain.AppStepPending, saved[0].Steps[0].State)
74 images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
75 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
76 })
77}
78
79// TestStartDeploy_SameKeyHandsOneOwner proves the second phase is only ever
80// handed to the single claimer: a repeated key reports Owned false, carries
81// the stored journal, and replays with an explicit conflict instead of a
82// second execution.
83func TestStartDeploy_SameKeyHandsOneOwner(t *testing.T) {
84 ctx := context.Background()
85 state, runtime, images, secrets := mockDeps(t)
86 rev := mockRevision()
87
88 stored := domain.AppOperation{
89 Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
90 Request: domain.AppOperationRequestFor("deploy", "blog", "", ""),
91 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
92 }
93 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
94 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
95 state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(domain.AppOperation{}, true, nil).Once()
96 state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(stored, false, nil).Once()
97
98 svc := keyedService(t, state, runtime, images, secrets)
99 first, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
100 require.NoError(t, err)
101 require.True(t, first.Owned)
102
103 second, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
104 require.NoError(t, err)
105 require.False(t, second.Owned, "the key is never handed to a second owner")
106 require.Equal(t, "k1", second.Claim.Journal.Op)
107 require.ErrorIs(t, second.ReplayError(), domain.ErrAppStateConflict)
108
109 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
110}
111
112// expectHeadlessDeploy wires one full sequential replacement of a headless
113// service whose only effect is one container replacement, and records every
114// journal write so the caller can assert claim-before-execution ordering.
115func expectHeadlessDeploy(
116 state *outmocks.MockAppState,
117 runtime *outmocks.MockContainerRuntime,
118 images *outmocks.MockImageResolver,
119 secrets *outmocks.MockSecretProvider,
120) (domain.AppDesiredRevision, *[]domain.AppOperation) {
121 svcSpec := webService()
122 svcSpec.HTTP = nil
123 svcSpec.Readiness = domain.AppReadiness{}
124 rev := domain.AppDesiredRevision{
125 Revision: "rev-1", App: "blog",
126 Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}},
127 }
128
129 saved := &[]domain.AppOperation{}
130 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
131 *saved = append(*saved, op)
132 return nil
133 }).Maybe()
134 state.EXPECT().Recover(mock.Anything).Return(nil)
135 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
136 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil)
137 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
138 runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil)
139 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
140 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
141 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
142 App: "blog", Services: map[string]domain.AppEffectiveService{},
143 }, true, nil)
144 expectNetworkProvision(runtime, "app-blog", 1)
145 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil)
146 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil)
147 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
148 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
149 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
150 return rev, saved
151}
152
153// TestExecuteDeploy_RecordsTerminalOutcome proves the second phase executes
154// the already claimed operation and records its terminal success, while the
155// claim itself stayed non-terminal up to execution.
156func TestExecuteDeploy_RecordsTerminalOutcome(t *testing.T) {
157 ctx := context.Background()
158 state, runtime, images, secrets := mockDeps(t)
159 _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
160
161 svc := deployment.NewService(deployment.Deps{
162 State: state, Runtime: runtime, Images: images, Secrets: secrets,
163 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
164 func(context.Context, string, string) (int, error) { return 200, nil },
165 func(context.Context, string) error { return nil },
166 ))
167
168 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
169 require.NoError(t, err)
170 require.True(t, started.Owned)
171 require.Len(t, *saved, 1)
172 require.False(t, (*saved)[0].Terminal(), "the claim is non-terminal before execution")
173
174 result, err := svc.ExecuteDeploy(ctx, started.Claim)
175
176 require.NoError(t, err)
177 require.NotNil(t, result)
178 assert.Equal(t, "deployed", result.Services["web"].Result)
179 last := (*saved)[len(*saved)-1]
180 require.True(t, last.Terminal(), "the second phase records a terminal outcome")
181 assert.Equal(t, domain.AppOutcomeSuccess, last.Outcome)
182 runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
183}
184
185// TestExecuteDeploy_RecordsTerminalFailure proves a preflight that fails
186// during the second phase records the terminal failure in the claimed
187// journal and runs no workload.
188func TestExecuteDeploy_RecordsTerminalFailure(t *testing.T) {
189 ctx := context.Background()
190 state, runtime, images, secrets := mockDeps(t)
191 rev := mockRevision()
192
193 var saved []domain.AppOperation
194 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
195 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
196 saved = append(saved, op)
197 return nil
198 }).Maybe()
199 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
200 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
201 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError)
202
203 svc := keyedService(t, state, runtime, images, secrets)
204 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
205 require.NoError(t, err)
206 require.True(t, started.Owned)
207
208 result, err := svc.ExecuteDeploy(ctx, started.Claim)
209
210 require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
211 require.NotNil(t, result)
212 require.NotEmpty(t, saved)
213 last := saved[len(saved)-1]
214 require.True(t, last.Terminal())
215 assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
216 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
217}
218
219// TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim proves the store is
220// authoritative: a claim whose durable record was terminalized by another
221// actor between the phases replays that outcome and never executes, even
222// though the in-process claim still carries a non-terminal journal.
223func TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim(t *testing.T) {
224 ctx := context.Background()
225 state, runtime, images, secrets := mockDeps(t)
226 rev := mockRevision()
227
228 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
229 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
230 state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
231 return op, true, nil
232 }).Once()
233
234 svc := keyedService(t, state, runtime, images, secrets)
235 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
236 require.NoError(t, err)
237 require.True(t, started.Owned)
238 require.False(t, started.Claim.Journal.Terminal(), "the in-process claim is still non-terminal")
239
240 // Another actor settled the key in the store: the stale in-memory copy
241 // must not drive execution.
242 expectStoredOperation(state, domain.AppOperation{
243 Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
244 Outcome: domain.AppOutcomeSuccess,
245 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepSucceeded}},
246 })
247
248 result, err := svc.ExecuteDeploy(ctx, started.Claim)
249
250 require.NoError(t, err)
251 require.NotNil(t, result)
252 assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome)
253 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
254 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
255}
256
257// TestExecuteDeploy_CancellationLeavesRecoverableClaim proves cancellation or
258// shutdown during the second phase leaves the durable claim non-terminal, and
259// the existing reconciliation converges it on the next mutation instead of
260// ever re-executing it.
261func TestExecuteDeploy_CancellationLeavesRecoverableClaim(t *testing.T) {
262 ctx := context.Background()
263 store := newTestStore(t)
264 _, desired := seedReplaceableApp(t, ctx, store)
265 runtime := outmocks.NewMockContainerRuntime(t)
266
267 barrier := outmocks.NewMockGCBarrier(t)
268 lease := outmocks.NewMockGCLease(t)
269 barrier.EXPECT().AcquireShared(mock.Anything).Return(nil, context.Canceled).Once()
270 barrier.EXPECT().AcquireShared(mock.Anything).Return(lease, nil).Once()
271 lease.EXPECT().Release().Return().Once()
272
273 svc := deployment.NewService(deployment.Deps{
274 State: store, Runtime: runtime,
275 Images: outmocks.NewMockImageResolver(t), Secrets: outmocks.NewMockSecretProvider(t),
276 }, zerowrap.Default()).WithGCBarrier(barrier)
277
278 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-cancel", Revision: desired.Revision})
279 require.NoError(t, err)
280 require.True(t, started.Owned)
281
282 claimed, err := store.LoadOperation(ctx, "blog", "op-cancel")
283 require.NoError(t, err)
284 require.False(t, claimed.Terminal(), "the claim is durable and non-terminal before execution")
285
286 cancelled, cancel := context.WithCancel(ctx)
287 cancel()
288 _, err = svc.ExecuteDeploy(cancelled, started.Claim)
289 require.ErrorIs(t, err, context.Canceled)
290
291 interrupted, err := store.LoadOperation(ctx, "blog", "op-cancel")
292 require.NoError(t, err)
293 require.False(t, interrupted.Terminal(), "cancellation leaves the claim non-terminal for recovery")
294
295 // The next mutation reconciles the interrupted claim before claiming its
296 // own key: the operation is finalized and never executes twice.
297 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
298 require.NoError(t, err)
299
300 reconciled, err := store.LoadOperation(ctx, "blog", "op-cancel")
301 require.NoError(t, err)
302 require.True(t, reconciled.Terminal())
303 assert.Equal(t, domain.AppOutcomeFailed, reconciled.Outcome)
304
305 _, err = svc.ExecuteDeploy(ctx, deployment.DeployClaim{App: "blog", Op: "op-cancel"})
306 require.ErrorIs(t, err, domain.ErrAppStateConflict, "a settled key never re-executes")
307}
308
309// TestDeploy_IsStartDeployThenExecuteDeploy proves the synchronous entry point
310// is still the composition of both phases: it journals a non-terminal claim
311// before any effect and a terminal outcome after the replacement.
312func TestDeploy_IsStartDeployThenExecuteDeploy(t *testing.T) {
313 ctx := context.Background()
314 state, runtime, images, secrets := mockDeps(t)
315 _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
316
317 svc := deployment.NewService(deployment.Deps{
318 State: state, Runtime: runtime, Images: images, Secrets: secrets,
319 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
320 func(context.Context, string, string) (int, error) { return 200, nil },
321 func(context.Context, string) error { return nil },
322 ))
323
324 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
325
326 require.NoError(t, err)
327 require.NotNil(t, result)
328 assert.Equal(t, "deployed", result.Services["web"].Result)
329 require.NotEmpty(t, *saved)
330 require.False(t, (*saved)[0].Terminal(), "Deploy journals the claim before execution")
331 require.True(t, (*saved)[len(*saved)-1].Terminal(), "Deploy records the terminal outcome")
332}
333
334// TestStartDeploy_LiveClaimSurvivesForegroundReconciliation pins the
335// StartDeploy/ExecuteDeploy hand-off window: while an owner is between the
336// claim and the coordinator (blocked before its GC lease), a same-key and a
337// different-key foreground mutation must not reconcile, terminalize, or
338// re-claim the live operation. The single owner then executes exactly the
339// revision it claimed.
340func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) {
341 ctx := context.Background()
342 store := newTestStore(t)
343 _, desired := seedReplaceableApp(t, ctx, store)
344 runtime := outmocks.NewMockContainerRuntime(t)
345 images := outmocks.NewMockImageResolver(t)
346 secrets := outmocks.NewMockSecretProvider(t)
347
348 // One full headless replacement, the only effects the live owner may run.
349 images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
350 runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
351 expectNetworkProvision(runtime, "app-blog", 1)
352 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
353 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
354 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
355 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
356 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
357 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
358 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
359
360 // Block ExecuteDeploy on its GC lease, before it takes the coordinator.
361 entered := make(chan struct{})
362 release := make(chan struct{})
363 barrier := outmocks.NewMockGCBarrier(t)
364 lease := outmocks.NewMockGCLease(t)
365 barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
366 close(entered)
367 <-release
368 }).Return(lease, nil).Once()
369 lease.EXPECT().Release().Return().Once()
370
371 svc := deployment.NewService(deployment.Deps{
372 State: store, Runtime: runtime, Images: images, Secrets: secrets,
373 }, zerowrap.Default()).WithGCBarrier(barrier).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
374 func(context.Context, string, string) (int, error) { return 200, nil },
375 func(context.Context, string) error { return nil },
376 ))
377
378 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
379 require.NoError(t, err)
380 require.True(t, started.Owned, "the first claim owns the operation")
381
382 done := make(chan error, 1)
383 go func() {
384 _, execErr := svc.ExecuteDeploy(ctx, started.Claim)
385 done <- execErr
386 }()
387 select {
388 case <-entered:
389 case <-time.After(5 * time.Second):
390 t.Fatal("timed out waiting for ExecuteDeploy to block before the coordinator")
391 }
392
393 // Same key while the owner is live: replay, never a second owner.
394 same, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
395 require.NoError(t, err)
396 require.False(t, same.Owned, "a same-key replay never owns the operation twice")
397 require.ErrorIs(t, same.ReplayError(), domain.ErrAppStateConflict, "the live claim was not terminalized")
398
399 // Different key while the owner is live: the unfinished-operation guard
400 // refuses, so no second claim is written.
401 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-other", Revision: "rev-0"})
402 require.ErrorIs(t, err, domain.ErrAppStateConflict)
403
404 live, err := store.LoadOperation(ctx, "blog", "op-live")
405 require.NoError(t, err)
406 require.False(t, live.Terminal(), "a live operation is never terminalized by a foreground mutation")
407 _, err = store.LoadOperation(ctx, "blog", "op-other")
408 require.ErrorIs(t, err, domain.ErrAppOperationNotFound, "no second claim was written")
409
410 close(release)
411 require.NoError(t, <-done)
412
413 final, err := store.LoadOperation(ctx, "blog", "op-live")
414 require.NoError(t, err)
415 require.True(t, final.Terminal())
416 assert.Equal(t, domain.AppOutcomeSuccess, final.Outcome)
417 assert.Equal(t, desired.Revision, final.InputRevision, "the owner executed the revision it claimed, not the stale one")
418 runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
419}
420
421// TestAbandonDeploy_SettlesClaimWithoutStarting proves a claimed but never
422// scheduled operation can be settled safely: the journal becomes terminal, the
423// in-process live marker is released, and a later claim succeeds instead of
424// being refused as a permanently unfinished operation. No runtime effect is
425// touched.
426func TestAbandonDeploy_SettlesClaimWithoutStarting(t *testing.T) {
427 ctx := context.Background()
428 store := newTestStore(t)
429 _, desired := seedReplaceableApp(t, ctx, store)
430 runtime := outmocks.NewMockContainerRuntime(t)
431 images := outmocks.NewMockImageResolver(t)
432 secrets := outmocks.NewMockSecretProvider(t)
433
434 svc := deployment.NewService(deployment.Deps{
435 State: store, Runtime: runtime, Images: images, Secrets: secrets,
436 }, zerowrap.Default())
437
438 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-abandon", Revision: desired.Revision})
439 require.NoError(t, err)
440 require.True(t, started.Owned)
441
442 // While the claim is live, a different key is refused as unfinished.
443 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-blocked", Revision: desired.Revision})
444 require.ErrorIs(t, err, domain.ErrAppStateConflict)
445
446 require.NoError(t, svc.AbandonDeploy(ctx, started.Claim))
447
448 settled, err := store.LoadOperation(ctx, "blog", "op-abandon")
449 require.NoError(t, err)
450 require.True(t, settled.Terminal(), "a settled claim must not remain in flight")
451 assert.Equal(t, domain.AppOutcomeFailed, settled.Outcome)
452
453 // The live marker is gone and the journal terminal, so the next claim is
454 // accepted cleanly instead of hanging on a false live operation.
455 next, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
456 require.NoError(t, err)
457 require.True(t, next.Owned, "a settled claim never blocks the next owner")
458 require.NoError(t, svc.AbandonDeploy(ctx, next.Claim))
459
460 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
461}