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 "github.com/bnema/gordon/internal/adapters/out/appstate"
14 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/deployment"
17)
18
19// unchangedSeed shapes the ACTIVE record and the desired revision of an app
20// whose defaults match exactly (same image, digest, spec, env, binds).
21type unchangedSeed struct {
22 binds map[int]int
23 desiredEnv map[string]string
24 inhibitedBy string
25 // mutateDesired alters the desired service after the ACTIVE one is seeded.
26 mutateDesired func(*domain.AppService)
27}
28
29// seedUnchangedApp seeds an app whose ACTIVE service runs exactly what the
30// desired revision would create, unless the seed alters one input.
31func seedUnchangedApp(t *testing.T, ctx context.Context, store *appstate.Store, seeds ...unchangedSeed) {
32 t.Helper()
33 seed := unchangedSeed{binds: map[int]int{8080: 18080}}
34 if len(seeds) > 0 {
35 seed = seeds[0]
36 }
37 spec := webService()
38 spec.Secrets = map[string]string{}
39 spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second}
40 activeRev := testRevision("blog", spec)
41 activeRev.Revision = "rev-0"
42 require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
43 require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
44 "web": {
45 Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image, ActivatedBy: "op-created",
46 Digest: restartTestDigest, Spec: activeRev.Spec.Services[0], BackendBinds: seed.binds,
47 },
48 }}))
49 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
50 seedRevision(t, ctx, store, "intent-0", "", activeRev)
51 desired := testRevision("blog", spec)
52 if seed.desiredEnv != nil {
53 desired.Spec.Env = seed.desiredEnv
54 }
55 if seed.mutateDesired != nil {
56 seed.mutateDesired(&desired.Spec.Services[0])
57 }
58 seedRevision(t, ctx, store, "intent-1", "rev-0", desired)
59 if seed.inhibitedBy != "" {
60 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
61 App: "blog", Service: "web", ContainerID: "c-old", Reason: domain.AppInhibitReplacementPending, Operation: seed.inhibitedBy,
62 }))
63 }
64}
65
66// expectReplacement wires one full replacement of c-old by c-new.
67func expectReplacement(runtime *outmocks.MockContainerRuntime) {
68 expectNetworkProvision(runtime, "app-blog", 1)
69 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
70 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
71 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
72 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
73 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
74 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
75 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
76}
77
78// unchangedDeployService wires a deploy of an app whose ACTIVE service
79// already matches the desired revision.
80func unchangedDeployService(t *testing.T, seeds ...unchangedSeed) (*deployment.Service, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, context.Context, *appstate.Store) {
81 t.Helper()
82 ctx := context.Background()
83 store := newTestStore(t)
84 runtime := outmocks.NewMockContainerRuntime(t)
85 images := outmocks.NewMockImageResolver(t)
86 secrets := outmocks.NewMockSecretProvider(t)
87 seedUnchangedApp(t, ctx, store, seeds...)
88 images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
89 runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
90 svc := deployment.NewService(deployment.Deps{
91 State: store, Runtime: runtime, Images: images, Secrets: secrets,
92 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
93 func(context.Context, string, string) (int, error) { return 200, nil },
94 func(context.Context, string) error { return nil },
95 ))
96 return svc, runtime, images, ctx, store
97}
98
99// TestDeploy_SkipsServiceAlreadyRunningTheSameImage proves a deploy whose
100// resolved digest, spec, and env match the running service keeps the
101// container and reports it unchanged instead of recreating it.
102func TestDeploy_SkipsServiceAlreadyRunningTheSameImage(t *testing.T) {
103 svc, runtime, _, ctx, store := unchangedDeployService(t)
104 runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
105 Return(&domain.Container{ID: "c-old", Status: string(domain.ContainerStatusRunning)}, nil).Once()
106
107 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-same"})
108 require.NoError(t, err)
109 assert.Equal(t, deployment.ServiceResultUnchanged, result.Services["web"].Result)
110 assert.Equal(t, "c-old", result.Services["web"].After, "the running container is kept")
111 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
112 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
113
114 op, err := store.LoadOperation(ctx, "blog", "op-same")
115 require.NoError(t, err)
116 assert.Equal(t, domain.AppOutcomeSuccess, op.Outcome)
117 step, found := opStep(op, "service.web.replace")
118 require.True(t, found)
119 assert.Equal(t, domain.AppStepSucceeded, step.State)
120 assert.Contains(t, step.Detail, "unchanged: already running docker.io/example/web:1.4.2")
121
122 active, ok, err := store.LoadActive(ctx, "blog")
123 require.NoError(t, err)
124 require.True(t, ok)
125 assert.Equal(t, "c-old", active.Services["web"].Container)
126 assert.Equal(t, map[int]int{8080: 18080}, active.Services["web"].BackendBinds, "the kept container stays routable")
127 assert.Equal(t, "op-created", active.Services["web"].ActivatedBy, "a kept container keeps its original activation")
128 assert.True(t, active.Converged, "an unchanged deploy still converges ACTIVE on the deployed revision")
129}
130
131// TestDeploy_KeepsContainerOnBackupOnlyChange proves a change limited to the
132// service backup declaration keeps the running container, and that ACTIVE
133// records the new declaration and revision so backup target resolution sees it.
134func TestDeploy_KeepsContainerOnBackupOnlyChange(t *testing.T) {
135 svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
136 binds: map[int]int{8080: 18080},
137 mutateDesired: func(s *domain.AppService) {
138 s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
139 },
140 })
141 runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
142 Return(&domain.Container{ID: "c-old", Status: string(domain.ContainerStatusRunning)}, nil).Once()
143
144 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup"})
145 require.NoError(t, err)
146 assert.Equal(t, deployment.ServiceResultUnchanged, result.Services["web"].Result)
147 assert.Equal(t, "c-old", result.Services["web"].After)
148 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
149 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
150
151 active, ok, err := store.LoadActive(ctx, "blog")
152 require.NoError(t, err)
153 require.True(t, ok)
154 web := active.Services["web"]
155 assert.Equal(t, "c-old", web.Container)
156 assert.Equal(t, "op-created", web.ActivatedBy)
157 assert.Equal(t, []string{"web-data"}, web.Spec.Backup.Volume, "ACTIVE records the new backup declaration")
158 assert.NotEqual(t, "rev-0", web.EffectiveRevision, "ACTIVE records the new effective revision")
159 assert.Equal(t, active.ConvergedRevision, web.EffectiveRevision)
160}
161
162// TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup proves a real
163// runtime change still replaces the container even when backup also changed.
164func TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup(t *testing.T) {
165 svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
166 binds: map[int]int{8080: 18080},
167 mutateDesired: func(s *domain.AppService) {
168 s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
169 s.Command = []string{"serve", "--fast"}
170 },
171 })
172 expectReplacement(runtime)
173
174 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup-cmd"})
175 require.NoError(t, err)
176 assert.Equal(t, "deployed", result.Services["web"].Result)
177 assert.Equal(t, "c-new", result.Services["web"].After)
178
179 active, ok, err := store.LoadActive(ctx, "blog")
180 require.NoError(t, err)
181 require.True(t, ok)
182 assert.Equal(t, []string{"web-data"}, active.Services["web"].Spec.Backup.Volume)
183}
184
185// TestDeploy_ReplacesSameDigestWhenTheServiceIsNotServing proves the skip
186// never keeps a container that is not fully published: missing routing
187// binds (a withdrawn generation), a recovery inhibition left by a failed
188// replacement, or a changed app env all force a replacement.
189func TestDeploy_ReplacesSameDigestWhenTheServiceIsNotServing(t *testing.T) {
190 tests := []struct {
191 name string
192 seed unchangedSeed
193 }{
194 {name: "withdrawn binds", seed: unchangedSeed{}},
195 {name: "recovery inhibited", seed: unchangedSeed{binds: map[int]int{8080: 18080}, inhibitedBy: "op-failed"}},
196 {name: "app env changed", seed: unchangedSeed{binds: map[int]int{8080: 18080}, desiredEnv: map[string]string{"MODE": "prod"}}},
197 }
198 for _, tc := range tests {
199 t.Run(tc.name, func(t *testing.T) {
200 svc, runtime, _, ctx, _ := unchangedDeployService(t, tc.seed)
201 expectReplacement(runtime)
202
203 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-replace"})
204 require.NoError(t, err)
205 assert.Equal(t, "deployed", result.Services["web"].Result)
206 assert.Equal(t, "c-new", result.Services["web"].After)
207 })
208 }
209}
210
211// TestDeploy_ReplacesWhenTheRunningContainerIsNotRunning proves the skip
212// never trusts ACTIVE alone: a stopped or missing container is replaced.
213func TestDeploy_ReplacesWhenTheRunningContainerIsNotRunning(t *testing.T) {
214 svc, runtime, _, ctx, _ := unchangedDeployService(t)
215 runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
216 Return(&domain.Container{ID: "c-old", Status: "exited"}, nil).Once()
217 expectReplacement(runtime)
218
219 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-exited"})
220 require.NoError(t, err)
221 assert.Equal(t, "deployed", result.Services["web"].Result)
222 assert.Equal(t, "c-new", result.Services["web"].After)
223}
224
225// TestDeploy_ReplacesWhenTheDigestChanged proves a new image digest behind
226// the same tag is always deployed.
227func TestDeploy_ReplacesWhenTheDigestChanged(t *testing.T) {
228 ctx := context.Background()
229 store := newTestStore(t)
230 runtime := outmocks.NewMockContainerRuntime(t)
231 images := outmocks.NewMockImageResolver(t)
232 secrets := outmocks.NewMockSecretProvider(t)
233 seedUnchangedApp(t, ctx, store)
234 newDigest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
235 images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(newDigest, nil).Once()
236 runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
237 expectNetworkProvision(runtime, "app-blog", 1)
238 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
239 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
240 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
241 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
242 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
243 runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
244 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
245 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
246
247 svc := deployment.NewService(deployment.Deps{
248 State: store, Runtime: runtime, Images: images, Secrets: secrets,
249 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
250 func(context.Context, string, string) (int, error) { return 200, nil },
251 func(context.Context, string) error { return nil },
252 ))
253
254 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-new-digest"})
255 require.NoError(t, err)
256 assert.Equal(t, "deployed", result.Services["web"].Result)
257 runtime.AssertNotCalled(t, "InspectContainer", mock.Anything, "c-old")
258
259 active, ok, err := store.LoadActive(ctx, "blog")
260 require.NoError(t, err)
261 require.True(t, ok)
262 assert.Equal(t, newDigest, active.Services["web"].Digest)
263}
264
265// TestDeploy_RecreatesWhenASecretValueChanged proves deploy applies new
266// secret values: a running container created with an old value is replaced,
267// and one created with the current value is kept.
268func TestDeploy_RecreatesWhenASecretValueChanged(t *testing.T) {
269 tests := []struct {
270 name string
271 runningEnv []string
272 wantResult string
273 }{
274 {name: "secret changed", runningEnv: []string{"DB_PASSWORD=old", "PATH=/bin"}, wantResult: "deployed"},
275 {name: "secret current", runningEnv: []string{"DB_PASSWORD=new", "PATH=/bin"}, wantResult: deployment.ServiceResultUnchanged},
276 }
277 for _, tc := range tests {
278 t.Run(tc.name, func(t *testing.T) {
279 svc, runtime, secrets, ctx := secretDeployService(t)
280 secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("new", nil)
281 runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
282 Return(&domain.Container{ID: "c-old", Status: string(domain.ContainerStatusRunning), Env: tc.runningEnv}, nil).Once()
283 if tc.wantResult == "deployed" {
284 expectReplacement(runtime)
285 }
286
287 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-secret"})
288 require.NoError(t, err)
289 assert.Equal(t, tc.wantResult, result.Services["web"].Result)
290 })
291 }
292}
293
294// secretDeployService wires an unchanged app whose service reads one secret.
295func secretDeployService(t *testing.T) (*deployment.Service, *outmocks.MockContainerRuntime, *outmocks.MockSecretProvider, context.Context) {
296 t.Helper()
297 ctx := context.Background()
298 store := newTestStore(t)
299 runtime := outmocks.NewMockContainerRuntime(t)
300 images := outmocks.NewMockImageResolver(t)
301 secrets := outmocks.NewMockSecretProvider(t)
302 spec := webService()
303 spec.Secrets = map[string]string{"DB_PASSWORD": "db-password"}
304 spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second}
305 activeRev := testRevision("blog", spec)
306 activeRev.Revision = "rev-0"
307 require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
308 require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
309 "web": {
310 Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image, ActivatedBy: "op-created",
311 Digest: restartTestDigest, Spec: activeRev.Spec.Services[0], BackendBinds: map[int]int{8080: 18080},
312 },
313 }}))
314 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
315 seedRevision(t, ctx, store, "intent-0", "", activeRev)
316 seedRevision(t, ctx, store, "intent-1", "rev-0", testRevision("blog", spec))
317 images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
318 runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
319 svc := deployment.NewService(deployment.Deps{
320 State: store, Runtime: runtime, Images: images, Secrets: secrets,
321 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
322 func(context.Context, string, string) (int, error) { return 200, nil },
323 func(context.Context, string) error { return nil },
324 ))
325 return svc, runtime, secrets, ctx
326}