v3.1.0 d676137

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

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