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}