v3.0.0-alpha.1 6fdddb8

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

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