v3.1.0 d676137

store_test.go RawHistoryFolder

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}`

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