v3.0.0-alpha.1 6fdddb8

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

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