main bac21c6

operation_claim_test.go RawHistoryFolder

1package deployment_test
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/zerowrap"
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/mock"
10	"github.com/stretchr/testify/require"
11
12	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13	"github.com/bnema/gordon/internal/domain"
14	"github.com/bnema/gordon/internal/usecase/deployment"
15)
16
17func keyedService(
18	t *testing.T,
19	state *outmocks.MockAppState,
20	runtime *outmocks.MockContainerRuntime,
21	images *outmocks.MockImageResolver,
22	secrets *outmocks.MockSecretProvider,
23) *deployment.Service {
24	t.Helper()
25	return deployment.NewService(deployment.Deps{
26		State: state, Runtime: runtime, Images: images, Secrets: secrets,
27	}, zerowrap.Default())
28}
29
30// TestDeploy_ClaimPersistsRequestIdentity proves the first claim writes the
31// journal with the immutable request identity of the caller's inputs, so a
32// reused key for another request can be rejected later.
33func TestDeploy_ClaimPersistsRequestIdentity(t *testing.T) {
34	ctx := context.Background()
35	state, runtime, images, secrets := mockDeps(t)
36	rev := mockRevision()
37
38	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
39	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
40	state.EXPECT().ClaimOperation(mock.Anything, mock.MatchedBy(func(op domain.AppOperation) bool {
41		return op.Op == "key-1" &&
42			op.Kind == "deploy" &&
43			op.Request == domain.AppOperationRequestFor("deploy", "blog", "", "") &&
44			!op.Terminal()
45	})).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
46		return op, true, nil
47	}).Once()
48	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once()
49	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once()
50	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
51
52	svc := keyedService(t, state, runtime, images, secrets)
53	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "key-1"})
54
55	require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
56	require.NotNil(t, result)
57	assert.Equal(t, "key-1", result.Op)
58}
59
60// TestDeploy_TerminalKeyReplayExecutesNoEffect proves a repeat of a
61// finished request returns its stored journal without touching a workload.
62func TestDeploy_TerminalKeyReplayExecutesNoEffect(t *testing.T) {
63	ctx := context.Background()
64	state, runtime, images, secrets := mockDeps(t)
65	rev := mockRevision()
66
67	stored := domain.AppOperation{
68		Op: "key-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
69		Request: domain.AppOperationRequestFor("deploy", "blog", "", ""),
70		Outcome: domain.AppOutcomeSuccess,
71		Steps:   []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepSucceeded}},
72	}
73	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
74	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
75	state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(stored, false, nil).Once()
76
77	svc := keyedService(t, state, runtime, images, secrets)
78	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "key-1"})
79
80	require.NoError(t, err)
81	require.NotNil(t, result)
82	assert.Equal(t, "key-1", result.Op)
83	assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome)
84	state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
85	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
86}
87
88// TestDeploy_FailedTerminalKeyReplayConflicts proves a failed response does
89// not become successful merely because its idempotency key is replayed.
90func TestDeploy_FailedTerminalKeyReplayConflicts(t *testing.T) {
91	for _, outcome := range []string{domain.AppOutcomeFailed, domain.AppOutcomePartial} {
92		t.Run(outcome, func(t *testing.T) {
93			ctx := context.Background()
94			state, runtime, images, secrets := mockDeps(t)
95			rev := mockRevision()
96
97			failed := domain.AppOperation{
98				Op: "key-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
99				Request: domain.AppOperationRequestFor("deploy", "blog", "", ""),
100				Outcome: outcome,
101				Steps:   []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepFailed, Error: "image unavailable"}},
102			}
103			state.EXPECT().Recover(mock.Anything).Return(nil).Once()
104			state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
105			state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(failed, false, nil).Once()
106
107			svc := keyedService(t, state, runtime, images, secrets)
108			result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "key-1"})
109
110			require.ErrorIs(t, err, domain.ErrAppStateConflict)
111			require.NotNil(t, result)
112			assert.Equal(t, outcome, result.Outcome)
113			runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
114		})
115	}
116}
117
118// TestDeploy_PendingKeyReplayConflicts proves an in-flight (or interrupted)
119// claim is never re-executed: the caller receives the journal and a
120// conflict instead of a second deployment.
121func TestDeploy_PendingKeyReplayConflicts(t *testing.T) {
122	ctx := context.Background()
123	state, runtime, images, secrets := mockDeps(t)
124	rev := mockRevision()
125
126	pending := domain.AppOperation{
127		Op: "key-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
128		Request: domain.AppOperationRequestFor("deploy", "blog", "", ""),
129		Steps:   []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
130	}
131	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
132	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
133	state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(pending, false, nil).Once()
134
135	svc := keyedService(t, state, runtime, images, secrets)
136	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "key-1"})
137
138	require.ErrorIs(t, err, domain.ErrAppStateConflict)
139	require.NotNil(t, result, "the interrupted journal is returned for inspection")
140	assert.Empty(t, result.Outcome)
141	state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
142	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
143}
144
145// TestDeploy_MismatchedKeyIsRejected proves one key cannot answer a
146// different request.
147func TestDeploy_MismatchedKeyIsRejected(t *testing.T) {
148	ctx := context.Background()
149	state, runtime, images, secrets := mockDeps(t)
150	rev := mockRevision()
151
152	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
153	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
154	state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).
155		Return(domain.AppOperation{}, false, domain.ErrAppStateConflict).Once()
156
157	svc := keyedService(t, state, runtime, images, secrets)
158	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "key-1"})
159
160	require.ErrorIs(t, err, domain.ErrAppStateConflict)
161	assert.Nil(t, result)
162	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
163}
164
165// TestDeploy_UnknownAppIsNotFound proves deploying a name with no app
166// identity reports the missing app and claims nothing.
167func TestDeploy_UnknownAppIsNotFound(t *testing.T) {
168	ctx := context.Background()
169	state, runtime, images, secrets := mockDeps(t)
170
171	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
172	state.EXPECT().LoadDesired(mock.Anything, "ghost").Return(domain.AppDesiredRevision{}, false, nil).Once()
173	state.EXPECT().AppExists(mock.Anything, "ghost").Return(false, nil).Once()
174
175	svc := keyedService(t, state, runtime, images, secrets)
176	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "ghost", Op: "key-1"})
177
178	require.ErrorIs(t, err, domain.ErrAppNotFound)
179	assert.Nil(t, result)
180	state.AssertNotCalled(t, "ClaimOperation", mock.Anything, mock.Anything)
181	state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
182}
183
184// TestDeploy_UnknownExplicitRevisionOnKnownApp proves a missing explicit
185// revision of a known app stays a revision error, not an app error.
186func TestDeploy_UnknownExplicitRevisionOnKnownApp(t *testing.T) {
187	ctx := context.Background()
188	state, runtime, images, secrets := mockDeps(t)
189
190	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
191	state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-404").
192		Return(domain.AppDesiredRevision{}, domain.ErrAppRevisionNotFound).Once()
193	state.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
194
195	svc := keyedService(t, state, runtime, images, secrets)
196	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-404", Op: "key-1"})
197
198	require.ErrorIs(t, err, domain.ErrAppRevisionNotFound)
199	state.AssertNotCalled(t, "ClaimOperation", mock.Anything, mock.Anything)
200}
201
202// TestRemove_TerminalKeyReplaySkipsRuntime proves a repeated keyed removal
203// replays its stored result without touching the runtime again.
204func TestRemove_TerminalKeyReplaySkipsRuntime(t *testing.T) {
205	ctx := context.Background()
206	state, runtime, images, secrets := mockDeps(t)
207
208	stored := domain.AppOperation{
209		Op: "key-1", Kind: "remove", App: "blog",
210		Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
211		Outcome: domain.AppOutcomeSuccess,
212	}
213	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
214	state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(stored, false, nil).Once()
215
216	svc := keyedService(t, state, runtime, images, secrets)
217	result, err := svc.Remove(ctx, "blog", "key-1")
218
219	require.NoError(t, err)
220	require.NotNil(t, result)
221	assert.Equal(t, "key-1", result.Op)
222	assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome)
223	state.AssertNotCalled(t, "SaveIntent", mock.Anything, mock.Anything)
224	state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything)
225	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
226}
227
228// TestStop_PendingKeyReplayConflicts proves a stop replay of an unfinished
229// operation never re-stops a workload.
230func TestStop_PendingKeyReplayConflicts(t *testing.T) {
231	ctx := context.Background()
232	state, runtime, images, secrets := mockDeps(t)
233
234	pending := domain.AppOperation{
235		Op: "key-1", Kind: "stop", App: "blog",
236		Request: domain.AppOperationRequestFor("stop", "blog", "", ""),
237		Steps:   []domain.AppOperationStep{{ID: "intent.stopped", State: domain.AppStepPending}},
238	}
239	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
240	state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(pending, false, nil).Once()
241
242	svc := keyedService(t, state, runtime, images, secrets)
243	result, err := svc.Stop(ctx, "blog", "key-1")
244
245	require.ErrorIs(t, err, domain.ErrAppStateConflict)
246	require.NotNil(t, result)
247	state.AssertNotCalled(t, "SaveIntent", mock.Anything, mock.Anything)
248	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
249}
250
251// TestRestart_ClaimCarriesServiceIdentity proves the restart claim records
252// the targeted service, so a key reused for another service conflicts.
253func TestRestart_ClaimCarriesServiceIdentity(t *testing.T) {
254	ctx := context.Background()
255	state, runtime, images, secrets := mockDeps(t)
256
257	active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
258		"web": {Container: "c-1", EffectiveRevision: "rev-1", Spec: headlessSpec()},
259	}}
260	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
261	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once()
262	state.EXPECT().ClaimOperation(mock.Anything, mock.MatchedBy(func(op domain.AppOperation) bool {
263		return op.Request == domain.AppOperationRequestFor("restart", "blog", "", "web")
264	})).Return(domain.AppOperation{}, false, domain.ErrAppStateConflict).Once()
265
266	svc := keyedService(t, state, runtime, images, secrets)
267	_, err := svc.Restart(ctx, "blog", "web", "key-1")
268
269	require.ErrorIs(t, err, domain.ErrAppStateConflict)
270	runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything, mock.Anything)
271}

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