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}