1package images
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15// TestPrune_SnapshotGapFailsClosed proves an incomplete protection
16// snapshot never authorizes deletion: an unreadable app record could be
17// hiding a claim on any candidate, so every candidate stays unknown.
18func TestPrune_SnapshotGapFailsClosed(t *testing.T) {
19 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
20 manifests := newFakeManifestStorage()
21 manifests.repositories = []string{"app"}
22 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
23 for index, tag := range []string{"latest", "v1", "v2"} {
24 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
25 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
26 }
27
28 orphan := testDigest("orphan")
29 blobs := &fakeBlobStorage{
30 blobs: []string{orphan},
31 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
32 }
33 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
34 Gaps: []domain.InventoryGap{{
35 Source: domain.InventorySourceAppState,
36 Reason: domain.PruneReasonUnknownInventory,
37 Detail: "app record unreadable",
38 }},
39 }}
40 runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
41 Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
42 }}
43
44 svc, _ := newPruneService(t, manifests, blobs, protection, runtime)
45 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
46 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
47 })
48 require.NoError(t, err)
49
50 assert.Empty(t, manifests.deletedManifests, "an incomplete snapshot must not authorize tag deletion")
51 assert.Empty(t, blobs.deletedBlobs, "an incomplete snapshot must not authorize blob deletion")
52 assert.Empty(t, runtime.removedImages, "an incomplete snapshot must not authorize image deletion")
53 assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
54 // Only the explicitly retained window tags stay protected; every
55 // other candidate is unknown rather than eligible.
56 assert.Equal(t, 2, report.Plan.CountByVerdict(domain.PruneVerdictProtected))
57 assert.Equal(t, len(report.Plan.Candidates)-2, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
58}
59
60// TestPrune_MovedTagKeepsActiveDigestClosure proves an ACTIVE digest root
61// keeps the full OCI closure of its manifest: moving the mutable tag away
62// must not let prune delete the config or layers of the pinned image.
63func TestPrune_MovedTagKeepsActiveDigestClosure(t *testing.T) {
64 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
65 pinned := testDigest("active-manifest")
66 configDigest := testDigest("active-config")
67 layerDigest := testDigest("active-layer")
68
69 manifests := newFakeManifestStorage()
70 manifests.repositories = []string{"app"}
71 // The tag now points elsewhere; the pinned manifest survives untagged.
72 manifests.tagsByRepo["app"] = []string{"latest"}
73 manifests.modTimes[manifestRefKey("app", "latest")] = now
74 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
75 manifests.manifests[manifestRefKey("app", pinned)] = mustManifestJSON(t, configDigest, layerDigest)
76
77 blobs := &fakeBlobStorage{
78 blobs: []string{configDigest, layerDigest, testDigest("orphan")},
79 blobModTimes: map[string]time.Time{
80 configDigest: now.Add(-72 * time.Hour),
81 layerDigest: now.Add(-72 * time.Hour),
82 testDigest("orphan"): now.Add(-72 * time.Hour),
83 },
84 }
85 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
86 Roots: []domain.ProtectionRoot{{
87 Kind: domain.ProtectionActiveService, Ref: pinned, Repository: "app", Owner: "app@active.web",
88 }},
89 }}
90
91 svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
92 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
93 require.NoError(t, err)
94
95 for _, protected := range []string{configDigest, layerDigest} {
96 protectedVerdicts := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
97 found := false
98 for _, verdict := range protectedVerdicts {
99 if verdict.Ref == protected {
100 found = true
101 }
102 }
103 assert.True(t, found, "blob %s of the pinned manifest must stay protected", protected)
104 }
105 assert.NotContains(t, blobs.deletedBlobs, configDigest)
106 assert.NotContains(t, blobs.deletedBlobs, layerDigest)
107}
108
109// TestPrune_SubjectClosureProtectsManifestConfigAndLayers proves a retained
110// artifact manifest keeps its subject manifest and that subject's blobs.
111func TestPrune_SubjectClosureProtectsManifestConfigAndLayers(t *testing.T) {
112 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
113 subject := testDigest("subject-manifest")
114 configDigest := testDigest("subject-config")
115 layerDigest := testDigest("subject-layer")
116
117 manifests := newFakeManifestStorage()
118 manifests.repositories = []string{"app"}
119 manifests.tagsByRepo["app"] = []string{"attestation"}
120 manifests.modTimes[manifestRefKey("app", "attestation")] = now
121 manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, subject)
122 manifests.manifests[manifestRefKey("app", subject)] = mustManifestJSON(t, configDigest, layerDigest)
123
124 blobs := &fakeBlobStorage{
125 blobs: []string{subject, configDigest, layerDigest},
126 blobModTimes: map[string]time.Time{
127 subject: now.Add(-72 * time.Hour), configDigest: now.Add(-72 * time.Hour), layerDigest: now.Add(-72 * time.Hour),
128 },
129 }
130 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
131 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
132 require.NoError(t, err)
133
134 assert.Empty(t, blobs.deletedBlobs)
135 for _, digest := range []string{subject, configDigest, layerDigest} {
136 assert.Contains(t, candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)), digest)
137 }
138}
139
140func TestPrune_MissingSubjectFailsClosed(t *testing.T) {
141 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
142 orphan := testDigest("orphan")
143 manifests := newFakeManifestStorage()
144 manifests.repositories = []string{"app"}
145 manifests.tagsByRepo["app"] = []string{"attestation"}
146 manifests.modTimes[manifestRefKey("app", "attestation")] = now
147 manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, testDigest("missing-subject"))
148 blobs := &fakeBlobStorage{blobs: []string{orphan}, blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)}}
149
150 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
151 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
152 require.NoError(t, err)
153
154 assert.Empty(t, blobs.deletedBlobs)
155 assert.Contains(t, candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)), orphan)
156}
157
158func TestPrune_SubjectCycleTerminatesAndProtectsClosure(t *testing.T) {
159 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
160 a := testDigest("subject-a")
161 b := testDigest("subject-b")
162 manifests := newFakeManifestStorage()
163 manifests.repositories = []string{"app"}
164 manifests.tagsByRepo["app"] = []string{"attestation"}
165 manifests.modTimes[manifestRefKey("app", "attestation")] = now
166 manifests.manifests[manifestRefKey("app", "attestation")] = mustSubjectManifestJSON(t, a)
167 manifests.manifests[manifestRefKey("app", a)] = mustSubjectManifestJSON(t, b)
168 manifests.manifests[manifestRefKey("app", b)] = mustSubjectManifestJSON(t, a)
169 blobs := &fakeBlobStorage{blobs: []string{a, b}, blobModTimes: map[string]time.Time{a: now.Add(-72 * time.Hour), b: now.Add(-72 * time.Hour)}}
170
171 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{}}, &fakePruneRuntime{})
172 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
173 require.NoError(t, err)
174
175 assert.Empty(t, blobs.deletedBlobs)
176 protected := candidateRefs(report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected))
177 assert.Contains(t, protected, a)
178 assert.Contains(t, protected, b)
179}
180
181func mustSubjectManifestJSON(t *testing.T, subject string) []byte {
182 t.Helper()
183 return []byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","subject":{"digest":"` + subject + `"}}`)
184}
185
186func candidateRefs(candidates []domain.PruneCandidateReport) []string {
187 refs := make([]string, 0, len(candidates))
188 for _, candidate := range candidates {
189 refs = append(refs, candidate.Ref)
190 }
191 return refs
192}
193
194// TestPrune_ExternalRepositoryDigestRootDoesNotDisablePruning proves a
195// durable digest root whose repository has no local content does not make
196// the registry closure incomplete: external images never introduce local
197// content, so unrelated blobs stay eligible.
198func TestPrune_ExternalRepositoryDigestRootDoesNotDisablePruning(t *testing.T) {
199 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
200 orphan := testDigest("orphan")
201
202 manifests := newFakeManifestStorage()
203 manifests.repositories = []string{"app"}
204 manifests.tagsByRepo["app"] = []string{"latest"}
205 manifests.modTimes[manifestRefKey("app", "latest")] = now
206 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
207
208 blobs := &fakeBlobStorage{
209 blobs: []string{orphan},
210 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
211 }
212 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
213 Roots: []domain.ProtectionRoot{{
214 Kind: domain.ProtectionActiveService, Ref: testDigest("external-manifest"),
215 Repository: "docker.io/library/nginx", Owner: "app@active.web",
216 }},
217 }}
218
219 svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
220 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
221 require.NoError(t, err)
222
223 assert.Equal(t, []string{orphan}, blobs.deletedBlobs, "an external root must not block local prune")
224 assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
225}
226
227// TestPrune_UnresolvedDigestRootFailsClosed proves a durable digest root
228// whose manifest cannot be read makes the registry closure incomplete, so
229// unrelated blobs stay unknown instead of being deleted.
230func TestPrune_UnresolvedDigestRootFailsClosed(t *testing.T) {
231 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
232 missing := testDigest("missing-manifest")
233 orphan := testDigest("orphan")
234
235 manifests := newFakeManifestStorage()
236 manifests.repositories = []string{"app"}
237 manifests.tagsByRepo["app"] = []string{"latest"}
238 manifests.modTimes[manifestRefKey("app", "latest")] = now
239 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("latest-config"))
240
241 blobs := &fakeBlobStorage{
242 blobs: []string{orphan},
243 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
244 }
245 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
246 Roots: []domain.ProtectionRoot{{
247 Kind: domain.ProtectionActiveService, Ref: missing, Repository: "app", Owner: "app@active.web",
248 }},
249 }}
250
251 svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
252 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
253 require.NoError(t, err)
254
255 assert.Empty(t, blobs.deletedBlobs, "an unresolved durable root must not authorize deletion")
256 unknown := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)
257 require.Len(t, unknown, 1)
258 assert.Equal(t, orphan, unknown[0].Ref)
259}
260
261// TestPrune_BlobClaimedByDigestRootSurvives proves a digest pinned
262// directly by durable state (not reachable through any retained tag)
263// is protected.
264func TestPrune_BlobClaimedByDigestRootSurvives(t *testing.T) {
265 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
266 pinned := testDigest("pinned-by-operation")
267
268 manifests := newFakeManifestStorage()
269 manifests.repositories = []string{"app"}
270 manifests.tagsByRepo["app"] = []string{"latest", "v1"}
271 for index, tag := range []string{"latest", "v1"} {
272 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index) * time.Hour)
273 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
274 }
275
276 blobs := &fakeBlobStorage{
277 blobs: []string{pinned},
278 blobModTimes: map[string]time.Time{pinned: now.Add(-72 * time.Hour)},
279 }
280 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
281 Roots: []domain.ProtectionRoot{
282 {Kind: domain.ProtectionOperation, Ref: pinned, Owner: "app@op-1"},
283 },
284 }}
285
286 svc, _ := newPruneService(t, manifests, blobs, protection, &fakePruneRuntime{})
287 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
288 require.NoError(t, err)
289
290 assert.Empty(t, blobs.deletedBlobs, "a digest pinned by durable state must survive")
291 protected := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
292 require.Len(t, protected, 1)
293 assert.Equal(t, []domain.PruneReason{domain.PruneReasonProtectedOperation}, protected[0].Reasons)
294}
295
296// TestPrune_BlobWithoutModTimeStaysUnknown proves an unreadable blob age
297// fails closed instead of falling through to eligibility: a blob that
298// cannot be aged cannot be proven past the upload TTL.
299func TestPrune_BlobWithoutModTimeStaysUnknown(t *testing.T) {
300 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
301 unknownAge := testDigest("unknown-age")
302
303 manifests := newFakeManifestStorage()
304 manifests.repositories = []string{"app"}
305 manifests.tagsByRepo["app"] = []string{"latest"}
306 manifests.modTimes[manifestRefKey("app", "latest")] = now
307 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg"))
308
309 blobs := &fakeBlobStorage{blobs: []string{unknownAge}, blobModTimeErr: errors.New("stat failed")}
310 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
311
312 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
313 require.NoError(t, err)
314 assert.Empty(t, blobs.deletedBlobs)
315 unknown := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictUnknown)
316 require.Len(t, unknown, 1)
317 assert.Equal(t, []domain.PruneReason{domain.PruneReasonUnknownInventory}, unknown[0].Reasons)
318}
319
320// TestPrune_MalformedTagFailsClosed proves a tag whose identity cannot
321// be established makes the registry plan incomplete rather than being
322// skipped: skipping it would hide its content from the closure.
323func TestPrune_MalformedTagFailsClosed(t *testing.T) {
324 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
325 orphan := testDigest("orphan")
326
327 manifests := newFakeManifestStorage()
328 manifests.repositories = []string{"app"}
329 manifests.tagsByRepo["app"] = []string{"latest", " "}
330 manifests.modTimes[manifestRefKey("app", "latest")] = now
331 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg"))
332
333 blobs := &fakeBlobStorage{
334 blobs: []string{orphan},
335 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
336 }
337 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
338
339 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
340 require.NoError(t, err)
341 assert.Empty(t, blobs.deletedBlobs)
342 assert.NotEmpty(t, report.Plan.Gaps)
343 assert.Zero(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
344}
345
346// TestPrune_SecondExecutionIsIdempotent proves an executed plan is
347// complete: re-running it finds nothing left to delete, and the second
348// report carries no eligible candidate.
349func TestPrune_SecondExecutionIsIdempotent(t *testing.T) {
350 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
351 manifests := newFakeManifestStorage()
352 manifests.repositories = []string{"app"}
353 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2", "v3"}
354 for index, tag := range []string{"latest", "v1", "v2", "v3"} {
355 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-4) * time.Hour)
356 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
357 }
358
359 orphan := testDigest("orphan")
360 blobs := &fakeBlobStorage{
361 blobs: []string{orphan},
362 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
363 }
364 runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
365 Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
366 }}
367
368 svc, _ := newPruneService(t, manifests, blobs, releasedImageSnapshot("sha256:dangling"), runtime)
369 opts := domain.ImagePruneOptions{KeepLast: 1, PruneDangling: true, PruneRegistry: true}
370
371 first, err := svc.Prune(context.Background(), opts)
372 require.NoError(t, err)
373 require.NotEmpty(t, first.Plan.Deleted, "the first run must delete something to be worth repeating")
374 firstDeleted := len(first.Plan.Deleted)
375 firstTags := len(manifests.deletedManifests)
376 firstImages := len(runtime.removedImages)
377 firstBlobs := len(blobs.deletedBlobs)
378 require.NotZero(t, firstTags+firstImages+firstBlobs)
379
380 second, err := svc.Prune(context.Background(), opts)
381 require.NoError(t, err)
382 assert.Empty(t, second.Plan.Deleted, "a second run must delete nothing")
383 assert.Zero(t, second.Plan.CountByVerdict(domain.PruneVerdictEligible), "nothing may remain eligible")
384 assert.Zero(t, second.Plan.CountByVerdict(domain.PruneVerdictUnknown), "a completed plan leaves no unknowns behind")
385 assert.Empty(t, second.Plan.Failures)
386 assert.Equal(t, firstTags, len(manifests.deletedManifests))
387 assert.Equal(t, firstImages, len(runtime.removedImages))
388 assert.Equal(t, firstBlobs, len(blobs.deletedBlobs))
389 assert.Len(t, first.Plan.Deleted, firstDeleted)
390}
391
392// TestPrune_RuntimeDeletionRunsOutsideRegistryLock proves the registry
393// mutation lock is not held while runtime images are deleted, so a slow
394// runtime prune cannot stall registry pushes.
395func TestPrune_RuntimeDeletionRunsOutsideRegistryLock(t *testing.T) {
396 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
397 manifests := newFakeManifestStorage()
398 manifests.repositories = []string{"app"}
399 manifests.tagsByRepo["app"] = []string{"latest", "v1"}
400 manifests.modTimes[manifestRefKey("app", "latest")] = now
401 manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
402 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("l"))
403 manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("one"))
404
405 // The runtime port reports whether the registry lock was held when
406 // the deletion ran.
407 runtime := &fakePruneRuntime{
408 inventory: &domain.RuntimeInventory{
409 Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
410 },
411 }
412 svc, _ := newPruneService(t, manifests, &fakeBlobStorage{}, releasedImageSnapshot("sha256:dangling"), runtime)
413
414 unlocked := false
415 runtime.onRemoveImage = func() {
416 unlocked = svc.mutationMu.TryLock()
417 if unlocked {
418 svc.mutationMu.Unlock()
419 }
420 }
421
422 _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
423 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
424 })
425 require.NoError(t, err)
426 assert.Equal(t, []string{"sha256:dangling"}, runtime.removedImages)
427 assert.True(t, unlocked, "runtime deletion must not run under the registry mutation lock")
428}