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}