v3.3.2 9697f9d

service_test.go RawHistoryFolder

1package apps_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/boundaries/out"
14	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15	"github.com/bnema/gordon/internal/domain"
16	"github.com/bnema/gordon/internal/usecase/apps"
17)
18
19func testSpec(name string) domain.AppSpec {
20	return domain.AppSpec{
21		Name: name,
22		Services: []domain.AppService{
23			{
24				Name: "web", Image: "img:1", StopGrace: 10 * time.Second,
25				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
26				HTTP:      []domain.AppHTTPInterface{{Host: name + ".example.com", Port: 8080, TLS: "auto"}},
27			},
28		},
29	}
30}
31
32// applySuccess wires a full accept path on the store mock.
33func applySuccess(store *outmocks.MockAppState, spec domain.AppSpec) {
34	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
35	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
36	store.EXPECT().LoadDesired(mock.Anything, spec.Name).Return(domain.AppDesiredRevision{}, false, nil).Once()
37	store.EXPECT().LoadActive(mock.Anything, spec.Name).Return(domain.AppActive{}, false, nil).Once()
38	store.EXPECT().AcceptApply(mock.Anything, mock.Anything).Return(nil).Once()
39}
40
41// recordingBarrier records shared-lease acquisition for apply tests.
42type recordingBarrier struct {
43	shared   int
44	released int
45	err      error
46}
47
48func (b *recordingBarrier) AcquireShared(context.Context) (out.GCLease, error) {
49	if b.err != nil {
50		return nil, b.err
51	}
52	b.shared++
53	return &recordingLease{barrier: b}, nil
54}
55
56func (b *recordingBarrier) AcquireExclusive(context.Context) (out.GCLease, error) {
57	return nil, assert.AnError
58}
59
60type recordingLease struct{ barrier *recordingBarrier }
61
62func (l *recordingLease) Release() { l.barrier.released++ }
63
64func TestApply_RejectsImageOutsideRegistryAllowlistBeforeStoreAccess(t *testing.T) {
65	store := outmocks.NewMockAppState(t)
66	spec := testSpec("blog")
67	spec.Services[0].Image = "registry.example.com/team/app:1"
68	svc := apps.NewService(store, zerowrap.Default()).WithImagePolicy(domain.ImageSourcePolicy{})
69
70	_, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
71
72	assert.ErrorIs(t, err, domain.ErrAppImageNotAllowed)
73}
74
75func TestApply_RequireDigestIncludesInstallationRegistry(t *testing.T) {
76	store := outmocks.NewMockAppState(t)
77	spec := testSpec("blog")
78	spec.Services[0].Image = "gordon.example.com/team/app:1"
79	svc := apps.NewService(store, zerowrap.Default()).WithImagePolicy(domain.ImageSourcePolicy{
80		InstallationRegistry: "gordon.example.com",
81		RequireDigest:        true,
82	})
83
84	_, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
85	assert.ErrorIs(t, err, domain.ErrAppImageNotAllowed)
86}
87
88// TestApply_HoldsSharedGCLease proves apply holds the shared GC lease for
89// its whole mutation, so prune cannot snapshot a half-published apply.
90func TestApply_HoldsSharedGCLease(t *testing.T) {
91	ctx := context.Background()
92	store := outmocks.NewMockAppState(t)
93	spec := testSpec("blog")
94	applySuccess(store, spec)
95	barrier := &recordingBarrier{}
96	svc := apps.NewService(store, zerowrap.Default()).WithGCBarrier(barrier)
97
98	_, _, err := svc.Apply(ctx, spec, []byte("manifest"), false)
99
100	require.NoError(t, err)
101	assert.Equal(t, 1, barrier.shared, "apply must take the shared GC lease")
102	assert.Equal(t, 1, barrier.released, "apply must release the shared GC lease")
103}
104
105// TestApply_FailsClosedWhenGCBarrierRefuses proves an apply never publishes
106// desired state while it cannot hold the shared lease (e.g. prune holds the
107// exclusive lease).
108func TestApply_FailsClosedWhenGCBarrierRefuses(t *testing.T) {
109	ctx := context.Background()
110	barrier := &recordingBarrier{err: assert.AnError}
111	svc := apps.NewService(outmocks.NewMockAppState(t), zerowrap.Default()).WithGCBarrier(barrier)
112
113	_, _, err := svc.Apply(ctx, testSpec("blog"), []byte("manifest"), false)
114
115	require.Error(t, err)
116	assert.Equal(t, 0, barrier.shared)
117}
118
119func TestApply_AcceptsAndPersists(t *testing.T) {
120	ctx := context.Background()
121	store := outmocks.NewMockAppState(t)
122	svc := apps.NewService(store, zerowrap.Default())
123	spec := testSpec("blog")
124
125	applySuccess(store, spec)
126	result, dry, err := svc.Apply(ctx, spec, []byte("manifest"), false)
127	require.NoError(t, err)
128	assert.Nil(t, dry)
129	require.NotNil(t, result)
130	assert.True(t, result.Pending)
131	assert.False(t, result.Noop)
132	assert.NotEmpty(t, result.ResultingRevision)
133	assert.NotEmpty(t, result.IntentID)
134}
135
136func TestApply_NoopIsIdempotent(t *testing.T) {
137	ctx := context.Background()
138	store := outmocks.NewMockAppState(t)
139	svc := apps.NewService(store, zerowrap.Default())
140	spec := testSpec("blog")
141
142	// First apply persists.
143	applySuccess(store, spec)
144	first, _, err := svc.Apply(ctx, spec, []byte("manifest"), false)
145	require.NoError(t, err)
146
147	// Second apply with identical content is a no-op: no writes.
148	desired := domain.AppDesiredRevision{Revision: first.ResultingRevision, App: "blog", Spec: spec}
149	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
150	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
151	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
152	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
153	second, _, err := svc.Apply(ctx, spec, []byte("manifest"), false)
154	require.NoError(t, err)
155	assert.True(t, second.Noop)
156	assert.Equal(t, first.ResultingRevision, second.ResultingRevision)
157}
158
159func TestApply_DryRunWritesNothing(t *testing.T) {
160	ctx := context.Background()
161	store := outmocks.NewMockAppState(t)
162	svc := apps.NewService(store, zerowrap.Default())
163	spec := testSpec("blog")
164
165	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
166	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
167	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
168	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
169	result, dry, err := svc.Apply(ctx, spec, []byte("manifest"), true)
170	require.NoError(t, err)
171	assert.Nil(t, result)
172	require.NotNil(t, dry)
173	assert.True(t, dry.Valid)
174	store.AssertNotCalled(t, "AcceptApply", mock.Anything, mock.Anything)
175}
176
177func TestApply_ImageOnlyDiffPreservesActiveNetworks(t *testing.T) {
178	ctx := context.Background()
179	store := outmocks.NewMockAppState(t)
180	svc := apps.NewService(store, zerowrap.Default())
181	spec := testSpec("blog")
182	spec.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
183	activeService := spec.Services[0]
184	activeService.Image = "img:1"
185	spec.Services[0].Image = "img:2"
186
187	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
188	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
189	previousDesired := spec
190	previousDesired.Services = append([]domain.AppService(nil), spec.Services...)
191	previousDesired.Services[0].Image = "img:1"
192	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: previousDesired}, true, nil).Once()
193	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
194		App: "blog", Networks: spec.Networks,
195		Services: map[string]domain.AppEffectiveService{"web": {Spec: activeService}},
196	}, true, nil).Once()
197
198	_, dry, err := svc.Apply(ctx, spec, []byte("manifest"), true)
199	require.NoError(t, err)
200	require.NotNil(t, dry)
201	assert.Equal(t, []string{"service/web/image"}, dry.Diff.Changed)
202}
203
204func TestApply_RejectsCrossAppConflict(t *testing.T) {
205	ctx := context.Background()
206	store := outmocks.NewMockAppState(t)
207	svc := apps.NewService(store, zerowrap.Default())
208
209	applySuccess(store, testSpec("blog"))
210	_, _, err := svc.Apply(ctx, testSpec("blog"), []byte("m"), false)
211	require.NoError(t, err)
212
213	// Same host, different app → conflict before any write.
214	other := testSpec("shop")
215	other.Services[0].HTTP[0].Host = "blog.example.com"
216	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
217	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{
218		Reservations: []domain.AppListenerReservation{{Proto: "http", Host: "blog.example.com", Service: "web", App: "blog"}},
219	}, nil).Once()
220	store.EXPECT().LoadDesired(mock.Anything, "shop").Return(domain.AppDesiredRevision{}, false, nil).Once()
221	store.EXPECT().LoadActive(mock.Anything, "shop").Return(domain.AppActive{}, false, nil).Once()
222	_, _, err = svc.Apply(ctx, other, []byte("m"), false)
223	require.ErrorIs(t, err, domain.ErrAppReservationConflict)
224	store.AssertNotCalled(t, "AcceptApply", mock.Anything, mock.MatchedBy(func(intent domain.AppApplyIntent) bool {
225		return intent.App == "shop"
226	}))
227}
228
229func TestApply_RejectsDuplicateClaimInCandidate(t *testing.T) {
230	ctx := context.Background()
231	store := outmocks.NewMockAppState(t)
232	svc := apps.NewService(store, zerowrap.Default())
233
234	spec := testSpec("blog")
235	dup := spec.Services[0]
236	dup.Name = "web2"
237	spec.Services = append(spec.Services, dup)
238
239	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
240	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
241	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
242	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
243	_, _, err := svc.Apply(ctx, spec, []byte("m"), false)
244	require.ErrorIs(t, err, domain.ErrAppReservationConflict)
245}
246
247func TestApply_RejectsOverlappingWildcardClaimInCandidate(t *testing.T) {
248	ctx := context.Background()
249	store := outmocks.NewMockAppState(t)
250	svc := apps.NewService(store, zerowrap.Default())
251
252	spec := domain.AppSpec{
253		Name: "tcp-overlap",
254		Services: []domain.AppService{
255			{
256				Name: "one", Image: "img:1", StopGrace: 10 * time.Second,
257				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
258				TCP:       []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "0.0.0.0:19090"}},
259			},
260			{
261				Name: "two", Image: "img:1", StopGrace: 10 * time.Second,
262				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
263				TCP:       []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "127.0.0.1:19090"}},
264			},
265		},
266	}
267
268	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
269	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
270	store.EXPECT().LoadDesired(mock.Anything, spec.Name).Return(domain.AppDesiredRevision{}, false, nil).Once()
271	store.EXPECT().LoadActive(mock.Anything, spec.Name).Return(domain.AppActive{}, false, nil).Once()
272	_, _, err := svc.Apply(ctx, spec, []byte("m"), false)
273	require.ErrorIs(t, err, domain.ErrAppReservationConflict)
274	store.AssertNotCalled(t, "AcceptApply", mock.Anything, mock.Anything)
275}
276
277func TestApply_InvalidSpecFailsBeforeStore(t *testing.T) {
278	ctx := context.Background()
279	store := outmocks.NewMockAppState(t)
280	svc := apps.NewService(store, zerowrap.Default())
281
282	spec := testSpec("Bad!")
283	_, _, err := svc.Apply(ctx, spec, []byte("m"), false)
284	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
285	store.AssertNotCalled(t, "Recover", mock.Anything)
286}
287
288// TestApply_RejectsPublishMismatchBeforeStore proves an L4 publish that does
289// not match the entrypoint listener is refused before any persistence or
290// workload effect, so the runtime can never widen a narrower declaration.
291func TestApply_RejectsPublishMismatchBeforeStore(t *testing.T) {
292	ctx := context.Background()
293	store := outmocks.NewMockAppState(t)
294	svc := apps.NewService(store, zerowrap.Default()).WithEntrypoints(map[string]domain.EntryPointListener{
295		"tcp": {Address: "0.0.0.0:25432", Protocol: domain.EntryPointProtocolTCP},
296	})
297
298	spec := testSpec("game")
299	spec.Services[0].HTTP = nil
300	spec.Services[0].TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 25432, Publish: "127.0.0.1:25432"}}
301
302	_, _, err := svc.Apply(ctx, spec, []byte("m"), false)
303	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
304	store.AssertNotCalled(t, "Recover", mock.Anything)
305}
306
307// TestApply_RejectsUnknownEntrypointBeforeStore proves an L4 interface that
308// references a missing entrypoint fails closed at apply time.
309func TestApply_RejectsUnknownEntrypointBeforeStore(t *testing.T) {
310	ctx := context.Background()
311	store := outmocks.NewMockAppState(t)
312	svc := apps.NewService(store, zerowrap.Default()).WithEntrypoints(map[string]domain.EntryPointListener{})
313
314	spec := testSpec("game")
315	spec.Services[0].HTTP = nil
316	spec.Services[0].TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 25432, Publish: "0.0.0.0:25432"}}
317
318	_, _, err := svc.Apply(ctx, spec, []byte("m"), false)
319	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
320	store.AssertNotCalled(t, "Recover", mock.Anything)
321}
322
323func TestApply_SamePortDifferentProtoCoexists(t *testing.T) {
324	ctx := context.Background()
325	store := outmocks.NewMockAppState(t)
326	svc := apps.NewService(store, zerowrap.Default())
327
328	tcpApp := domain.AppSpec{
329		Name: "tcpapp",
330		Services: []domain.AppService{
331			{
332				Name: "s", Image: "img:1", StopGrace: 10 * time.Second,
333				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
334				TCP:       []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "0.0.0.0:9000"}},
335			},
336		},
337	}
338	applySuccess(store, tcpApp)
339	_, _, err := svc.Apply(ctx, tcpApp, []byte("m"), false)
340	require.NoError(t, err)
341
342	udpApp := domain.AppSpec{
343		Name: "udpapp",
344		Services: []domain.AppService{
345			{
346				Name: "s", Image: "img:1", StopGrace: 10 * time.Second,
347				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
348				UDP:       []domain.AppUDPInterface{{Entrypoint: "udp", Port: 9000, Publish: "0.0.0.0:9000"}},
349			},
350		},
351	}
352	applySuccess(store, udpApp)
353	_, _, err = svc.Apply(ctx, udpApp, []byte("m"), false)
354	require.NoError(t, err)
355}
356
357func TestReservationsFor_DelegatesToDomain(t *testing.T) {
358	spec := domain.AppSpec{
359		Name: "blog",
360		Services: []domain.AppService{
361			{
362				Name: "web", Image: "img:1",
363				HTTP: []domain.AppHTTPInterface{{Host: "b.example.com", Port: 8080, TLS: "auto"}},
364				TCP:  []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}},
365			},
366		},
367	}
368	// Apply derives reservations through the domain owner; derivation
369	// policy itself is covered in the domain package.
370	reservations := domain.ReservationsFor(spec)
371	require.Len(t, reservations, 2)
372	assert.Equal(t, "http", reservations[0].Proto)
373	assert.Equal(t, "tcp", reservations[1].Proto)
374	assert.Equal(t, "dual", reservations[1].IP)
375}
376
377// TestApply_ImageOnlyDiffWithAppEnv reproduces issue 22: with a non-empty
378// [env], an image-only change must not report the app env as changed.
379func TestApply_ImageOnlyDiffWithAppEnv(t *testing.T) {
380	ctx := context.Background()
381	store := outmocks.NewMockAppState(t)
382	svc := apps.NewService(store, zerowrap.Default())
383	spec := testSpec("blog")
384	spec.Env = map[string]string{"MODE": "prod"}
385	previous := testSpec("blog")
386	previous.Env = map[string]string{"MODE": "prod"}
387	spec.Services[0].Image = "img:2"
388
389	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
390	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
391	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: previous}, true, nil).Once()
392	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
393		App:      "blog",
394		Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", Spec: previous.Services[0]}},
395	}, true, nil).Once()
396	store.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: previous}, nil).Once()
397
398	_, dry, err := svc.Apply(ctx, spec, []byte("manifest"), true)
399	require.NoError(t, err)
400	require.NotNil(t, dry)
401	assert.Equal(t, []string{"service/web/image"}, dry.Diff.Changed)
402}

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