1package appstate_test
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/bnema/zerowrap"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/gordon/internal/adapters/out/appstate"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func testLogger() zerowrap.Logger {
18 return zerowrap.Default()
19}
20
21func newTestStore(t *testing.T) *appstate.Store {
22 t.Helper()
23 dir := t.TempDir()
24 store, err := appstate.NewStore(dir, testLogger())
25 require.NoError(t, err)
26 t.Cleanup(func() {
27 assert.NoError(t, store.Close())
28 })
29 return store
30}
31
32func testIntent(app, rev string) domain.AppApplyIntent {
33 return testIntentAfter(app, rev, "")
34}
35
36// testIntentAfter builds an intent that expects a specific former desired
37// revision, as the apply use case does.
38func testIntentAfter(app, rev, supersedes string) domain.AppApplyIntent {
39 intent := domain.AppApplyIntent{
40 Intent: "apply-test-" + rev,
41 App: app,
42 Revision: rev,
43 Spec: domain.AppSpec{
44 Name: app,
45 Services: []domain.AppService{
46 {Name: "web", Image: "img:1", StopGrace: 10 * time.Second},
47 },
48 },
49 Reservations: []domain.AppListenerReservation{
50 {Proto: "http", Host: app + ".example.com", Service: "web", App: app},
51 },
52 CreatedAt: time.Now().UTC(),
53 }
54 intent.Supersedes = supersedes
55 return intent
56}
57
58func backendClaim(app, service, container string, port int) domain.AppListenerReservation {
59 return domain.AppListenerReservation{
60 Proto: "tcp", IP: "127.0.0.1", Port: port,
61 Service: service, App: app,
62 Owner: domain.OwnerGordonBackend, ContainerID: container,
63 }
64}
65
66// TestStore_BackendBindsRegisterRelease proves the gordon-backend claim
67// lifecycle: registration records distinguishable loopback claims,
68// same-app replacement claims coexist, release drops exactly the retired
69// container's claims.
70func TestStore_BackendBindsRegisterRelease(t *testing.T) {
71 ctx := context.Background()
72 store := newTestStore(t)
73
74 require.NoError(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{
75 backendClaim("blog", "web", "c-old", 18080),
76 }))
77 // Replacement coexists (different container, different port).
78 require.NoError(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{
79 backendClaim("blog", "web", "c-new", 18081),
80 }))
81
82 checkpoint, err := store.LoadCheckpoint(ctx)
83 require.NoError(t, err)
84 backends := 0
85 for _, res := range checkpoint.Reservations {
86 if res.Owner == domain.OwnerGordonBackend {
87 backends++
88 assert.Equal(t, "127.0.0.1", res.IP)
89 }
90 }
91 assert.Equal(t, 2, backends)
92
93 // Release drops exactly the retired container's claim.
94 require.NoError(t, store.ReleaseBackendBinds(ctx, "blog", "c-old"))
95 checkpoint, err = store.LoadCheckpoint(ctx)
96 require.NoError(t, err)
97 backends = 0
98 for _, res := range checkpoint.Reservations {
99 if res.Owner == domain.OwnerGordonBackend {
100 backends++
101 assert.Equal(t, "c-new", res.ContainerID)
102 }
103 }
104 assert.Equal(t, 1, backends)
105
106 // Unknown release is ignored.
107 require.NoError(t, store.ReleaseBackendBinds(ctx, "blog", "c-gone"))
108}
109
110// TestStore_BackendBindsConflictCrossApp proves fail-closed registration:
111// another app's identical loopback claim conflicts; same-app claims never
112// self-conflict.
113func TestStore_BackendBindsConflictCrossApp(t *testing.T) {
114 ctx := context.Background()
115 store := newTestStore(t)
116
117 require.NoError(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{
118 backendClaim("blog", "web", "c-blog", 18080),
119 }))
120 err := store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{
121 backendClaim("shop", "web", "c-shop", 18080),
122 })
123 require.ErrorIs(t, err, domain.ErrAppReservationConflict)
124
125 // Same app re-registering (restart re-inspection) succeeds.
126 require.NoError(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{
127 backendClaim("blog", "web", "c-blog", 18080),
128 }))
129}
130
131// TestStore_BackendBindsUDPProtocol proves UDP claims register alongside
132// TCP claims on the same host port without conflict (distinct sockets),
133// while a second UDP claim on that port from another app conflicts.
134func TestStore_BackendBindsUDPProtocol(t *testing.T) {
135 ctx := context.Background()
136 store := newTestStore(t)
137
138 tcp := backendClaim("blog", "web", "c-blog", 19000)
139 udp := backendClaim("blog", "web", "c-blog", 19000)
140 udp.Proto = "udp"
141 require.NoError(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{tcp, udp}))
142
143 otherUDP := backendClaim("shop", "web", "c-shop", 19000)
144 otherUDP.Proto = "udp"
145 err := store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{otherUDP})
146 require.ErrorIs(t, err, domain.ErrAppReservationConflict)
147
148 // Non-backend protocols are rejected.
149 bad := backendClaim("blog", "web", "c-blog", 19001)
150 bad.Proto = "sctp"
151 require.ErrorIs(t, store.RegisterBackendBinds(ctx, []domain.AppListenerReservation{bad}), domain.ErrAppReservationConflict)
152}
153
154func TestStore_StageCommitMaterialize(t *testing.T) {
155 ctx := context.Background()
156 store := newTestStore(t)
157
158 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-1")))
159 // Not visible before commit.
160 _, ok, err := store.LoadDesired(ctx, "blog")
161 require.NoError(t, err)
162 assert.False(t, ok)
163
164 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
165 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
166
167 desired, ok, err := store.LoadDesired(ctx, "blog")
168 require.NoError(t, err)
169 require.True(t, ok)
170 assert.Equal(t, "rev-1", desired.Revision)
171 assert.Equal(t, "pending", desired.Status)
172
173 // Idempotent replay.
174 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
175
176 // Checkpoint folded.
177 checkpoint, err := store.LoadCheckpoint(ctx)
178 require.NoError(t, err)
179 require.Len(t, checkpoint.Reservations, 1)
180 assert.Equal(t, "blog.example.com", checkpoint.Reservations[0].Host)
181
182 // Commit of a committed intent fails.
183 require.ErrorIs(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"), domain.ErrAppStateConflict)
184 // Materialize of unknown intent fails.
185 require.ErrorIs(t, store.MaterializeApply(ctx, "blog", "apply-nope"), domain.ErrAppIntentNotFound)
186}
187
188func TestStore_RecoverCompletesCommitted(t *testing.T) {
189 ctx := context.Background()
190 store := newTestStore(t)
191
192 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-1")))
193 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
194 // Simulate crash: never materialized. Recover finishes it.
195 require.NoError(t, store.Recover(ctx))
196
197 desired, ok, err := store.LoadDesired(ctx, "blog")
198 require.NoError(t, err)
199 require.True(t, ok)
200 assert.Equal(t, "rev-1", desired.Revision)
201}
202
203func TestStore_ConcurrentSamePortCrossApp(t *testing.T) {
204 ctx := context.Background()
205 store := newTestStore(t)
206
207 // Two apps claiming the same host: store accepts both intents
208 // (conflict detection lives in the use case); the checkpoint folds
209 // per-app, and ListApps stays consistent under concurrency.
210 var wg sync.WaitGroup
211 errs := make([]error, 4)
212 for i := range 4 {
213 wg.Add(1)
214 go func(i int) {
215 defer wg.Done()
216 app := []string{"a", "b", "c", "d"}[i]
217 intent := testIntent(app, "rev-1")
218 if err := store.StageApply(ctx, intent); err != nil {
219 errs[i] = err
220 return
221 }
222 if err := store.CommitApply(ctx, app, intent.Intent); err != nil {
223 errs[i] = err
224 return
225 }
226 errs[i] = store.MaterializeApply(ctx, app, intent.Intent)
227 }(i)
228 }
229 wg.Wait()
230 for _, err := range errs {
231 assert.NoError(t, err)
232 }
233 apps, err := store.ListApps(ctx)
234 require.NoError(t, err)
235 assert.Equal(t, []string{"a", "b", "c", "d"}, apps)
236}
237
238func TestStore_GarbageProtectsReferenced(t *testing.T) {
239 ctx := context.Background()
240 store := newTestStore(t)
241
242 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-1")))
243 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
244 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
245 require.NoError(t, store.StageApply(ctx, testIntentAfter("blog", "rev-2", "rev-1")))
246 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-2"))
247 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-2"))
248
249 // rev-1 is superseded but still listed; GC with no in-flight keeps
250 // retention count (2 < 8) — both survive.
251 require.NoError(t, store.CollectGarbage(ctx, "blog", nil))
252 revs, err := store.ListRevisions(ctx, "blog")
253 require.NoError(t, err)
254 assert.Contains(t, revs, "rev-1")
255 assert.Contains(t, revs, "rev-2")
256
257 // Mark rev-2 active; GC must never evict it even with in-flight noise.
258 active := domain.AppActive{
259 App: "blog", ConvergedRevision: "rev-2", Converged: true,
260 Services: map[string]domain.AppEffectiveService{
261 "web": {EffectiveRevision: "rev-2", Image: "img:1"},
262 },
263 }
264 require.NoError(t, store.SaveActive(ctx, active))
265 require.NoError(t, store.CollectGarbage(ctx, "blog", []string{"rev-99"}))
266 _, err = store.LoadRevision(ctx, "blog", "rev-2")
267 require.NoError(t, err)
268}
269
270func TestStore_CorruptAndIncompatible(t *testing.T) {
271 ctx := context.Background()
272 store := newTestStore(t)
273
274 // Corrupt desired record → scoped corrupt error, other apps unaffected.
275 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-1")))
276 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
277 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
278 require.NoError(t, appstate.CorruptDesiredForTest(store, "blog", func([]byte) []byte { return []byte("{nope") }))
279 _, _, err := store.LoadDesired(ctx, "blog")
280 require.ErrorIs(t, err, domain.ErrAppStateCorrupt)
281
282 require.NoError(t, store.StageApply(ctx, testIntent("other", "rev-1")))
283 require.NoError(t, store.CommitApply(ctx, "other", "apply-test-rev-1"))
284 require.NoError(t, store.MaterializeApply(ctx, "other", "apply-test-rev-1"))
285 _, ok, err := store.LoadDesired(ctx, "other")
286 require.NoError(t, err)
287 assert.True(t, ok)
288
289 // Incompatible version → checkpoint load refuses the database.
290 require.NoError(t, appstate.CorruptCheckpointForTest(store, func([]byte) []byte { return []byte(`{"version":99}`) }))
291 _, err = store.LoadCheckpoint(ctx)
292 require.ErrorIs(t, err, domain.ErrAppStateIncompatible)
293}
294
295func TestStore_AcceptApplyPublishesDesiredRevision(t *testing.T) {
296 ctx := context.Background()
297 store := newTestStore(t)
298
299 require.NoError(t, store.AcceptApply(ctx, testIntent("blog", "rev-1")))
300
301 desired, ok, err := store.LoadDesired(ctx, "blog")
302 require.NoError(t, err)
303 require.True(t, ok)
304 assert.Equal(t, "rev-1", desired.Revision)
305 checkpoint, err := store.LoadCheckpoint(ctx)
306 require.NoError(t, err)
307 assert.NotEmpty(t, checkpoint.Reservations)
308 intent, err := store.LoadApplyIntent(ctx, "blog", "apply-test-rev-1")
309 require.NoError(t, err)
310 assert.Equal(t, domain.AppIntentApplied, intent.State)
311
312 // A stale supersedes pointer is refused at commit and publishes nothing.
313 err = store.AcceptApply(ctx, testIntentAfter("blog", "rev-2", "rev-stale"))
314 require.Error(t, err)
315 desired, _, err = store.LoadDesired(ctx, "blog")
316 require.NoError(t, err)
317 assert.Equal(t, "rev-1", desired.Revision)
318}
319
320func TestStore_GarbageSweepsStagedOrphan(t *testing.T) {
321 ctx := context.Background()
322 store := newTestStore(t)
323
324 // Staged but never committed = orphan. GC removes it; committed stays.
325 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-orphan")))
326 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-keep")))
327 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-keep"))
328 require.NoError(t, store.CollectGarbage(ctx, "blog", nil))
329
330 _, err := store.LoadApplyIntent(ctx, "blog", "apply-test-rev-orphan")
331 require.ErrorIs(t, err, domain.ErrAppIntentNotFound)
332 _, err = store.LoadApplyIntent(ctx, "blog", "apply-test-rev-keep")
333 require.NoError(t, err)
334}
335
336func TestStore_OperationJournal(t *testing.T) {
337 ctx := context.Background()
338 store := newTestStore(t)
339
340 op := domain.AppOperation{
341 Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
342 StartedAt: time.Now().UTC(),
343 Steps: []domain.AppOperationStep{{ID: "preflight", State: "succeeded"}},
344 }
345 require.NoError(t, store.SaveOperation(ctx, op))
346 loaded, err := store.LoadOperation(ctx, "blog", "op-1")
347 require.NoError(t, err)
348 assert.Equal(t, "rev-1", loaded.InputRevision)
349 require.Len(t, loaded.Steps, 1)
350
351 _, err = store.LoadOperation(ctx, "blog", "op-nope")
352 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
353}
354
355func TestStore_IntentAndOwnership(t *testing.T) {
356 ctx := context.Background()
357 store := newTestStore(t)
358
359 intent, err := store.LoadIntent(ctx, "fresh")
360 require.NoError(t, err)
361 assert.False(t, intent.Stopped)
362
363 require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "fresh", Stopped: true, UpdatedBy: "op-1", UpdatedAt: time.Now().UTC()}))
364 intent, err = store.LoadIntent(ctx, "fresh")
365 require.NoError(t, err)
366 assert.True(t, intent.Stopped)
367
368 ownership, err := store.LoadOwnership(ctx, "fresh")
369 require.NoError(t, err)
370 assert.Equal(t, "fresh", ownership.App)
371 ownership.ID = "app-uuid-1"
372 ownership.Volumes = []domain.AppOwnedVolume{{Name: "d", Service: "db", RuntimeName: "gordon-fresh--db--vol--d", State: "attached"}}
373 require.NoError(t, store.SaveOwnership(ctx, ownership))
374 ownership, err = store.LoadOwnership(ctx, "fresh")
375 require.NoError(t, err)
376 require.Len(t, ownership.Volumes, 1)
377 assert.Equal(t, "app-uuid-1", ownership.ID)
378}
379
380func TestStore_RetentionEvictsUnreferencedBeyondDefault(t *testing.T) {
381 ctx := context.Background()
382 store := newTestStore(t)
383
384 // Apply 10 revisions; only desired stays protected. Default keeps 8
385 // unreferenced + every protected reference.
386 previous := ""
387 for i := 1; i <= 10; i++ {
388 rev := "rev-" + itoa2(i)
389 require.NoError(t, store.StageApply(ctx, testIntentAfter("blog", rev, previous)))
390 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-"+rev))
391 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-"+rev))
392 previous = rev
393 }
394 require.NoError(t, store.CollectGarbage(ctx, "blog", nil))
395 revs, err := store.ListRevisions(ctx, "blog")
396 require.NoError(t, err)
397 // Desired (rev-10) protected + 8 newest unreferenced kept.
398 assert.Len(t, revs, domain.AppDefaultRevisionRetention+1)
399 assert.Contains(t, revs, "rev-10")
400 assert.NotContains(t, revs, "rev-01")
401}
402
403func TestStore_ConfiguredRetentionOverridesDefault(t *testing.T) {
404 ctx := context.Background()
405 store := newTestStore(t)
406 store.WithRevisionRetention(2)
407 assert.Equal(t, 2, store.Retention())
408
409 previous := ""
410 for i := 1; i <= 5; i++ {
411 rev := "rev-" + itoa2(i)
412 require.NoError(t, store.StageApply(ctx, testIntentAfter("blog", rev, previous)))
413 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-"+rev))
414 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-"+rev))
415 previous = rev
416 }
417 require.NoError(t, store.CollectGarbage(ctx, "blog", nil))
418 revs, err := store.ListRevisions(ctx, "blog")
419 require.NoError(t, err)
420 // Desired (rev-05) protected + 2 newest unreferenced kept.
421 assert.Len(t, revs, 3)
422 assert.Contains(t, revs, "rev-05")
423 assert.NotContains(t, revs, "rev-01")
424
425 store.WithRevisionRetention(0)
426 assert.Equal(t, domain.AppDefaultRevisionRetention, store.Retention())
427}
428
429func itoa2(n int) string {
430 if n < 10 {
431 return "0" + string(rune('0'+n))
432 }
433 return string(rune('0'+n/10)) + string(rune('0'+n%10))
434}
435
436func TestStore_ReopenPersistsAcrossClose(t *testing.T) {
437 dir := t.TempDir()
438 store, err := appstate.NewStore(dir, testLogger())
439 require.NoError(t, err)
440 ctx := context.Background()
441 require.NoError(t, store.StageApply(ctx, testIntent("blog", "rev-1")))
442 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
443 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
444 require.NoError(t, store.Close())
445
446 reopened, err := appstate.NewStore(dir, testLogger())
447 require.NoError(t, err)
448 t.Cleanup(func() { assert.NoError(t, reopened.Close()) })
449 desired, ok, err := reopened.LoadDesired(ctx, "blog")
450 require.NoError(t, err)
451 require.True(t, ok)
452 assert.Equal(t, "rev-1", desired.Revision)
453}
454
455func TestStore_OwnershipUUIDSurvivesNameReuse(t *testing.T) {
456 ctx := context.Background()
457 store := newTestStore(t)
458
459 // First incarnation owns a retained volume under its UUID.
460 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
461 App: "blog",
462 ID: "app-uuid-1",
463 Volumes: []domain.AppOwnedVolume{
464 {Name: "d", Service: "db", RuntimeName: "gordon-blog--db--vol--d", State: "retained"},
465 },
466 }))
467
468 // Simulate remove freeing the name, then a new app reusing it.
469 // The old record stays under the old UUID; the new app gets a new
470 // UUID and must not implicitly adopt the retained volume.
471 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-uuid-2"}))
472 ownership, err := store.LoadOwnership(ctx, "blog")
473 require.NoError(t, err)
474 assert.Equal(t, "app-uuid-2", ownership.ID)
475 assert.Empty(t, ownership.Volumes)
476}
477
478func TestStore_RecoveryInhibitionsAreDurableAndGenerationScoped(t *testing.T) {
479 ctx := context.Background()
480 dir := t.TempDir()
481 store, err := appstate.NewStore(dir, testLogger())
482 require.NoError(t, err)
483
484 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
485 App: "blog", Service: "web", ContainerID: "c-old",
486 Reason: domain.AppInhibitReplacementPending, Operation: "op-1",
487 }))
488 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
489 App: "blog", Service: "worker", ContainerID: "c-worker",
490 Reason: domain.AppInhibitReplacementPending, Operation: "op-2",
491 }))
492 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
493 App: "wiki", Service: "web", ContainerID: "c-wiki",
494 Reason: domain.AppInhibitReplacementPending, Operation: "op-3",
495 }))
496
497 // Re-saving the same generation replaces it instead of duplicating.
498 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
499 App: "blog", Service: "web", ContainerID: "c-old",
500 Reason: "removed", Operation: "op-4",
501 }))
502 blog, err := store.LoadRecoveryInhibitions(ctx, "blog")
503 require.NoError(t, err)
504 require.Len(t, blog, 2)
505 byService := map[string]domain.AppRecoveryInhibition{}
506 for _, inhibition := range blog {
507 byService[inhibition.Service] = inhibition
508 }
509 assert.Equal(t, "removed", byService["web"].Reason)
510 assert.Equal(t, "op-4", byService["web"].Operation)
511 assert.Equal(t, "c-worker", byService["worker"].ContainerID)
512
513 wiki, err := store.LoadRecoveryInhibitions(ctx, "wiki")
514 require.NoError(t, err)
515 require.Len(t, wiki, 1)
516
517 // Clearing one generation never touches another.
518 require.NoError(t, store.ClearRecoveryInhibition(ctx, "blog", "web", "c-old"))
519 blog, err = store.LoadRecoveryInhibitions(ctx, "blog")
520 require.NoError(t, err)
521 require.Len(t, blog, 1)
522 assert.Equal(t, "worker", blog[0].Service)
523 require.NoError(t, store.ClearRecoveryInhibition(ctx, "blog", "web", "c-old"),
524 "clearing an absent record is not an error")
525
526 unknown, err := store.LoadRecoveryInhibitions(ctx, "absent")
527 require.NoError(t, err)
528 assert.Empty(t, unknown)
529
530 require.NoError(t, store.Close())
531
532 // Reboot: the markers outlive the process.
533 reopened, err := appstate.NewStore(dir, testLogger())
534 require.NoError(t, err)
535 t.Cleanup(func() { assert.NoError(t, reopened.Close()) })
536 blog, err = reopened.LoadRecoveryInhibitions(ctx, "blog")
537 require.NoError(t, err)
538 require.Len(t, blog, 1)
539 assert.Equal(t, "c-worker", blog[0].ContainerID)
540
541 cancelled, cancel := context.WithCancel(ctx)
542 cancel()
543 if _, err := reopened.LoadRecoveryInhibitions(cancelled, "blog"); err == nil {
544 t.Fatal("a cancelled context must fail the read")
545 }
546 require.Error(t, reopened.SaveRecoveryInhibition(cancelled, domain.AppRecoveryInhibition{App: "blog"}))
547 require.Error(t, reopened.ClearRecoveryInhibition(cancelled, "blog", "worker", "c-worker"))
548}
549
550func TestStore_DesiredDevicesRoundTrip(t *testing.T) {
551 ctx := context.Background()
552 store := newTestStore(t)
553
554 intent := testIntent("blog", "rev-1")
555 intent.Spec.Services[0].Devices = []string{"test_gpu"}
556 require.NoError(t, store.StageApply(ctx, intent))
557 require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
558 require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
559
560 desired, ok, err := store.LoadDesired(ctx, "blog")
561 require.NoError(t, err)
562 require.True(t, ok)
563 require.Len(t, desired.Spec.Services, 1)
564 assert.Equal(t, []string{"test_gpu"}, desired.Spec.Services[0].Devices, "logical names persist in revisions")
565}
566
567// TestStore_DesiredWithoutDevicesLoads proves a desired record written by a
568// Gordon release that predates service devices still loads: the absent JSON
569// key decodes to a nil slice, never an error or a phantom device.
570func TestStore_DesiredWithoutDevicesLoads(t *testing.T) {
571 ctx := context.Background()
572 store := newTestStore(t)
573
574 require.NoError(t, appstate.SeedDesiredRecordForTest(store, "blog", []byte(legacyDesiredWithoutDevices)))
575
576 desired, ok, err := store.LoadDesired(ctx, "blog")
577 require.NoError(t, err)
578 require.True(t, ok)
579 require.Len(t, desired.Spec.Services, 1)
580 assert.Nil(t, desired.Spec.Services[0].Devices, "a missing devices key must not become a phantom device")
581}
582
583// legacyDesiredWithoutDevices is a verbatim state.db desired record from a
584// release predating service devices. Unlike current serializer output, its
585// service object carries no "Devices" key at all rather than a null one.
586const legacyDesiredWithoutDevices = `{
587 "revision": "rev-1",
588 "app": "blog",
589 "accepted_at": "2026-01-01T00:00:00Z",
590 "source_sha256": "",
591 "spec": {
592 "Name": "blog",
593 "Env": null,
594 "Services": [
595 {
596 "Name": "web",
597 "Image": "img:1",
598 "Command": null,
599 "StopGrace": 10000000000,
600 "Readiness": {"Type": "", "Path": "", "Contains": "", "Port": 0, "Timeout": 0},
601 "HTTP": null,
602 "TCP": null,
603 "UDP": null,
604 "Secrets": null,
605 "Volumes": null,
606 "Binds": null,
607 "Databases": null,
608 "Backup": {"Postgres": null, "Volume": null}
609 }
610 ],
611 "Networks": null
612 },
613 "reservations": [{"proto": "http", "host": "blog.example.com", "service": "web", "app": "blog"}],
614 "secrets_required": null,
615 "secrets_env": {},
616 "status": "pending"
617}`