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