v3.3.1 31f1eec

two_phase_deploy_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close