1package images
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// digestOf returns the sha256 digest of a manifest body, matching the
21// identity the service derives during inventory.
22func manifestBodyDigest(data []byte) string {
23 sum := sha256.Sum256(data)
24 return "sha256:" + hex.EncodeToString(sum[:])
25}
26
27func testDigest(seed string) string {
28 sum := sha256.Sum256([]byte(seed))
29 return "sha256:" + hex.EncodeToString(sum[:])
30}
31
32// fakeProtectionStore returns one fixed snapshot.
33type fakeProtectionStore struct {
34 snapshot *domain.PruneProtectionSnapshot
35 err error
36 calls int
37}
38
39func (f *fakeProtectionStore) ProtectionSnapshot(context.Context) (*domain.PruneProtectionSnapshot, error) {
40 f.calls++
41 if f.err != nil {
42 return nil, f.err
43 }
44 if f.snapshot == nil {
45 return &domain.PruneProtectionSnapshot{}, nil
46 }
47 return f.snapshot, nil
48}
49
50// fakePruneRuntime records every exact deletion request.
51type fakePruneRuntime struct {
52 inventory *domain.RuntimeInventory
53 inventoryErr error
54
55 removedImages []string
56 removeImageErr error
57
58 removedVolumes []string
59 removeVolumeErr error
60
61 // onRemoveImage, when set, runs before a runtime image deletion so a
62 // test can observe the state it executes under.
63 onRemoveImage func()
64}
65
66func (f *fakePruneRuntime) InventoryRuntime(context.Context) (*domain.RuntimeInventory, error) {
67 if f.inventoryErr != nil {
68 return nil, f.inventoryErr
69 }
70 if f.inventory == nil {
71 return &domain.RuntimeInventory{}, nil
72 }
73 return f.inventory, nil
74}
75
76func (f *fakePruneRuntime) RemoveImageExact(_ context.Context, ref domain.RuntimeImageRef) error {
77 if f.onRemoveImage != nil {
78 f.onRemoveImage()
79 }
80 if f.removeImageErr != nil {
81 return f.removeImageErr
82 }
83 f.removedImages = append(f.removedImages, ref.ID)
84 // A real adapter no longer reports a removed image, so the double
85 // must not either: otherwise a second run would look idempotent
86 // only because the double forgets nothing.
87 if f.inventory != nil {
88 kept := f.inventory.Images[:0]
89 for _, image := range f.inventory.Images {
90 if image.ID != ref.ID {
91 kept = append(kept, image)
92 }
93 }
94 f.inventory.Images = kept
95 }
96 return nil
97}
98
99func (f *fakePruneRuntime) RemoveVolumeExact(_ context.Context, ref domain.RuntimeVolumeRef) error {
100 if f.removeVolumeErr != nil {
101 return f.removeVolumeErr
102 }
103 f.removedVolumes = append(f.removedVolumes, ref.Name)
104 return nil
105}
106
107// fakeBarrier records the exclusive lease lifecycle.
108type fakeBarrier struct {
109 mu sync.Mutex
110 acquires int
111 releases int
112 err error
113}
114
115func (b *fakeBarrier) AcquireShared(context.Context) (out.GCLease, error) {
116 return noopLease{}, nil
117}
118
119func (b *fakeBarrier) AcquireExclusive(context.Context) (out.GCLease, error) {
120 if b.err != nil {
121 return nil, b.err
122 }
123 b.mu.Lock()
124 b.acquires++
125 b.mu.Unlock()
126 return &recordingLease{barrier: b}, nil
127}
128
129type recordingLease struct {
130 barrier *fakeBarrier
131 once sync.Once
132}
133
134func (l *recordingLease) Release() {
135 l.once.Do(func() {
136 l.barrier.mu.Lock()
137 l.barrier.releases++
138 l.barrier.mu.Unlock()
139 })
140}
141
142// noopLease is a lease used by the barrier under test.
143type noopLease struct{}
144
145func (noopLease) Release() {}
146
147func newPruneService(t *testing.T, manifests *fakeManifestStorage, blobs *fakeBlobStorage, protection *fakeProtectionStore, runtime *fakePruneRuntime) (*Service, *fakeBarrier) {
148 t.Helper()
149 barrier := &fakeBarrier{}
150 svc := NewService(&fakeRuntime{}, manifests, blobs, zerowrap.Default()).
151 WithPrunePorts(protection, runtime, barrier)
152 return svc, barrier
153}
154
155// TestPrune_SelectiveMixedBatch is the core acceptance case: app state
156// exists, and a mixed batch of safe, protected, and unknown candidates
157// produces exactly the safe deletions with no operation-wide refusal.
158func TestPrune_SelectiveMixedBatch(t *testing.T) {
159 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
160 oldTag := mustManifestJSON(t, testDigest("a"), testDigest("b"))
161 latestTag := mustManifestJSON(t, testDigest("c"), testDigest("d"))
162
163 manifests := newFakeManifestStorage()
164 manifests.repositories = []string{"app"}
165 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2", "v3", "v4", "v5"}
166 for index, tag := range []string{"latest", "v1", "v2", "v3", "v4", "v5"} {
167 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-6) * time.Hour)
168 }
169 manifests.manifests[manifestRefKey("app", "latest")] = latestTag
170 manifests.manifests[manifestRefKey("app", "v1")] = oldTag
171 for _, tag := range []string{"v2", "v3", "v4", "v5"} {
172 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-layer"))
173 }
174
175 blobs := &fakeBlobStorage{
176 blobs: []string{testDigest("b"), testDigest("d"), testDigest("orphan")},
177 blobSizes: map[string]int64{testDigest("orphan"): 4096},
178 blobModTimes: map[string]time.Time{testDigest("orphan"): now.Add(-48 * time.Hour)},
179 }
180
181 activeImage := "sha256:active"
182 danglingImage := "sha256:dangling"
183 foreignImage := "sha256:foreign"
184 protection := &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
185 Roots: []domain.ProtectionRoot{
186 {Kind: domain.ProtectionActiveService, Ref: mustTagKey(t, "app", "v1"), Owner: "app@active.web"},
187 },
188 ImageClaims: []domain.ImageClaim{releasedImageClaim(danglingImage)},
189 }}
190 runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
191 Images: []domain.RuntimeImage{
192 {ID: activeImage, RepoTags: []string{"app:latest"}, Labels: map[string]string{domain.LabelApp: "app"}},
193 {ID: danglingImage, Labels: map[string]string{domain.LabelApp: "app"}},
194 {ID: foreignImage},
195 },
196 Containers: []domain.RuntimeContainerUse{
197 {ContainerID: "c1", ImageID: activeImage, Running: true},
198 },
199 }}
200
201 svc, barrier := newPruneService(t, manifests, blobs, protection, runtime)
202 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
203 KeepLast: 3, PruneDangling: true, PruneRegistry: true,
204 })
205 require.NoError(t, err)
206
207 // latest + 3 = latest, v5, v4, v3 retained; v2 and v1 pruned
208 // only if unprotected. v1 is protected by the active app state.
209 assert.Equal(t, []manifestRef{{name: "app", reference: "v2"}}, manifests.deletedManifests)
210
211 // The dangling app image is deleted; the foreign and in-use ones
212 // are not.
213 assert.Equal(t, []string{danglingImage}, runtime.removedImages)
214
215 // The orphan blob is deleted; the blobs reachable from the retained
216 // and protected manifests are not.
217 assert.Equal(t, []string{testDigest("orphan")}, blobs.deletedBlobs)
218 assert.Equal(t, int64(4096), report.Plan.ReclaimedBytes)
219 assert.True(t, report.Plan.ReclaimedKnown)
220 assert.True(t, report.Plan.Applied)
221 assert.Empty(t, report.Plan.Failures)
222
223 assert.GreaterOrEqual(t, report.Plan.CountByVerdict(domain.PruneVerdictProtected), 1)
224 assert.GreaterOrEqual(t, report.Plan.CountByVerdict(domain.PruneVerdictEligible), 1)
225 assert.Equal(t, 1, barrier.acquires)
226 assert.Equal(t, 1, barrier.releases)
227 assert.Equal(t, 1, protection.calls)
228}
229
230func TestPrune_DryRunPlansWithoutDeleting(t *testing.T) {
231 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
232 manifests := newFakeManifestStorage()
233 manifests.repositories = []string{"app"}
234 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
235 for index, tag := range []string{"latest", "v1", "v2"} {
236 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
237 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag), testDigest(tag+"-l"))
238 }
239 blobs := &fakeBlobStorage{blobs: []string{testDigest("x")}, blobModTimes: map[string]time.Time{testDigest("x"): now.Add(-72 * time.Hour)}}
240
241 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
242 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
243 KeepLast: 1, PruneDangling: true, PruneRegistry: true, DryRun: true,
244 })
245 require.NoError(t, err)
246
247 assert.False(t, report.Plan.Applied)
248 assert.Empty(t, manifests.deletedManifests)
249 assert.Empty(t, blobs.deletedBlobs)
250 assert.NotEmpty(t, report.Plan.Candidates)
251
252 // The dry-run plan must equal the executed plan.
253 executed, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
254 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
255 })
256 require.NoError(t, err)
257 assert.Equal(t, report.Plan.Candidates, executed.Plan.Candidates)
258 assert.True(t, executed.Plan.Applied)
259}
260
261func TestPrune_ZeroEligibleSucceeds(t *testing.T) {
262 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
263 manifests := newFakeManifestStorage()
264 manifests.repositories = []string{"app"}
265 manifests.tagsByRepo["app"] = []string{"latest", "v1"}
266 for index, tag := range []string{"latest", "v1"} {
267 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index) * time.Hour)
268 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
269 }
270 blobs := &fakeBlobStorage{blobs: []string{}}
271 runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{}}
272
273 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, runtime)
274 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
275 KeepLast: 3, PruneDangling: true, PruneRegistry: true,
276 })
277 require.NoError(t, err, "a zero-deletion prune must succeed, not fail the operation")
278 assert.Zero(t, report.Registry.TagsRemoved)
279 assert.Zero(t, report.Registry.BlobsRemoved)
280 assert.Zero(t, report.Runtime.DeletedCount)
281 assert.Equal(t, 0, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
282}
283
284func TestPrune_MissingChildManifestMakesUnreferencedBlobsUnknown(t *testing.T) {
285 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
286 childDigest := testDigest("missing-child")
287 indexBody := mustManifestIndexJSON(t, childDigest)
288
289 manifests := newFakeManifestStorage()
290 manifests.repositories = []string{"app"}
291 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
292 manifests.modTimes[manifestRefKey("app", "latest")] = now
293 manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
294 manifests.modTimes[manifestRefKey("app", "v2")] = now.Add(-2 * time.Hour)
295 // latest is a multi-arch index whose child manifest is absent.
296 manifests.manifests[manifestRefKey("app", "latest")] = indexBody
297 manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("c1"))
298 manifests.manifests[manifestRefKey("app", "v2")] = mustManifestJSON(t, testDigest("c2"))
299
300 orphan := testDigest("orphan")
301 blobs := &fakeBlobStorage{
302 blobs: []string{orphan},
303 blobModTimes: map[string]time.Time{orphan: now.Add(-72 * time.Hour)},
304 }
305
306 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
307 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
308 KeepLast: 1, PruneRegistry: true,
309 })
310 require.NoError(t, err)
311
312 // The unreadable child makes the retained closure incomplete, so an
313 // otherwise-unreferenced blob is unknown, not eligible.
314 assert.Empty(t, blobs.deletedBlobs, "an incomplete closure must never authorize blob deletion")
315 assert.Contains(t, manifestBodyDigest(indexBody), "sha256:")
316 var unknown bool
317 for _, candidate := range report.Plan.Candidates {
318 if candidate.Kind == domain.PruneResourceOCIBlob && candidate.Verdict == domain.PruneVerdictUnknown {
319 unknown = true
320 assert.Equal(t, []domain.PruneReason{domain.PruneReasonUnknownManifest}, candidate.Reasons)
321 }
322 }
323 assert.True(t, unknown, "the orphan blob must be reported unknown")
324 assert.NotEmpty(t, report.Plan.Gaps)
325
326 // Tags outside the window are still deleted: the failure is scoped
327 // to the content whose safety depended on the missing manifest.
328 assert.Len(t, manifests.deletedManifests, 1)
329 assert.Equal(t, "v2", manifests.deletedManifests[0].reference)
330}
331
332func TestPrune_SharedBlobStaysProtectedThroughClosure(t *testing.T) {
333 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
334 sharedLayer := testDigest("shared-layer")
335
336 manifests := newFakeManifestStorage()
337 manifests.repositories = []string{"app"}
338 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
339 manifests.modTimes[manifestRefKey("app", "latest")] = now
340 manifests.modTimes[manifestRefKey("app", "v1")] = now.Add(-time.Hour)
341 manifests.modTimes[manifestRefKey("app", "v2")] = now.Add(-2 * time.Hour)
342 manifests.manifests[manifestRefKey("app", "latest")] = mustManifestJSON(t, testDigest("cfg-latest"), sharedLayer)
343 manifests.manifests[manifestRefKey("app", "v1")] = mustManifestJSON(t, testDigest("cfg-v1"), sharedLayer)
344 manifests.manifests[manifestRefKey("app", "v2")] = mustManifestJSON(t, testDigest("cfg-v2"))
345
346 blobs := &fakeBlobStorage{blobs: []string{sharedLayer}}
347 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
348 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
349 require.NoError(t, err)
350
351 assert.Empty(t, blobs.deletedBlobs, "a layer shared with a retained tag must survive")
352 protected := report.Plan.CandidatesOfKind(domain.PruneResourceOCIBlob, domain.PruneVerdictProtected)
353 require.Len(t, protected, 1)
354 assert.Equal(t, []domain.PruneReason{domain.PruneReasonProtectedSharedContent}, protected[0].Reasons)
355}
356
357func TestPrune_RegistryDeletionFailureIsIsolated(t *testing.T) {
358 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
359 manifests := newFakeManifestStorage()
360 manifests.repositories = []string{"app"}
361 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
362 for index, tag := range []string{"latest", "v1", "v2"} {
363 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
364 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
365 }
366 manifests.deleteErr = errors.New("storage unavailable")
367
368 blobs := &fakeBlobStorage{}
369 svc, _ := newPruneService(t, manifests, blobs, &fakeProtectionStore{}, &fakePruneRuntime{})
370 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
371 require.NoError(t, err, "one deletion failure must not fail the run")
372
373 require.Len(t, report.Plan.Failures, 1)
374 assert.Equal(t, domain.PruneResourceRegistryTag, report.Plan.Failures[0].Kind)
375 assert.Zero(t, report.Registry.TagsRemoved)
376 assert.Empty(t, report.Plan.Deleted)
377}
378
379func TestPrune_RuntimeDeletionFailureIsIsolated(t *testing.T) {
380 image := "sha256:dangling"
381 runtime := &fakePruneRuntime{
382 inventory: &domain.RuntimeInventory{
383 Images: []domain.RuntimeImage{{ID: image, Labels: map[string]string{domain.LabelApp: "app"}}},
384 },
385 removeImageErr: errors.New("image is in use"),
386 }
387 svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, releasedImageSnapshot(image), runtime)
388
389 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{PruneDangling: true})
390 require.NoError(t, err)
391 require.Len(t, report.Plan.Failures, 1)
392 assert.Equal(t, domain.PruneResourceRuntimeImage, report.Plan.Failures[0].Kind)
393 assert.Zero(t, report.Runtime.DeletedCount)
394}
395
396func TestPrune_KeepLastZeroSkipsRegistry(t *testing.T) {
397 manifests := newFakeManifestStorage()
398 manifests.repositories = []string{"app"}
399 manifests.tagsByRepo["app"] = []string{"v1"}
400 svc, _ := newPruneService(t, manifests, &fakeBlobStorage{blobs: []string{testDigest("x")}}, &fakeProtectionStore{}, &fakePruneRuntime{})
401
402 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 0, PruneRegistry: true})
403 require.NoError(t, err)
404 assert.Empty(t, manifests.deletedManifests)
405 assert.Empty(t, report.Plan.Candidates)
406}
407
408func TestPrune_RegistryOnlySkipsRuntime(t *testing.T) {
409 now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
410 manifests := newFakeManifestStorage()
411 manifests.repositories = []string{"app"}
412 manifests.tagsByRepo["app"] = []string{"latest", "v1", "v2"}
413 for index, tag := range []string{"latest", "v1", "v2"} {
414 manifests.modTimes[manifestRefKey("app", tag)] = now.Add(time.Duration(index-3) * time.Hour)
415 manifests.manifests[manifestRefKey("app", tag)] = mustManifestJSON(t, testDigest(tag))
416 }
417 runtime := &fakePruneRuntime{
418 inventory: &domain.RuntimeInventory{
419 Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
420 },
421 }
422 svc, _ := newPruneService(t, manifests, &fakeBlobStorage{}, &fakeProtectionStore{}, runtime)
423 _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{KeepLast: 1, PruneRegistry: true})
424 require.NoError(t, err)
425 assert.Empty(t, runtime.removedImages, "registry-only prune must not touch runtime images")
426}
427
428func TestPrune_RequiresProtectionStore(t *testing.T) {
429 svc := NewService(&fakeRuntime{}, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
430 _, err := svc.Prune(context.Background(), domain.DefaultImagePruneOptions())
431 require.Error(t, err)
432 assert.ErrorIs(t, err, domain.ErrPruneDisabled)
433}
434
435func TestPrune_ProtectionSnapshotFailureFailsClosed(t *testing.T) {
436 protection := &fakeProtectionStore{err: errors.New("unreadable state")}
437 svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, protection, &fakePruneRuntime{})
438 _, err := svc.Prune(context.Background(), domain.DefaultImagePruneOptions())
439 require.Error(t, err)
440 assert.Contains(t, err.Error(), "protection snapshot")
441}
442
443func TestPrune_RuntimeInventoryGapMakesCandidatesUnknown(t *testing.T) {
444 runtime := &fakePruneRuntime{inventory: &domain.RuntimeInventory{
445 Images: []domain.RuntimeImage{{ID: "sha256:dangling", Labels: map[string]string{domain.LabelApp: "app"}}},
446 Gaps: []domain.InventoryGap{{
447 Source: domain.InventorySourceRuntimeContainers,
448 Reason: domain.PruneReasonUnknownContainerUse,
449 Detail: "list containers failed",
450 }},
451 }}
452 svc, _ := newPruneService(t, newFakeManifestStorage(), &fakeBlobStorage{}, &fakeProtectionStore{}, runtime)
453 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{PruneDangling: true})
454 require.NoError(t, err)
455 assert.Empty(t, runtime.removedImages)
456 assert.Equal(t, 1, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
457 require.Len(t, report.Plan.Gaps, 1)
458}
459
460// releasedImageClaim marks identity as durably released by an app. Runtime
461// image deletion requires this positive ownership; labels alone never
462// authorize it.
463func releasedImageClaim(identity string) domain.ImageClaim {
464 return domain.ImageClaim{
465 Reference: identity, App: "app", AppID: "app-uuid",
466 State: domain.VolumeClaimReleased,
467 }
468}
469
470// releasedImageSnapshot is a complete snapshot containing one released
471// runtime image claim.
472func releasedImageSnapshot(identity string) *fakeProtectionStore {
473 return &fakeProtectionStore{snapshot: &domain.PruneProtectionSnapshot{
474 ImageClaims: []domain.ImageClaim{releasedImageClaim(identity)},
475 }}
476}
477
478func mustTagKey(t *testing.T, repository, tag string) string {
479 t.Helper()
480 ref, err := domain.NewRegistryTagRef(repository, tag)
481 require.NoError(t, err)
482 return ref.Key()
483}