v3.3.1 31f1eec

protection_test.go RawHistoryFolder

1package appstate_test
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"testing"
8	"time"
9
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 hasRoot(t *testing.T, snapshot *domain.PruneProtectionSnapshot, kind domain.ProtectionRootKind, ref string) bool {
18	t.Helper()
19	for _, root := range snapshot.Roots {
20		if root.Kind == kind && root.Ref == ref {
21			return true
22		}
23	}
24	return false
25}
26
27func tagRefKey(t *testing.T, repository, tag string) string {
28	t.Helper()
29	ref, err := domain.NewRegistryTagRef(repository, tag)
30	require.NoError(t, err)
31	return ref.Key()
32}
33
34// TestStore_ProtectionSnapshotCoversEveryRoot proves one snapshot
35// transaction collects desired, active, inhibition, apply, operation,
36// and ownership facts together.
37func TestStore_ProtectionSnapshotCoversEveryRoot(t *testing.T) {
38	ctx := context.Background()
39	store := newTestStore(t)
40
41	desired := testIntent("shop", "rev-1")
42	desired.Spec.Services[0].Image = "registry.example/shop:1"
43	require.NoError(t, store.StageApply(ctx, desired))
44	require.NoError(t, store.CommitApply(ctx, "shop", desired.Intent))
45	require.NoError(t, store.MaterializeApply(ctx, "shop", desired.Intent))
46
47	require.NoError(t, store.SaveActive(ctx, domain.AppActive{
48		App: "shop",
49		Services: map[string]domain.AppEffectiveService{
50			"web": {
51				EffectiveRevision: "rev-1",
52				Image:             "registry.example/shop:1",
53				Digest:            "sha256:" + strings.Repeat("ab", 32),
54				Container:         "c-live",
55			},
56		},
57	}))
58	require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
59		App: "shop", Service: "web", ContainerID: "c-inhibited",
60		Reason: domain.AppInhibitReplacementPending,
61	}))
62
63	// A staged intent may still materialize.
64	staged := testIntent("shop", "rev-2")
65	staged.Intent = "apply-staged"
66	staged.Spec.Services[0].Image = "registry.example/shop:2"
67	require.NoError(t, store.StageApply(ctx, staged))
68
69	// An unfinished operation pins its own image.
70	require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
71		Op: "op-open", Kind: "deploy", App: "shop",
72		Steps: []domain.AppOperationStep{
73			{ID: "preflight", State: domain.AppStepSucceeded},
74			{ID: "service.web.replace", State: domain.AppStepPending,
75				Digest: "sha256:" + strings.Repeat("cd", 32),
76				Image:  "registry.example/shop:3"},
77		},
78	}))
79
80	// A finished operation pins nothing.
81	require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
82		Op: "op-done", Kind: "deploy", App: "shop", Outcome: domain.AppOutcomeSuccess,
83		Steps: []domain.AppOperationStep{
84			{ID: "service.web.replace", State: domain.AppStepSucceeded,
85				Digest: "sha256:" + strings.Repeat("ef", 32),
86				Image:  "registry.example/shop:4"},
87		},
88	}))
89
90	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
91		App: "shop", ID: "app-uuid-shop",
92		Volumes: []domain.AppOwnedVolume{
93			{Name: "data", Service: "web", RuntimeName: "gordon-shop--web--vol--data", State: domain.AppResourceAttached},
94		},
95	}))
96
97	snapshot, err := store.ProtectionSnapshot(ctx)
98	require.NoError(t, err)
99	require.True(t, snapshot.Complete(), "snapshot gaps: %+v", snapshot.Gaps)
100
101	assert.True(t, hasRoot(t, snapshot, domain.ProtectionDesiredRevision, tagRefKey(t, "registry.example/shop", "1")),
102		"desired revision root missing")
103	assert.True(t, hasRoot(t, snapshot, domain.ProtectionActiveService, tagRefKey(t, "registry.example/shop", "1")),
104		"active service root missing")
105	assert.True(t, hasRoot(t, snapshot, domain.ProtectionActiveService, "sha256:"+strings.Repeat("ab", 32)),
106		"active digest root missing")
107	assert.True(t, hasRoot(t, snapshot, domain.ProtectionContainerUse, "c-live"),
108		"active container root missing")
109	assert.True(t, hasRoot(t, snapshot, domain.ProtectionRecoveryInhibition, "c-inhibited"),
110		"recovery inhibition root missing")
111	assert.True(t, hasRoot(t, snapshot, domain.ProtectionApplyIntent, tagRefKey(t, "registry.example/shop", "2")),
112		"staged apply intent root missing")
113	assert.True(t, hasRoot(t, snapshot, domain.ProtectionOperation, "sha256:"+strings.Repeat("cd", 32)),
114		"unfinished operation digest root missing")
115	assert.True(t, hasRoot(t, snapshot, domain.ProtectionOperation, tagRefKey(t, "registry.example/shop", "3")),
116		"unfinished operation image root missing")
117	assert.False(t, hasRoot(t, snapshot, domain.ProtectionOperation, "sha256:"+strings.Repeat("ef", 32)),
118		"finished operation must not pin content")
119	assert.False(t, hasRoot(t, snapshot, domain.ProtectionOperation, tagRefKey(t, "registry.example/shop", "4")),
120		"finished operation must not pin content")
121
122	claim, ok := snapshot.VolumeClaimFor("gordon-shop--web--vol--data")
123	require.True(t, ok)
124	assert.Equal(t, domain.VolumeClaimAttached, claim.State)
125	assert.Equal(t, "shop", claim.App)
126	assert.True(t, snapshot.VolumeProtects("gordon-shop--web--vol--data"))
127}
128
129// TestStore_ProtectionSnapshotStoppedAppKeepsActive proves a stopped app
130// with no running container still protects its image and container.
131func TestStore_ProtectionSnapshotStoppedAppKeepsActive(t *testing.T) {
132	ctx := context.Background()
133	store := newTestStore(t)
134
135	require.NoError(t, store.SaveActive(ctx, domain.AppActive{
136		App:        "blog",
137		StopIntent: true,
138		Services: map[string]domain.AppEffectiveService{
139			"web": {EffectiveRevision: "rev-1", Image: "registry.example/blog:7", Container: "c-stopped"},
140		},
141	}))
142	require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true, UpdatedBy: "test"}))
143
144	snapshot, err := store.ProtectionSnapshot(ctx)
145	require.NoError(t, err)
146	assert.True(t, hasRoot(t, snapshot, domain.ProtectionActiveService, tagRefKey(t, "registry.example/blog", "7")))
147	assert.True(t, hasRoot(t, snapshot, domain.ProtectionContainerUse, "c-stopped"))
148}
149
150// TestStore_ProtectionSnapshotKeepsHistoricalIncarnations proves a
151// reused app name never loses the earlier incarnation's retained
152// resources.
153func TestStore_ProtectionSnapshotKeepsHistoricalIncarnations(t *testing.T) {
154	ctx := context.Background()
155	store := newTestStore(t)
156
157	const volume = "gordon-blog--db--vol--d"
158	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
159		App: "blog", ID: "app-uuid-1",
160		Volumes: []domain.AppOwnedVolume{
161			{Name: "d", Service: "db", RuntimeName: volume, State: domain.AppResourceRetained},
162		},
163	}))
164	// Remove frees the name; a new incarnation takes it and owns
165	// nothing yet.
166	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-uuid-2"}))
167
168	snapshot, err := store.ProtectionSnapshot(ctx)
169	require.NoError(t, err)
170	require.True(t, snapshot.VolumeProtects(volume), "historical retained claim was lost")
171	claims := snapshot.VolumeClaimsFor(volume)
172	require.Len(t, claims, 1)
173	assert.Equal(t, domain.VolumeClaimRetained, claims[0].State)
174	assert.Equal(t, "app-uuid-1", claims[0].AppID)
175}
176
177// TestStore_ProtectionSnapshotKeepsDifferentClaimStates proves two
178// incarnations' claims on one volume are distinct facts: a released
179// claim must never overwrite or hide another incarnation's attached
180// claim for the same runtime volume.
181func TestStore_ProtectionSnapshotKeepsDifferentClaimStates(t *testing.T) {
182	ctx := context.Background()
183	store := newTestStore(t)
184
185	const volume = "gordon-app--web--vol--data"
186	// The old incarnation released the volume.
187	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
188		App: "app", ID: "app-uuid-old",
189		Volumes: []domain.AppOwnedVolume{
190			{Name: "data", Service: "web", RuntimeName: volume, State: domain.AppResourceReleased},
191		},
192	}))
193	// A new incarnation attached it again.
194	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
195		App: "app", ID: "app-uuid-new",
196		Volumes: []domain.AppOwnedVolume{
197			{Name: "data", Service: "web", RuntimeName: volume, State: domain.AppResourceAttached},
198		},
199	}))
200
201	snapshot, err := store.ProtectionSnapshot(ctx)
202	require.NoError(t, err)
203	claims := snapshot.VolumeClaimsFor(volume)
204	require.Len(t, claims, 2, "both incarnations' claims must survive")
205	assert.True(t, snapshot.VolumeProtects(volume), "the attached claim must still protect the volume")
206}
207
208// TestStore_ProtectionSnapshotReleasedIsTheOnlyDeletableState proves
209// only an explicit released record can ever make a volume eligible.
210func TestStore_ProtectionSnapshotReleasedIsTheOnlyDeletableState(t *testing.T) {
211	ctx := context.Background()
212	store := newTestStore(t)
213
214	const volume = "gordon-shop--web--vol--data"
215	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
216		App: "shop", ID: "app-uuid-shop",
217		Volumes: []domain.AppOwnedVolume{
218			{Name: "data", Service: "web", RuntimeName: volume, State: domain.AppResourceReleased},
219		},
220	}))
221
222	snapshot, err := store.ProtectionSnapshot(ctx)
223	require.NoError(t, err)
224	assert.False(t, snapshot.VolumeProtects(volume))
225	claim, ok := snapshot.VolumeClaimFor(volume)
226	require.True(t, ok)
227	assert.Equal(t, domain.VolumeClaimReleased, claim.State)
228}
229
230// TestStore_ProtectionSnapshotUnknownStateIsRetained proves an
231// unrecognized lifecycle state never becomes deletable.
232func TestStore_ProtectionSnapshotUnknownStateIsRetained(t *testing.T) {
233	ctx := context.Background()
234	store := newTestStore(t)
235
236	const volume = "gordon-shop--web--vol--data"
237	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
238		App: "shop", ID: "app-uuid-shop",
239		Volumes: []domain.AppOwnedVolume{
240			{Name: "data", Service: "web", RuntimeName: volume},
241		},
242	}))
243
244	snapshot, err := store.ProtectionSnapshot(ctx)
245	require.NoError(t, err)
246	assert.True(t, snapshot.VolumeProtects(volume), "empty state must map to retained")
247}
248
249// TestStore_ProtectionSnapshotMigrationBackfillsHistory proves the
250// one-time migration reconstructs history for records written before
251// the history bucket existed, and that reopening is idempotent.
252func TestStore_ProtectionSnapshotMigrationBackfillsHistory(t *testing.T) {
253	ctx := context.Background()
254	dir := t.TempDir()
255	store, err := appstate.NewStore(dir, testLogger())
256	require.NoError(t, err)
257
258	const volume = "gordon-blog--db--vol--d"
259	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
260		App: "blog", ID: "app-uuid-1",
261		Volumes: []domain.AppOwnedVolume{
262			{Name: "d", Service: "db", RuntimeName: volume, State: domain.AppResourceRetained},
263		},
264	}))
265
266	// Reproduce a pre-migration database: per-app records exist, the
267	// incarnation index does not.
268	require.NoError(t, appstate.ResetOwnershipHistoryForTest(store))
269	snapshot, err := store.ProtectionSnapshot(ctx)
270	require.NoError(t, err)
271	assert.False(t, snapshot.Complete(), "missing history bucket must be reported")
272	assert.True(t, snapshot.VolumeProtects(volume), "per-app record still protects")
273	require.NoError(t, store.Close())
274
275	// Reopening runs the migration once and reuses the existing index
276	// on every later open.
277	reopened, err := appstate.NewStore(dir, testLogger())
278	require.NoError(t, err)
279	t.Cleanup(func() { assert.NoError(t, reopened.Close()) })
280	snapshot, err = reopened.ProtectionSnapshot(ctx)
281	require.NoError(t, err)
282	assert.True(t, snapshot.Complete(), "snapshot gaps after migration: %+v", snapshot.Gaps)
283	assert.True(t, snapshot.VolumeProtects(volume))
284
285	require.NoError(t, reopened.Close())
286	third, err := appstate.NewStore(dir, testLogger())
287	require.NoError(t, err)
288	t.Cleanup(func() { assert.NoError(t, third.Close()) })
289	ownership, err := third.LoadOwnership(ctx, "blog")
290	require.NoError(t, err)
291	assert.Equal(t, "app-uuid-1", ownership.ID)
292	snapshot, err = third.ProtectionSnapshot(ctx)
293	require.NoError(t, err)
294	assert.True(t, snapshot.VolumeProtects(volume))
295}
296
297// TestStore_ProtectionSnapshotReportsCorruptRecords proves unreadable
298// state becomes a gap instead of an apparently unprotected snapshot.
299func TestStore_ProtectionSnapshotReportsCorruptRecords(t *testing.T) {
300	ctx := context.Background()
301	store := newTestStore(t)
302
303	desired := testIntent("shop", "rev-1")
304	require.NoError(t, store.StageApply(ctx, desired))
305	require.NoError(t, store.CommitApply(ctx, "shop", desired.Intent))
306	require.NoError(t, store.MaterializeApply(ctx, "shop", desired.Intent))
307	require.NoError(t, appstate.CorruptDesiredForTest(store, "shop", func([]byte) []byte {
308		return []byte("{not json")
309	}))
310
311	snapshot, err := store.ProtectionSnapshot(ctx)
312	require.NoError(t, err)
313	assert.False(t, snapshot.Complete())
314	require.NotEmpty(t, snapshot.Gaps)
315	assert.Equal(t, domain.InventorySourceAppState, snapshot.Gaps[0].Source)
316}
317
318// TestStore_ProtectionSnapshotConcurrentWrites proves a snapshot is
319// atomic against concurrent state writes.
320func TestStore_ProtectionSnapshotConcurrentWrites(t *testing.T) {
321	ctx := context.Background()
322	store := newTestStore(t)
323
324	const rounds = 40
325	var wg sync.WaitGroup
326	for index := 0; index < rounds; index++ {
327		wg.Add(1)
328		go func(index int) {
329			defer wg.Done()
330			app := "app-" + itoa2(index%4)
331			active := domain.AppActive{
332				App: app,
333				Services: map[string]domain.AppEffectiveService{
334					"web": {EffectiveRevision: "rev-" + itoa2(index), Image: "registry.example/" + app + ":1"},
335				},
336			}
337			assert.NoError(t, store.SaveActive(ctx, active))
338			assert.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
339				App: app, ID: "app-uuid-" + itoa2(index%4),
340				Volumes: []domain.AppOwnedVolume{
341					{Name: "data", Service: "web", RuntimeName: "gordon-" + itoa2(index%4) + "--web--vol--data"},
342				},
343			}))
344			snapshot, err := store.ProtectionSnapshot(ctx)
345			assert.NoError(t, err)
346			assert.NoError(t, snapshotValidate(snapshot))
347		}(index)
348	}
349	wg.Wait()
350}
351
352// snapshotValidate checks the snapshot invariant callers rely on: every
353// root and claim is well formed.
354func snapshotValidate(snapshot *domain.PruneProtectionSnapshot) error {
355	for _, root := range snapshot.Roots {
356		if !root.Valid() {
357			return assert.AnError
358		}
359	}
360	for _, gap := range snapshot.Gaps {
361		if !gap.Valid() {
362			return assert.AnError
363		}
364	}
365	return nil
366}
367
368// TestStore_ProtectionSnapshotCancellation proves a canceled context
369// never opens a read transaction.
370func TestStore_ProtectionSnapshotCancellation(t *testing.T) {
371	store := newTestStore(t)
372	ctx, cancel := context.WithCancel(context.Background())
373	cancel()
374	_, err := store.ProtectionSnapshot(ctx)
375	assert.ErrorIs(t, err, context.Canceled)
376}
377
378var _ = time.Second

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