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{
322 blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"},
323 blobSizes: map[string]int64{"sha256:orphan": 4096},
324 }
325 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
326
327 report, err := svc.PruneRegistry(context.Background(), 1)
328
329 require.NoError(t, err)
330 assert.Equal(t, 1, report.Registry.BlobsRemoved)
331 assert.Equal(t, int64(4096), report.Registry.SpaceReclaimed)
332 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
333}
334
335func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
336 manifestStorage := newFakeManifestStorage()
337 manifestStorage.repositories = []string{"gordon/api"}
338 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
339 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
340 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
341 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
342 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
343 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared")
344 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
345
346 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}}
347 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
348
349 report, err := svc.PruneRegistry(context.Background(), 1)
350
351 require.NoError(t, err)
352 assert.Equal(t, 1, report.Registry.BlobsRemoved)
353 assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
354}
355
356func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
357 manifestStorage := newFakeManifestStorage()
358 manifestStorage.repositories = []string{"gordon/api"}
359 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
360 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
361 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
362 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
363 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
364
365 blobStorage := &fakeBlobStorage{blobs: []string{
366 "sha256:child-amd64",
367 "sha256:child-arm64",
368 "sha256:cfg-amd64",
369 "sha256:layer-amd64",
370 "sha256:cfg-arm64",
371 "sha256:layer-arm64",
372 "sha256:orphan",
373 }}
374 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
375
376 report, err := svc.PruneRegistry(context.Background(), 1)
377
378 require.NoError(t, err)
379 assert.Equal(t, 1, report.Registry.BlobsRemoved)
380 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
381}
382
383func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
384 manifestStorage := newFakeManifestStorage()
385 manifestStorage.repositories = []string{"gordon/api"}
386 manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
387 tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
388 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
389 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
390 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
391
392 blobStorage := &fakeBlobStorage{}
393 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
394
395 report, err := svc.PruneRegistry(context.Background(), 1)
396
397 require.NoError(t, err)
398 assert.Equal(t, 1, report.Registry.TagsRemoved)
399 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
400}
401
402func TestService_PruneRegistry_LogsWarningAndContinuesWhenChildManifestMissing(t *testing.T) {
403 manifestStorage := newFakeManifestStorage()
404 manifestStorage.repositories = []string{"gordon/api"}
405 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
406 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
407 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
408
409 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
410 logger := zerowrap.Default()
411 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, logger)
412
413 report, err := svc.PruneRegistry(context.Background(), 1)
414
415 // Should not error - missing child manifests are logged as warnings and skipped
416 require.NoError(t, err)
417 assert.Equal(t, 0, report.Registry.TagsRemoved, "no tags should be removed (latest is kept)")
418 assert.Equal(t, 1, report.Registry.BlobsRemoved, "orphan blob should be garbage collected")
419}
420
421func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
422 manifestStorage := newFakeManifestStorage()
423 manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
424 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
425 manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
426 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
427 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
428 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
429 manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
430 manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
431 manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
432 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
433 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
434 manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
435 manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
436
437 blobStorage := &fakeBlobStorage{}
438 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
439
440 report, err := svc.PruneRegistry(context.Background(), 1)
441
442 require.NoError(t, err)
443 assert.Equal(t, 2, report.Registry.TagsRemoved)
444 assert.ElementsMatch(t, []manifestRef{
445 {name: "gordon/api", reference: "v1"},
446 {name: "gordon/web", reference: "v2"},
447 }, manifestStorage.deletedManifests)
448}
449
450func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
451 manifestStorage := newFakeManifestStorage()
452 manifestStorage.repositories = []string{"gordon/api"}
453 manifestStorage.tagsByRepo["gordon/api"] = []string{}
454 blobStorage := &fakeBlobStorage{}
455 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
456
457 report, err := svc.PruneRegistry(context.Background(), 2)
458
459 require.NoError(t, err)
460 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
461 assert.Empty(t, manifestStorage.deletedManifests)
462 assert.Empty(t, blobStorage.deletedBlobs)
463}
464
465func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) {
466 manifestStorage := newFakeManifestStorage()
467 manifestStorage.repositories = []string{"gordon/api"}
468 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
469 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
470 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
471 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
472 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
473 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
474 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
475 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
476
477 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
478
479 // First prune - should remove v1 and orphan
480 report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
481 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
482 })
483 require.NoError(t, err)
484 assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1")
485 assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan")
486
487 // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time)
488 rt.pruneReport = pkgruntime.PruneReport{}
489
490 // Second prune - should be idempotent (nothing else to remove)
491 report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
492 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
493 })
494 require.NoError(t, err)
495 assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)")
496 assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)")
497 assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images")
498 assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space")
499}
500
501func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
502 manifestStorage := newFakeManifestStorage()
503 manifestStorage.repositories = []string{"gordon/api"}
504 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
505 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
506 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
507 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
508 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
509 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
510 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
511 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
512
513 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
514
515 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
516 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
517 })
518
519 require.NoError(t, err)
520 assert.Equal(t, 1, report.Runtime.DeletedCount)
521 assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
522 assert.Equal(t, 1, report.Registry.TagsRemoved)
523 assert.Equal(t, 1, report.Registry.BlobsRemoved)
524 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
525 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
526}
527
528func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
529 manifestStorage := newFakeManifestStorage()
530 manifestStorage.repositories = []string{"gordon/api"}
531 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
532 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
533 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
534 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
535 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
536 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
537 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
538 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
539
540 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
541
542 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
543 KeepLast: 1, PruneDangling: true, PruneRegistry: true,
544 })
545
546 require.NoError(t, err)
547 assert.Equal(t, 0, report.Runtime.DeletedCount)
548 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
549 assert.Equal(t, 1, report.Registry.TagsRemoved)
550 assert.Equal(t, 1, report.Registry.BlobsRemoved)
551 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
552 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
553}
554
555func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) {
556 manifestStorage := newFakeManifestStorage()
557 manifestStorage.repositories = []string{"gordon/api"}
558 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
559 blobStorage := &fakeBlobStorage{}
560 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}}
561
562 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
563
564 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
565 KeepLast: 3, PruneDangling: true, PruneRegistry: false,
566 })
567
568 require.NoError(t, err)
569 assert.True(t, rt.pruneCalled, "runtime prune should have been called")
570 assert.Equal(t, 1, report.Runtime.DeletedCount)
571 assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned")
572 assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled")
573}
574
575func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
576 rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")}
577 svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default())
578
579 _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
580 KeepLast: 3, PruneDangling: true, PruneRegistry: false,
581 })
582
583 require.Error(t, err)
584 assert.Contains(t, err.Error(), "runtime prune failed")
585 assert.Contains(t, err.Error(), "docker daemon unavailable")
586}
587
588func TestService_PruneRegistry_CleansUpStaleUploads(t *testing.T) {
589 manifestStorage := newFakeManifestStorage()
590 blobStorage := &fakeCleanupBlobStorage{
591 fakeBlobStorage: &fakeBlobStorage{},
592 uploadsRemoved: 3,
593 uploadBytes: 1024 * 1024,
594 }
595 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
596
597 report, err := svc.PruneRegistry(context.Background(), 5)
598 require.NoError(t, err)
599 assert.Equal(t, 3, report.Registry.UploadsRemoved)
600 assert.Equal(t, int64(1024*1024), report.Registry.UploadSpaceReclaimed)
601 assert.Equal(t, 24*time.Hour, blobStorage.capturedMaxAge)
602}
603
604func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
605 manifestStorage := newFakeManifestStorage()
606 manifestStorage.repositories = []string{"gordon/api"}
607 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
608 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
609 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
610 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
611 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
612 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
613 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
614 rt := &fakeRuntime{}
615
616 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
617
618 report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{
619 KeepLast: 1, PruneDangling: false, PruneRegistry: true,
620 })
621
622 require.NoError(t, err)
623 assert.False(t, rt.pruneCalled, "runtime prune should not have been called")
624 assert.Equal(t, 0, report.Runtime.DeletedCount)
625 assert.Equal(t, 1, report.Registry.TagsRemoved)
626 assert.Equal(t, 1, report.Registry.BlobsRemoved)
627}
628
629type fakeRuntime struct {
630 pkgruntime.Runtime
631
632 listDetails []pkgruntime.ImageDetail
633 listErr error
634
635 pruneReport pkgruntime.PruneReport
636 pruneErr error
637 pruneCalled bool
638 pruneDanglingOnly bool
639}
640
641type noopManifestStorage struct {
642 out.ManifestStorage
643}
644
645type noopBlobStorage struct {
646 out.BlobStorage
647}
648
649type manifestRef struct {
650 name string
651 reference string
652}
653
654type fakeManifestStorage struct {
655 out.ManifestStorage
656
657 repositories []string
658 tagsByRepo map[string][]string
659 modTimes map[string]time.Time
660 manifests map[string][]byte
661
662 listRepositoriesCalls int
663 deletedManifests []manifestRef
664}
665
666type fakeBlobStorage struct {
667 out.BlobStorage
668
669 blobs []string
670 blobSizes map[string]int64
671 deletedBlobs []string
672}
673
674type fakeCleanupBlobStorage struct {
675 *fakeBlobStorage
676 uploadsRemoved int
677 uploadBytes int64
678 capturedMaxAge time.Duration
679}
680
681func (f *fakeCleanupBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
682 f.capturedMaxAge = maxAge
683 return f.uploadsRemoved, f.uploadBytes, nil
684}
685
686func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
687 return f.listDetails, f.listErr
688}
689
690func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
691 f.pruneCalled = true
692 f.pruneDanglingOnly = danglingOnly
693 return f.pruneReport, f.pruneErr
694}
695
696func newFakeManifestStorage() *fakeManifestStorage {
697 return &fakeManifestStorage{
698 tagsByRepo: make(map[string][]string),
699 modTimes: make(map[string]time.Time),
700 manifests: make(map[string][]byte),
701 }
702}
703
704func manifestRefKey(name, reference string) string {
705 return name + "@" + reference
706}
707
708func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
709 t.Helper()
710
711 type descriptor struct {
712 Digest string `json:"digest"`
713 }
714
715 payload := struct {
716 Config descriptor `json:"config"`
717 Layers []descriptor `json:"layers"`
718 }{
719 Config: descriptor{Digest: configDigest},
720 Layers: make([]descriptor, 0, len(layerDigests)),
721 }
722
723 for _, digest := range layerDigests {
724 payload.Layers = append(payload.Layers, descriptor{Digest: digest})
725 }
726
727 data, err := json.Marshal(payload)
728 require.NoError(t, err)
729 return data
730}
731
732func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
733 t.Helper()
734
735 type descriptor struct {
736 Digest string `json:"digest"`
737 }
738
739 payload := struct {
740 Manifests []descriptor `json:"manifests"`
741 }{
742 Manifests: make([]descriptor, 0, len(digests)),
743 }
744
745 for _, digest := range digests {
746 payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
747 }
748
749 data, err := json.Marshal(payload)
750 require.NoError(t, err)
751 return data
752}
753
754func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
755 f.listRepositoriesCalls++
756 return append([]string(nil), f.repositories...), nil
757}
758
759func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
760 return append([]string(nil), f.tagsByRepo[name]...), nil
761}
762
763func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
764 modTime, ok := f.modTimes[manifestRefKey(name, reference)]
765 if !ok {
766 return time.Time{}, errors.New("manifest modtime not found")
767 }
768 return modTime, nil
769}
770
771func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
772 f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
773 tags := f.tagsByRepo[name]
774 filtered := tags[:0]
775 for _, tag := range tags {
776 if tag != reference {
777 filtered = append(filtered, tag)
778 }
779 }
780 f.tagsByRepo[name] = filtered
781 delete(f.manifests, manifestRefKey(name, reference))
782 return nil
783}
784
785func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
786 manifest, ok := f.manifests[manifestRefKey(name, reference)]
787 if !ok {
788 return nil, "", errors.New("manifest not found")
789 }
790 return manifest, "application/vnd.oci.image.manifest.v1+json", nil
791}
792
793func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
794 return append([]string(nil), f.blobs...), nil
795}
796
797func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
798 f.deletedBlobs = append(f.deletedBlobs, digest)
799 var size int64
800 if f.blobSizes != nil {
801 size = f.blobSizes[digest]
802 }
803 for i, existing := range f.blobs {
804 if existing != digest {
805 continue
806 }
807 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
808 break
809 }
810 return size, nil
811}
812
813func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
814 return 0, 0, nil
815}
816
817func TestSplitRepoTag(t *testing.T) {
818 tests := []struct {
819 name string
820 input string
821 wantRepo string
822 wantTag string
823 }{
824 {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
825 {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
826 {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
827 {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
828 {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
829 {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
830 {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
831 }
832
833 for _, tt := range tests {
834 t.Run(tt.name, func(t *testing.T) {
835 repo, tag := splitRepoTag(tt.input)
836 assert.Equal(t, tt.wantRepo, repo)
837 assert.Equal(t, tt.wantTag, tag)
838 })
839 }
840}
841
842var _ pkgruntime.Runtime = (*fakeRuntime)(nil)