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