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 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
148 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
149 return rev, saved
150}
151
152// TestExecuteDeploy_RecordsTerminalOutcome proves the second phase executes
153// the already claimed operation and records its terminal success, while the
154// claim itself stayed non-terminal up to execution.
155func TestExecuteDeploy_RecordsTerminalOutcome(t *testing.T) {
156 ctx := context.Background()
157 state, runtime, images, secrets := mockDeps(t)
158 _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
159
160 svc := deployment.NewService(deployment.Deps{
161 State: state, Runtime: runtime, Images: images, Secrets: secrets,
162 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
163 func(context.Context, string, string) (int, error) { return 200, nil },
164 func(context.Context, string) error { return nil },
165 ))
166
167 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
168 require.NoError(t, err)
169 require.True(t, started.Owned)
170 require.Len(t, *saved, 1)
171 require.False(t, (*saved)[0].Terminal(), "the claim is non-terminal before execution")
172
173 result, err := svc.ExecuteDeploy(ctx, started.Claim)
174
175 require.NoError(t, err)
176 require.NotNil(t, result)
177 assert.Equal(t, "deployed", result.Services["web"].Result)
178 last := (*saved)[len(*saved)-1]
179 require.True(t, last.Terminal(), "the second phase records a terminal outcome")
180 assert.Equal(t, domain.AppOutcomeSuccess, last.Outcome)
181 runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
182}
183
184// TestExecuteDeploy_RecordsTerminalFailure proves a preflight that fails
185// during the second phase records the terminal failure in the claimed
186// journal and runs no workload.
187func TestExecuteDeploy_RecordsTerminalFailure(t *testing.T) {
188 ctx := context.Background()
189 state, runtime, images, secrets := mockDeps(t)
190 rev := mockRevision()
191
192 var saved []domain.AppOperation
193 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
194 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
195 saved = append(saved, op)
196 return nil
197 }).Maybe()
198 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
199 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
200 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError)
201
202 svc := keyedService(t, state, runtime, images, secrets)
203 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
204 require.NoError(t, err)
205 require.True(t, started.Owned)
206
207 result, err := svc.ExecuteDeploy(ctx, started.Claim)
208
209 require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
210 require.NotNil(t, result)
211 require.NotEmpty(t, saved)
212 last := saved[len(saved)-1]
213 require.True(t, last.Terminal())
214 assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
215 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
216}
217
218// TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim proves the store is
219// authoritative: a claim whose durable record was terminalized by another
220// actor between the phases replays that outcome and never executes, even
221// though the in-process claim still carries a non-terminal journal.
222func TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim(t *testing.T) {
223 ctx := context.Background()
224 state, runtime, images, secrets := mockDeps(t)
225 rev := mockRevision()
226
227 state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
228 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
229 state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
230 return op, true, nil
231 }).Once()
232
233 svc := keyedService(t, state, runtime, images, secrets)
234 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
235 require.NoError(t, err)
236 require.True(t, started.Owned)
237 require.False(t, started.Claim.Journal.Terminal(), "the in-process claim is still non-terminal")
238
239 // Another actor settled the key in the store: the stale in-memory copy
240 // must not drive execution.
241 expectStoredOperation(state, domain.AppOperation{
242 Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
243 Outcome: domain.AppOutcomeSuccess,
244 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepSucceeded}},
245 })
246
247 result, err := svc.ExecuteDeploy(ctx, started.Claim)
248
249 require.NoError(t, err)
250 require.NotNil(t, result)
251 assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome)
252 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
253 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
254}
255
256// TestExecuteDeploy_CancellationLeavesRecoverableClaim proves cancellation or
257// shutdown during the second phase leaves the durable claim non-terminal, and
258// the existing reconciliation converges it on the next mutation instead of
259// ever re-executing it.
260func TestExecuteDeploy_CancellationLeavesRecoverableClaim(t *testing.T) {
261 ctx := context.Background()
262 store := newTestStore(t)
263 _, desired := seedReplaceableApp(t, ctx, store)
264 runtime := outmocks.NewMockContainerRuntime(t)
265
266 barrier := outmocks.NewMockGCBarrier(t)
267 lease := outmocks.NewMockGCLease(t)
268 barrier.EXPECT().AcquireShared(mock.Anything).Return(nil, context.Canceled).Once()
269 barrier.EXPECT().AcquireShared(mock.Anything).Return(lease, nil).Once()
270 lease.EXPECT().Release().Return().Once()
271
272 svc := deployment.NewService(deployment.Deps{
273 State: store, Runtime: runtime,
274 Images: outmocks.NewMockImageResolver(t), Secrets: outmocks.NewMockSecretProvider(t),
275 }, zerowrap.Default()).WithGCBarrier(barrier)
276
277 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-cancel", Revision: desired.Revision})
278 require.NoError(t, err)
279 require.True(t, started.Owned)
280
281 claimed, err := store.LoadOperation(ctx, "blog", "op-cancel")
282 require.NoError(t, err)
283 require.False(t, claimed.Terminal(), "the claim is durable and non-terminal before execution")
284
285 cancelled, cancel := context.WithCancel(ctx)
286 cancel()
287 _, err = svc.ExecuteDeploy(cancelled, started.Claim)
288 require.ErrorIs(t, err, context.Canceled)
289
290 interrupted, err := store.LoadOperation(ctx, "blog", "op-cancel")
291 require.NoError(t, err)
292 require.False(t, interrupted.Terminal(), "cancellation leaves the claim non-terminal for recovery")
293
294 // The next mutation reconciles the interrupted claim before claiming its
295 // own key: the operation is finalized and never executes twice.
296 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
297 require.NoError(t, err)
298
299 reconciled, err := store.LoadOperation(ctx, "blog", "op-cancel")
300 require.NoError(t, err)
301 require.True(t, reconciled.Terminal())
302 assert.Equal(t, domain.AppOutcomeFailed, reconciled.Outcome)
303
304 _, err = svc.ExecuteDeploy(ctx, deployment.DeployClaim{App: "blog", Op: "op-cancel"})
305 require.ErrorIs(t, err, domain.ErrAppStateConflict, "a settled key never re-executes")
306}
307
308// TestDeploy_IsStartDeployThenExecuteDeploy proves the synchronous entry point
309// is still the composition of both phases: it journals a non-terminal claim
310// before any effect and a terminal outcome after the replacement.
311func TestDeploy_IsStartDeployThenExecuteDeploy(t *testing.T) {
312 ctx := context.Background()
313 state, runtime, images, secrets := mockDeps(t)
314 _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
315
316 svc := deployment.NewService(deployment.Deps{
317 State: state, Runtime: runtime, Images: images, Secrets: secrets,
318 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
319 func(context.Context, string, string) (int, error) { return 200, nil },
320 func(context.Context, string) error { return nil },
321 ))
322
323 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
324
325 require.NoError(t, err)
326 require.NotNil(t, result)
327 assert.Equal(t, "deployed", result.Services["web"].Result)
328 require.NotEmpty(t, *saved)
329 require.False(t, (*saved)[0].Terminal(), "Deploy journals the claim before execution")
330 require.True(t, (*saved)[len(*saved)-1].Terminal(), "Deploy records the terminal outcome")
331}
332
333// TestStartDeploy_LiveClaimSurvivesForegroundReconciliation pins the
334// StartDeploy/ExecuteDeploy hand-off window: while an owner is between the
335// claim and the coordinator (blocked before its GC lease), a same-key and a
336// different-key foreground mutation must not reconcile, terminalize, or
337// re-claim the live operation. The single owner then executes exactly the
338// revision it claimed.
339func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) {
340 ctx := context.Background()
341 store := newTestStore(t)
342 _, desired := seedReplaceableApp(t, ctx, store)
343 runtime := outmocks.NewMockContainerRuntime(t)
344 images := outmocks.NewMockImageResolver(t)
345 secrets := outmocks.NewMockSecretProvider(t)
346
347 // One full headless replacement, the only effects the live owner may run.
348 images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
349 runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
350 expectNetworkProvision(runtime, "app-blog", 1)
351 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
352 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
353 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
354 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
355 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
356 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
357
358 // Block ExecuteDeploy on its GC lease, before it takes the coordinator.
359 entered := make(chan struct{})
360 release := make(chan struct{})
361 barrier := outmocks.NewMockGCBarrier(t)
362 lease := outmocks.NewMockGCLease(t)
363 barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
364 close(entered)
365 <-release
366 }).Return(lease, nil).Once()
367 lease.EXPECT().Release().Return().Once()
368
369 svc := deployment.NewService(deployment.Deps{
370 State: store, Runtime: runtime, Images: images, Secrets: secrets,
371 }, zerowrap.Default()).WithGCBarrier(barrier).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
372 func(context.Context, string, string) (int, error) { return 200, nil },
373 func(context.Context, string) error { return nil },
374 ))
375
376 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
377 require.NoError(t, err)
378 require.True(t, started.Owned, "the first claim owns the operation")
379
380 done := make(chan error, 1)
381 go func() {
382 _, execErr := svc.ExecuteDeploy(ctx, started.Claim)
383 done <- execErr
384 }()
385 select {
386 case <-entered:
387 case <-time.After(5 * time.Second):
388 t.Fatal("timed out waiting for ExecuteDeploy to block before the coordinator")
389 }
390
391 // Same key while the owner is live: replay, never a second owner.
392 same, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
393 require.NoError(t, err)
394 require.False(t, same.Owned, "a same-key replay never owns the operation twice")
395 require.ErrorIs(t, same.ReplayError(), domain.ErrAppStateConflict, "the live claim was not terminalized")
396
397 // Different key while the owner is live: the unfinished-operation guard
398 // refuses, so no second claim is written.
399 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-other", Revision: "rev-0"})
400 require.ErrorIs(t, err, domain.ErrAppStateConflict)
401
402 live, err := store.LoadOperation(ctx, "blog", "op-live")
403 require.NoError(t, err)
404 require.False(t, live.Terminal(), "a live operation is never terminalized by a foreground mutation")
405 _, err = store.LoadOperation(ctx, "blog", "op-other")
406 require.ErrorIs(t, err, domain.ErrAppOperationNotFound, "no second claim was written")
407
408 close(release)
409 require.NoError(t, <-done)
410
411 final, err := store.LoadOperation(ctx, "blog", "op-live")
412 require.NoError(t, err)
413 require.True(t, final.Terminal())
414 assert.Equal(t, domain.AppOutcomeSuccess, final.Outcome)
415 assert.Equal(t, desired.Revision, final.InputRevision, "the owner executed the revision it claimed, not the stale one")
416 runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
417}
418
419// TestAbandonDeploy_SettlesClaimWithoutStarting proves a claimed but never
420// scheduled operation can be settled safely: the journal becomes terminal, the
421// in-process live marker is released, and a later claim succeeds instead of
422// being refused as a permanently unfinished operation. No runtime effect is
423// touched.
424func TestAbandonDeploy_SettlesClaimWithoutStarting(t *testing.T) {
425 ctx := context.Background()
426 store := newTestStore(t)
427 _, desired := seedReplaceableApp(t, ctx, store)
428 runtime := outmocks.NewMockContainerRuntime(t)
429 images := outmocks.NewMockImageResolver(t)
430 secrets := outmocks.NewMockSecretProvider(t)
431
432 svc := deployment.NewService(deployment.Deps{
433 State: store, Runtime: runtime, Images: images, Secrets: secrets,
434 }, zerowrap.Default())
435
436 started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-abandon", Revision: desired.Revision})
437 require.NoError(t, err)
438 require.True(t, started.Owned)
439
440 // While the claim is live, a different key is refused as unfinished.
441 _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-blocked", Revision: desired.Revision})
442 require.ErrorIs(t, err, domain.ErrAppStateConflict)
443
444 require.NoError(t, svc.AbandonDeploy(ctx, started.Claim))
445
446 settled, err := store.LoadOperation(ctx, "blog", "op-abandon")
447 require.NoError(t, err)
448 require.True(t, settled.Terminal(), "a settled claim must not remain in flight")
449 assert.Equal(t, domain.AppOutcomeFailed, settled.Outcome)
450
451 // The live marker is gone and the journal terminal, so the next claim is
452 // accepted cleanly instead of hanging on a false live operation.
453 next, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
454 require.NoError(t, err)
455 require.True(t, next.Owned, "a settled claim never blocks the next owner")
456 require.NoError(t, svc.AbandonDeploy(ctx, next.Claim))
457
458 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
459}