1package images
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8 "time"
9
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"
16 pkgruntime "github.com/bnema/gordon/pkg/runtime"
17)
18
19func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20 manifestStorage := newFakeManifestStorage()
21 blobStorage := noopBlobStorage{}
22
23 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
24 rt := &fakeRuntime{
25 listDetails: []pkgruntime.ImageDetail{
26 {
27 ID: "sha256:111",
28 RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"},
29 Size: 1234,
30 Created: createdAt,
31 },
32 {
33 ID: "sha256:222",
34 RepoTags: []string{"<none>:<none>"},
35 Size: 4321,
36 Created: createdAt.Add(-time.Hour),
37 },
38 },
39 }
40
41 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
42
43 images, err := svc.ListImages(context.Background())
44
45 require.NoError(t, err)
46 require.Len(t, images, 3)
47
48 assert.Equal(t, domain.ImageInfo{
49 Repository: "gordon/api",
50 Tag: "latest",
51 Size: 1234,
52 Created: createdAt,
53 ID: "sha256:111",
54 Dangling: false,
55 }, images[0])
56
57 assert.Equal(t, domain.ImageInfo{
58 Repository: "gordon/api",
59 Tag: "v1.2.3",
60 Size: 1234,
61 Created: createdAt,
62 ID: "sha256:111",
63 Dangling: false,
64 }, images[1])
65
66 assert.Equal(t, domain.ImageInfo{
67 Repository: "",
68 Tag: "",
69 Size: 4321,
70 Created: createdAt.Add(-time.Hour),
71 ID: "sha256:222",
72 Dangling: true,
73 }, images[2])
74}
75
76func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77 manifestStorage := newFakeManifestStorage()
78 blobStorage := noopBlobStorage{}
79 rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
80
81 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
82
83 images, err := svc.ListImages(context.Background())
84
85 require.NoError(t, err)
86 assert.Empty(t, images)
87}
88
89func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90 manifestStorage := newFakeManifestStorage()
91 blobStorage := noopBlobStorage{}
92 createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
93
94 rt := &fakeRuntime{
95 listDetails: []pkgruntime.ImageDetail{{
96 ID: "sha256:mixed",
97 RepoTags: []string{"<none>:<none>", "gordon/web:latest"},
98 Size: 100,
99 Created: createdAt,
100 }},
101 }
102
103 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
104
105 images, err := svc.ListImages(context.Background())
106
107 require.NoError(t, err)
108 require.Len(t, images, 1)
109 assert.Equal(t, "gordon/web", images[0].Repository)
110 assert.Equal(t, "latest", images[0].Tag)
111 assert.False(t, images[0].Dangling)
112}
113
114func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115 manifestStorage := newFakeManifestStorage()
116 blobStorage := noopBlobStorage{}
117 rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118
119 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
120
121 _, err := svc.ListImages(context.Background())
122
123 require.Error(t, err)
124 assert.Contains(t, err.Error(), "failed to list images")
125}
126
127func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) {
128 manifestStorage := newFakeManifestStorage()
129 manifestStorage.repositories = []string{"gordon/api"}
130 manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"}
131 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
132 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
133
134 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
135 rt := &fakeRuntime{
136 listDetails: []pkgruntime.ImageDetail{
137 {
138 ID: "sha256:runtime",
139 RepoTags: []string{"reg.bnema.dev/gordon/api:latest"},
140 Size: 4096,
141 Created: createdAt,
142 },
143 },
144 }
145
146 svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default())
147
148 images, err := svc.ListImages(context.Background())
149
150 require.NoError(t, err)
151 require.Len(t, images, 3)
152
153 assert.Equal(t, domain.ImageInfo{
154 Repository: "reg.bnema.dev/gordon/api",
155 Tag: "latest",
156 Size: 4096,
157 Created: createdAt,
158 ID: "sha256:runtime",
159 Dangling: false,
160 }, images[0])
161
162 assert.Equal(t, domain.ImageInfo{
163 Repository: "reg.bnema.dev/gordon/api",
164 Tag: "v1.2.3",
165 Size: 0,
166 Created: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC),
167 ID: "",
168 Dangling: false,
169 }, images[1])
170
171 assert.Equal(t, domain.ImageInfo{
172 Repository: "reg.bnema.dev/gordon/api",
173 Tag: "v1.2.2",
174 Size: 0,
175 Created: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC),
176 ID: "",
177 Dangling: false,
178 }, images[2])
179}
180
181func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
182 manifestStorage := noopManifestStorage{}
183 blobStorage := noopBlobStorage{}
184 rt := &fakeRuntime{
185 pruneReport: pkgruntime.PruneReport{
186 DeletedIDs: []string{"sha256:a", "sha256:b"},
187 SpaceReclaimed: 2048,
188 },
189 }
190
191 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
192
193 report, err := svc.PruneRuntime(context.Background())
194
195 require.NoError(t, err)
196 assert.Equal(t, 2, report.Runtime.DeletedCount)
197 assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
198 assert.True(t, rt.pruneCalled)
199 assert.True(t, rt.pruneDanglingOnly)
200}
201
202func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
203 manifestStorage := noopManifestStorage{}
204 blobStorage := noopBlobStorage{}
205 rt := &fakeRuntime{
206 pruneReport: pkgruntime.PruneReport{
207 DeletedIDs: nil,
208 SpaceReclaimed: 0,
209 },
210 }
211
212 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
213
214 report, err := svc.PruneRuntime(context.Background())
215
216 require.NoError(t, err)
217 assert.Equal(t, 0, report.Runtime.DeletedCount)
218 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
219 assert.True(t, rt.pruneCalled)
220 assert.True(t, rt.pruneDanglingOnly)
221}
222
223func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
224 manifestStorage := noopManifestStorage{}
225 blobStorage := noopBlobStorage{}
226 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
227
228 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
229
230 _, err := svc.PruneRuntime(context.Background())
231
232 require.Error(t, err)
233 assert.Contains(t, err.Error(), "failed to prune runtime images")
234 assert.True(t, rt.pruneCalled)
235 assert.True(t, rt.pruneDanglingOnly)
236}
237
238func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
239 manifestStorage := newFakeManifestStorage()
240 manifestStorage.repositories = []string{"gordon/api"}
241 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
242 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
243 manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
244 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
245 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
246 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
247 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
248 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
249
250 blobStorage := &fakeBlobStorage{}
251 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
252
253 report, err := svc.PruneRegistry(context.Background(), 2)
254
255 require.NoError(t, err)
256 assert.Equal(t, 1, report.Registry.TagsRemoved)
257 assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
258}
259
260func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
261 manifestStorage := newFakeManifestStorage()
262 manifestStorage.repositories = []string{"gordon/api"}
263 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
264 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
265 manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
266 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
267 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
268 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
269
270 blobStorage := &fakeBlobStorage{}
271 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
272
273 report, err := svc.PruneRegistry(context.Background(), 1)
274
275 require.NoError(t, err)
276 assert.Equal(t, 1, report.Registry.TagsRemoved)
277 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
278}
279
280func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
281 manifestStorage := newFakeManifestStorage()
282 manifestStorage.repositories = []string{"gordon/api"}
283 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
284 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
285 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
286 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
287 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
288
289 blobStorage := &fakeBlobStorage{}
290 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
291
292 report, err := svc.PruneRegistry(context.Background(), 5)
293
294 require.NoError(t, err)
295 assert.Equal(t, 0, report.Registry.TagsRemoved)
296 assert.Empty(t, manifestStorage.deletedManifests)
297}
298
299func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
300 manifestStorage := newFakeManifestStorage()
301 manifestStorage.repositories = []string{"gordon/api"}
302 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
303 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
304
305 report, err := svc.PruneRegistry(context.Background(), 0)
306
307 require.NoError(t, err)
308 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
309 assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
310 assert.Empty(t, manifestStorage.deletedManifests)
311 assert.Empty(t, blobStorage.deletedBlobs)
312}
313
314func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
315 manifestStorage := newFakeManifestStorage()
316 manifestStorage.repositories = []string{"gordon/api"}
317 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
318 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
319 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
320
321 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
322 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
323
324 report, err := svc.PruneRegistry(context.Background(), 1)
325
326 require.NoError(t, err)
327 assert.Equal(t, 1, report.Registry.BlobsRemoved)
328 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
329}
330
331func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
332 manifestStorage := newFakeManifestStorage()
333 manifestStorage.repositories = []string{"gordon/api"}
334 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
335 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
336 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
337 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
338 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
339 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared")
340 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
341
342 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}}
343 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
344
345 report, err := svc.PruneRegistry(context.Background(), 1)
346
347 require.NoError(t, err)
348 assert.Equal(t, 1, report.Registry.BlobsRemoved)
349 assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
350}
351
352func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
353 manifestStorage := newFakeManifestStorage()
354 manifestStorage.repositories = []string{"gordon/api"}
355 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
356 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
357 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
358 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
359 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
360
361 blobStorage := &fakeBlobStorage{blobs: []string{
362 "sha256:child-amd64",
363 "sha256:child-arm64",
364 "sha256:cfg-amd64",
365 "sha256:layer-amd64",
366 "sha256:cfg-arm64",
367 "sha256:layer-arm64",
368 "sha256:orphan",
369 }}
370 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
371
372 report, err := svc.PruneRegistry(context.Background(), 1)
373
374 require.NoError(t, err)
375 assert.Equal(t, 1, report.Registry.BlobsRemoved)
376 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
377}
378
379func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
380 manifestStorage := newFakeManifestStorage()
381 manifestStorage.repositories = []string{"gordon/api"}
382 manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
383 tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
384 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
385 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
386 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
387
388 blobStorage := &fakeBlobStorage{}
389 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
390
391 report, err := svc.PruneRegistry(context.Background(), 1)
392
393 require.NoError(t, err)
394 assert.Equal(t, 1, report.Registry.TagsRemoved)
395 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
396}
397
398func TestService_PruneRegistry_LogsWarningAndContinuesWhenChildManifestMissing(t *testing.T) {
399 manifestStorage := newFakeManifestStorage()
400 manifestStorage.repositories = []string{"gordon/api"}
401 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
402 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
403 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
404
405 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
406 logger := zerowrap.Default()
407 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, logger)
408
409 report, err := svc.PruneRegistry(context.Background(), 1)
410
411 // Should not error - missing child manifests are logged as warnings and skipped
412 require.NoError(t, err)
413 assert.Equal(t, 0, report.Registry.TagsRemoved, "no tags should be removed (latest is kept)")
414 assert.Equal(t, 1, report.Registry.BlobsRemoved, "orphan blob should be garbage collected")
415}
416
417func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
418 manifestStorage := newFakeManifestStorage()
419 manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
420 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
421 manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
422 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
423 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
424 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
425 manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
426 manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
427 manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
428 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
429 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
430 manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
431 manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
432
433 blobStorage := &fakeBlobStorage{}
434 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
435
436 report, err := svc.PruneRegistry(context.Background(), 1)
437
438 require.NoError(t, err)
439 assert.Equal(t, 2, report.Registry.TagsRemoved)
440 assert.ElementsMatch(t, []manifestRef{
441 {name: "gordon/api", reference: "v1"},
442 {name: "gordon/web", reference: "v2"},
443 }, manifestStorage.deletedManifests)
444}
445
446func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
447 manifestStorage := newFakeManifestStorage()
448 manifestStorage.repositories = []string{"gordon/api"}
449 manifestStorage.tagsByRepo["gordon/api"] = []string{}
450 blobStorage := &fakeBlobStorage{}
451 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
452
453 report, err := svc.PruneRegistry(context.Background(), 2)
454
455 require.NoError(t, err)
456 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
457 assert.Empty(t, manifestStorage.deletedManifests)
458 assert.Empty(t, blobStorage.deletedBlobs)
459}
460
461func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
462 manifestStorage := newFakeManifestStorage()
463 manifestStorage.repositories = []string{"gordon/api"}
464 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
465 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
466 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
467 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
468 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
469 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
470 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
471 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
472
473 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
474
475 // First prune - should remove v1 and orphan
476 report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
477 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
478 })
479 require.NoError(t, err)
480 assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")
481 assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
482
483 // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
484 rt.pruneReport = pkgruntime.PruneReport{}
485
486 // Second prune - should be idempotent (nothing else to remove)
487 report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
488 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
489 })
490 require.NoError(t, err)
491 assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
492 assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)")
493 assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images")
494 assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space")
495}
496
497func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
498 manifestStorage := newFakeManifestStorage()
499 manifestStorage.repositories = []string{"gordon/api"}
500 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
501 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
502 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
503 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
504 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
505 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
506 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
507 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
508
509 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
510
511 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
512 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
513 })
514
515 require.NoError(t, err)
516 assert.Equal(t, 1, report.Runtime.DeletedCount)
517 assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
518 assert.Equal(t, 1, report.Registry.TagsRemoved)
519 assert.Equal(t, 1, report.Registry.BlobsRemoved)
520 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
521 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
522}
523
524func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
525 manifestStorage := newFakeManifestStorage()
526 manifestStorage.repositories = []string{"gordon/api"}
527 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
528 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
529 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
530 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
531 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
532 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
533 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
534 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
535
536 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
537
538 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
539 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
540 })
541
542 require.NoError(t, err)
543 assert.Equal(t, 0, report.Runtime.DeletedCount)
544 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
545 assert.Equal(t, 1, report.Registry.TagsRemoved)
546 assert.Equal(t, 1, report.Registry.BlobsRemoved)
547 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
548 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
549}
550
551func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) {
552 manifestStorage := newFakeManifestStorage()
553 manifestStorage.repositories = []string{"gordon/api"}
554 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
555 blobStorage := &fakeBlobStorage{}
556 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}}
557
558 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
559
560 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
561 KeepLast: 3, PruneDangling: true, PruneRegistry: false,
562 })
563
564 require.NoError(t, err)
565 assert.True(t, rt.pruneCalled, "runtime prune should have been called")
566 assert.Equal(t, 1, report.Runtime.DeletedCount)
567 assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned")
568 assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled")
569}
570
571func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
572 rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")}
573 svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
574
575 _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
576 KeepLast: 3, PruneDangling: true, PruneRegistry: false,
577 })
578
579 require.Error(t, err)
580 assert.Contains(t, err.Error(), "runtime prune failed")
581 assert.Contains(t, err.Error(), "docker daemon unavailable")
582}
583
584func TestService_PruneRegistry_CleansUpStaleUploads(t *testing.T) {
585 manifestStorage := newFakeManifestStorage()
586 blobStorage := &fakeCleanupBlobStorage{
587 fakeBlobStorage: &fakeBlobStorage{},
588 uploadsRemoved: 3,
589 uploadBytes: 1024 * 1024,
590 }
591 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
592
593 report, err := svc.PruneRegistry(context.Background(), 5)
594 require.NoError(t, err)
595 assert.Equal(t, 3, report.Registry.UploadsRemoved)
596 assert.Equal(t, int64(1024*1024), report.Registry.UploadSpaceReclaimed)
597 assert.Equal(t, 24*time.Hour, blobStorage.capturedMaxAge)
598}
599
600func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
601 manifestStorage := newFakeManifestStorage()
602 manifestStorage.repositories = []string{"gordon/api"}
603 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
604 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
605 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
606 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
607 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
608 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
609 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
610 rt := &fakeRuntime{}
611
612 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
613
614 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
615 KeepLast: 1, PruneDangling: false, PruneRegistry: true,
616 })
617
618 require.NoError(t, err)
619 assert.False(t, rt.pruneCalled, "runtime prune should not have been called")
620 assert.Equal(t, 0, report.Runtime.DeletedCount)
621 assert.Equal(t, 1, report.Registry.TagsRemoved)
622 assert.Equal(t, 1, report.Registry.BlobsRemoved)
623}
624
625type fakeRuntime struct {
626 pkgruntime.Runtime
627
628 listDetails []pkgruntime.ImageDetail
629 listErr error
630
631 pruneReport pkgruntime.PruneReport
632 pruneErr error
633 pruneCalled bool
634 pruneDanglingOnly bool
635}
636
637type noopManifestStorage struct {
638 out.ManifestStorage
639}
640
641type noopBlobStorage struct {
642 out.BlobStorage
643}
644
645type manifestRef struct {
646 name string
647 reference string
648}
649
650type fakeManifestStorage struct {
651 out.ManifestStorage
652
653 repositories []string
654 tagsByRepo map[string][]string
655 modTimes map[string]time.Time
656 manifests map[string][]byte
657
658 listRepositoriesCalls int
659 deletedManifests []manifestRef
660}
661
662type fakeBlobStorage struct {
663 out.BlobStorage
664
665 blobs []string
666 deletedBlobs []string
667}
668
669type fakeCleanupBlobStorage struct {
670 *fakeBlobStorage
671 uploadsRemoved int
672 uploadBytes int64
673 capturedMaxAge time.Duration
674}
675
676func (f *fakeCleanupBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
677 f.capturedMaxAge = maxAge
678 return f.uploadsRemoved, f.uploadBytes, nil
679}
680
681func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
682 return f.listDetails, f.listErr
683}
684
685func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
686 f.pruneCalled = true
687 f.pruneDanglingOnly = danglingOnly
688 return f.pruneReport, f.pruneErr
689}
690
691func newFakeManifestStorage() *fakeManifestStorage {
692 return &fakeManifestStorage{
693 tagsByRepo: make(map[string][]string),
694 modTimes: make(map[string]time.Time),
695 manifests: make(map[string][]byte),
696 }
697}
698
699func manifestRefKey(name, reference string) string {
700 return name + "@" + reference
701}
702
703func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
704 t.Helper()
705
706 type descriptor struct {
707 Digest string `json:"digest"`
708 }
709
710 payload := struct {
711 Config descriptor `json:"config"`
712 Layers []descriptor `json:"layers"`
713 }{
714 Config: descriptor{Digest: configDigest},
715 Layers: make([]descriptor, 0, len(layerDigests)),
716 }
717
718 for _, digest := range layerDigests {
719 payload.Layers = append(payload.Layers, descriptor{Digest: digest})
720 }
721
722 data, err := json.Marshal(payload)
723 require.NoError(t, err)
724 return data
725}
726
727func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
728 t.Helper()
729
730 type descriptor struct {
731 Digest string `json:"digest"`
732 }
733
734 payload := struct {
735 Manifests []descriptor `json:"manifests"`
736 }{
737 Manifests: make([]descriptor, 0, len(digests)),
738 }
739
740 for _, digest := range digests {
741 payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
742 }
743
744 data, err := json.Marshal(payload)
745 require.NoError(t, err)
746 return data
747}
748
749func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
750 f.listRepositoriesCalls++
751 return append([]string(nil), f.repositories...), nil
752}
753
754func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
755 return append([]string(nil), f.tagsByRepo[name]...), nil
756}
757
758func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
759 modTime, ok := f.modTimes[manifestRefKey(name, reference)]
760 if !ok {
761 return time.Time{}, errors.New("manifest modtime not found")
762 }
763 return modTime, nil
764}
765
766func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
767 f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
768 tags := f.tagsByRepo[name]
769 filtered := tags[:0]
770 for _, tag := range tags {
771 if tag != reference {
772 filtered = append(filtered, tag)
773 }
774 }
775 f.tagsByRepo[name] = filtered
776 delete(f.manifests, manifestRefKey(name, reference))
777 return nil
778}
779
780func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
781 manifest, ok := f.manifests[manifestRefKey(name, reference)]
782 if !ok {
783 return nil, "", errors.New("manifest not found")
784 }
785 return manifest, "application/vnd.oci.image.manifest.v1+json", nil
786}
787
788func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
789 return append([]string(nil), f.blobs...), nil
790}
791
792func (f *fakeBlobStorage) DeleteBlob(digest string) error {
793 f.deletedBlobs = append(f.deletedBlobs, digest)
794 for i, existing := range f.blobs {
795 if existing != digest {
796 continue
797 }
798 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
799 break
800 }
801 return nil
802}
803
804func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
805 return 0, 0, nil
806}
807
808func TestSplitRepoTag(t *testing.T) {
809 tests := []struct {
810 name string
811 input string
812 wantRepo string
813 wantTag string
814 }{
815 {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
816 {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
817 {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
818 {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
819 {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
820 {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
821 {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
822 }
823
824 for _, tt := range tests {
825 t.Run(tt.name, func(t *testing.T) {
826 repo, tag := splitRepoTag(tt.input)
827 assert.Equal(t, tt.wantRepo, repo)
828 assert.Equal(t, tt.wantTag, tag)
829 })
830 }
831}
832
833var _ pkgruntime.Runtime = (*fakeRuntime)(nil)