preflight_gate_test.go RawHistoryFolder

1package deployment_test
2
3import (
4	"context"
5	"maps"
6	"testing"
7	"time"
8
9	"github.com/bnema/zerowrap"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
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// recordSavedOperations captures every journal write so a test can assert the
20// terminal preflight outcome and that no effect ran before the gate failed.
21func recordSavedOperations(state *outmocks.MockAppState) *[]domain.AppOperation {
22	saved := &[]domain.AppOperation{}
23	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
24		func(_ context.Context, op domain.AppOperation) error {
25			*saved = append(*saved, op)
26			return nil
27		})
28	return saved
29}
30
31// assertNoDeployMutation proves a gate that failed before execution never
32// touched the workload: no container is created, started, stopped, or
33// removed, no volume is created, and ACTIVE is never republished.
34func assertNoDeployMutation(t *testing.T, runtime *outmocks.MockContainerRuntime, state *outmocks.MockAppState) {
35	t.Helper()
36	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
37	runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
38	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
39	runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
40	runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything)
41	state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
42}
43
44// assertPreflightGateJournal proves a failed preflight gate left a terminal
45// failed journal whose preflight step carries the failure.
46func assertPreflightGateJournal(t *testing.T, saved []domain.AppOperation) {
47	t.Helper()
48	require.NotEmpty(t, saved, "a failed gate must journal the claim and its failure")
49	last := saved[len(saved)-1]
50	require.True(t, last.Terminal(), "a failed preflight gate must persist a terminal journal")
51	assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
52	step, ok := opStep(last, "preflight")
53	require.True(t, ok)
54	assert.Equal(t, domain.AppStepFailed, step.State)
55}
56
57// TestDeploy_MissingRequiredSecretFailsClosed proves a deploy whose required
58// secret cannot be resolved fails closed with ErrAppSecretMissing before any
59// workload mutation, and journals the terminal failure.
60func TestDeploy_MissingRequiredSecretFailsClosed(t *testing.T) {
61	ctx := context.Background()
62	state, runtime, images, secrets := mockDeps(t)
63	rev := mockRevision()
64
65	state.EXPECT().Recover(mock.Anything).Return(nil)
66	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
67	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
68	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
69	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("", assert.AnError).Once()
70	saved := recordSavedOperations(state)
71
72	svc := keyedService(t, state, runtime, images, secrets)
73	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
74
75	require.ErrorIs(t, err, domain.ErrAppSecretMissing)
76	assertNoDeployMutation(t, runtime, state)
77	assertPreflightGateJournal(t, *saved)
78}
79
80// TestDeploy_EnvSecretCollisionFailsClosed proves a stored revision whose
81// public env collides with one of the service's secret keys is refused
82// before any image resolution or workload mutation.
83func TestDeploy_EnvSecretCollisionFailsClosed(t *testing.T) {
84	ctx := context.Background()
85	state, runtime, images, secrets := mockDeps(t)
86	rev := mockRevision()
87	rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"}
88
89	state.EXPECT().Recover(mock.Anything).Return(nil)
90	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
91	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
92	saved := recordSavedOperations(state)
93
94	svc := keyedService(t, state, runtime, images, secrets)
95	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
96
97	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
98	images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
99	secrets.AssertNotCalled(t, "GetSecret", mock.Anything, mock.Anything)
100	assertNoDeployMutation(t, runtime, state)
101	assertPreflightGateJournal(t, *saved)
102}
103
104// TestDeploy_UnmanagedImageVolumeRejected proves an image that declares a
105// VOLUME with no operator mapping is refused before any workload mutation,
106// and journals the terminal failure.
107func TestDeploy_UnmanagedImageVolumeRejected(t *testing.T) {
108	ctx := context.Background()
109	state, runtime, images, secrets := mockDeps(t)
110	rev := mockRevision()
111
112	state.EXPECT().Recover(mock.Anything).Return(nil)
113	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
114	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
115	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
116	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
117	runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return([]string{"/data"}, nil).Once()
118	saved := recordSavedOperations(state)
119
120	svc := keyedService(t, state, runtime, images, secrets)
121	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
122
123	require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
124	assertNoDeployMutation(t, runtime, state)
125	assertPreflightGateJournal(t, *saved)
126}
127
128// TestDeploy_RefusesUnownedExistingVolume proves R2: a generated runtime
129// volume that already exists but is NOT recorded in the app's ownership is
130// refused fail-closed — the deploy must never implicitly adopt (and modify)
131// foreign data under a reused name.
132func TestDeploy_RefusesUnownedExistingVolume(t *testing.T) {
133	ctx := context.Background()
134	state, runtime, images, secrets := mockDeps(t)
135	svcSpec := webService()
136	svcSpec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
137	rev := testRevision("blog", svcSpec)
138	runtimeName := domain.RuntimeVolumeName("blog", "web", "data")
139
140	state.EXPECT().Recover(mock.Anything).Return(nil)
141	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
142	// Ownership records no volume, so the runtime one is foreign.
143	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
144	images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil).Once()
145	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
146	runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once()
147	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
148	runtime.EXPECT().VolumeExists(mock.Anything, runtimeName).Return(true, nil).Once()
149	saved := recordSavedOperations(state)
150
151	svc := keyedService(t, state, runtime, images, secrets)
152	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
153
154	require.ErrorIs(t, err, domain.ErrAppStateConflict)
155	require.Contains(t, err.Error(), "not owned by app")
156	assertNoDeployMutation(t, runtime, state)
157	assertPreflightGateJournal(t, *saved)
158}
159
160// TestStartDeploy_TargetedDiffPolicy proves the targeted-deploy convergence
161// rules live in the claim phase: a requested-service image change is allowed
162// and claims the operation, while app-wide or other-service divergence is
163// refused with ErrAppStateConflict before any claim is written and without
164// resolving an image or touching the runtime.
165func TestStartDeploy_TargetedDiffPolicy(t *testing.T) {
166	baseService := webService()
167	worker := webService()
168	worker.Name = "worker"
169	worker.HTTP = []domain.AppHTTPInterface{{Host: "worker.example.com", Port: 8080, TLS: domain.AppTLSAuto}}
170	base := domain.AppActive{
171		App: "blog", ConvergedRevision: "rev-1", Converged: true,
172		Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}},
173		Services: map[string]domain.AppEffectiveService{
174			"web":    {Spec: baseService},
175			"worker": {Spec: worker},
176		},
177	}
178
179	tests := []struct {
180		name     string
181		mutate   func(*domain.AppSpec)
182		wantPath string
183	}{
184		{name: "requested service image", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
185		{name: "identical spec", mutate: func(*domain.AppSpec) {}},
186		{name: "other service", wantPath: "service/worker/image", mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
187		{name: "environment", wantPath: "also changes env", mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
188		{name: "network", wantPath: "network/shared/aliases", mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
189		{name: "service added", wantPath: "service/job (added)", mutate: func(spec *domain.AppSpec) {
190			spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
191		}},
192		{name: "service removed", wantPath: "service/worker (removed)", mutate: func(spec *domain.AppSpec) {
193			spec.Services = spec.Services[:1]
194			spec.Networks = nil
195		}},
196	}
197	for _, tc := range tests {
198		t.Run(tc.name, func(t *testing.T) {
199			ctx := context.Background()
200			state, runtime, images, secrets := mockDeps(t)
201			spec := domain.AppSpec{
202				Name:     "blog",
203				Networks: append([]domain.AppSharedNetwork(nil), base.Networks...),
204				Services: []domain.AppService{baseService, worker},
205			}
206			spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
207			tc.mutate(&spec)
208			rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
209
210			state.EXPECT().Recover(mock.Anything).Return(nil)
211			state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
212			state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
213			if tc.wantPath == "" {
214				state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
215			}
216
217			svc := keyedService(t, state, runtime, images, secrets)
218			started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
219
220			if tc.wantPath != "" {
221				require.ErrorIs(t, err, domain.ErrAppStateConflict)
222				assert.Contains(t, err.Error(), tc.wantPath)
223				state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
224			} else {
225				require.NoError(t, err)
226				require.True(t, started.Owned)
227			}
228			images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
229			assertNoDeployMutation(t, runtime, state)
230		})
231	}
232}
233
234// TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection proves the shared
235// GC lease is taken by the execution phase before resource selection and
236// released only when the mutation ends. Prune can therefore never observe a
237// resource that was selected but whose protection is not yet durable.
238func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
239	ctx := context.Background()
240	state, runtime, images, secrets := mockDeps(t)
241	svcSpec := webService()
242	svcSpec.HTTP = nil
243	svcSpec.Readiness = domain.AppReadiness{}
244	rev := testRevision("blog", svcSpec)
245
246	barrier := outmocks.NewMockGCBarrier(t)
247	lease := outmocks.NewMockGCLease(t)
248	held := false
249	barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
250		held = true
251	}).Return(lease, nil).Once()
252	lease.EXPECT().Release().Run(func() {
253		held = false
254	}).Once()
255
256	state.EXPECT().Recover(mock.Anything).Return(nil)
257	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
258	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
259	// Image resolution and image-volume inspection are the resource-selection
260	// steps: they must happen inside the shared lease.
261	images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
262		require.True(t, held, "image resolution must run inside the shared GC lease")
263	}).Return(restartTestDigest, nil).Once()
264	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
265	runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
266		require.True(t, held, "image-volume inspection must run inside the shared GC lease")
267	}).Return(nil, nil).Once()
268	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
269	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
270		App: "blog", Services: map[string]domain.AppEffectiveService{},
271	}, true, nil)
272	expectNetworkProvision(runtime, "app-blog", 1)
273	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
274	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
275	runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
276	state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
277	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
278	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
279
280	svc := deployment.NewService(deployment.Deps{
281		State: state, Runtime: runtime, Images: images, Secrets: secrets,
282	}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
283		func(context.Context, string, string) (int, error) { return 200, nil },
284		func(context.Context, string) error { return nil },
285	)).WithGCBarrier(barrier)
286
287	started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
288	require.NoError(t, err)
289	require.True(t, started.Owned)
290	require.False(t, held, "the claim phase takes no shared GC lease")
291
292	result, err := svc.ExecuteDeploy(ctx, started.Claim)
293	require.NoError(t, err)
294	require.NotNil(t, result)
295	assert.Equal(t, "deployed", result.Services["web"].Result)
296	assert.False(t, held, "the shared lease must be released when the operation ends")
297}
298
299// deviceRevision returns a device-bearing revision over the standard
300// mockRevision shape with device policies authorizing it.
301func deviceRevision() domain.AppDesiredRevision {
302	rev := mockRevision()
303	rev.Spec.Services[0].Devices = []string{"test_gpu"}
304	return rev
305}
306
307func devicePolicies() map[string]domain.AppDevicePolicy {
308	return map[string]domain.AppDevicePolicy{
309		"test_gpu": {
310			Name:            "test_gpu",
311			CDI:             []string{"example.com/gpu=GPU-test-uuid"},
312			AllowedApps:     []string{"blog"},
313			AllowedServices: []string{"web"},
314		},
315	}
316}
317
318// TestDeploy_UnsupportedEngineFailsBeforeMutation proves a device-bearing
319// revision on an engine that cannot serve CDI fails in preflight with
320// ErrRuntimeUnsupported: the serving generation is never withdrawn and no
321// container is created, started, stopped, or removed.
322func TestDeploy_UnsupportedEngineFailsBeforeMutation(t *testing.T) {
323	ctx := context.Background()
324	state, runtime, images, secrets := mockDeps(t)
325	rev := deviceRevision()
326
327	state.EXPECT().Recover(mock.Anything).Return(nil)
328	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
329	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
330	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
331	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
332	runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil)
333	runtime.EXPECT().SupportsCDIDevices(mock.Anything).Return(domain.ErrRuntimeUnsupported).Once()
334	saved := recordSavedOperations(state)
335
336	svc := keyedService(t, state, runtime, images, secrets).WithDevicePolicies(devicePolicies())
337	_, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
338
339	require.ErrorIs(t, err, domain.ErrRuntimeUnsupported)
340	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
341	assertNoDeployMutation(t, runtime, state)
342	assertPreflightGateJournal(t, *saved)
343}
344
345// TestStartDeploy_TargetedImageChangeWithUnchangedAppEnv reproduces issue
346// 22: an app with a non-empty [env] whose only change is one service image
347// must accept a service-targeted deploy.
348func TestStartDeploy_TargetedImageChangeWithUnchangedAppEnv(t *testing.T) {
349	ctx := context.Background()
350	state, runtime, images, secrets := mockDeps(t)
351	web := webService()
352	env := map[string]string{"MODE": "prod"}
353	active := domain.AppActive{
354		App: "blog", ConvergedRevision: "rev-1", Converged: true,
355		Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", Spec: web}},
356	}
357	next := web
358	next.Image = "img:2"
359	rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: domain.AppSpec{Name: "blog", Env: env, Services: []domain.AppService{next}}}
360
361	state.EXPECT().Recover(mock.Anything).Return(nil)
362	state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
363	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once()
364	state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(domain.AppDesiredRevision{
365		App: "blog", Revision: "rev-1", Spec: domain.AppSpec{Name: "blog", Env: maps.Clone(env), Services: []domain.AppService{web}},
366	}, nil).Once()
367	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
368
369	svc := keyedService(t, state, runtime, images, secrets)
370	started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
371	require.NoError(t, err)
372	require.True(t, started.Owned)
373}

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