v3.0.0-alpha.1 6fdddb8

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	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}

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