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 := noopManifestStorage{}
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 := noopManifestStorage{}
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 := noopManifestStorage{}
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 := noopManifestStorage{}
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_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128 manifestStorage := noopManifestStorage{}
129 blobStorage := noopBlobStorage{}
130 rt := &fakeRuntime{
131 pruneReport: pkgruntime.PruneReport{
132 DeletedIDs: []string{"sha256:a", "sha256:b"},
133 SpaceReclaimed: 2048,
134 },
135 }
136
137 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
138
139 report, err := svc.PruneRuntime(context.Background())
140
141 require.NoError(t, err)
142 assert.Equal(t, 2, report.Runtime.DeletedCount)
143 assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
144 assert.True(t, rt.pruneCalled)
145 assert.True(t, rt.pruneDanglingOnly)
146}
147
148func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
149 manifestStorage := noopManifestStorage{}
150 blobStorage := noopBlobStorage{}
151 rt := &fakeRuntime{
152 pruneReport: pkgruntime.PruneReport{
153 DeletedIDs: nil,
154 SpaceReclaimed: 0,
155 },
156 }
157
158 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
159
160 report, err := svc.PruneRuntime(context.Background())
161
162 require.NoError(t, err)
163 assert.Equal(t, 0, report.Runtime.DeletedCount)
164 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
165 assert.True(t, rt.pruneCalled)
166 assert.True(t, rt.pruneDanglingOnly)
167}
168
169func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
170 manifestStorage := noopManifestStorage{}
171 blobStorage := noopBlobStorage{}
172 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
173
174 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
175
176 _, err := svc.PruneRuntime(context.Background())
177
178 require.Error(t, err)
179 assert.Contains(t, err.Error(), "failed to prune runtime images")
180 assert.True(t, rt.pruneCalled)
181 assert.True(t, rt.pruneDanglingOnly)
182}
183
184func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
185 manifestStorage := newFakeManifestStorage()
186 manifestStorage.repositories = []string{"gordon/api"}
187 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
188 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
189 manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
190 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
191 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
194
195 blobStorage := &fakeBlobStorage{}
196 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
197
198 report, err := svc.PruneRegistry(context.Background(), 2)
199
200 require.NoError(t, err)
201 assert.Equal(t, 2, report.Registry.TagsRemoved)
202 assert.ElementsMatch(t, []manifestRef{
203 {name: "gordon/api", reference: "v2"},
204 {name: "gordon/api", reference: "v1"},
205 }, manifestStorage.deletedManifests)
206}
207
208func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
209 manifestStorage := newFakeManifestStorage()
210 manifestStorage.repositories = []string{"gordon/api"}
211 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
212 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
213 manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
214 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
215 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
216 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
217
218 blobStorage := &fakeBlobStorage{}
219 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
220
221 report, err := svc.PruneRegistry(context.Background(), 1)
222
223 require.NoError(t, err)
224 assert.Equal(t, 1, report.Registry.TagsRemoved)
225 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
226}
227
228func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
229 manifestStorage := newFakeManifestStorage()
230 manifestStorage.repositories = []string{"gordon/api"}
231 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
232 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
233 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
234 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
235 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
236
237 blobStorage := &fakeBlobStorage{}
238 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
239
240 report, err := svc.PruneRegistry(context.Background(), 5)
241
242 require.NoError(t, err)
243 assert.Equal(t, 0, report.Registry.TagsRemoved)
244 assert.Empty(t, manifestStorage.deletedManifests)
245}
246
247func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
248 manifestStorage := newFakeManifestStorage()
249 manifestStorage.repositories = []string{"gordon/api"}
250 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
251 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
252
253 report, err := svc.PruneRegistry(context.Background(), 0)
254
255 require.NoError(t, err)
256 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
257 assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
258 assert.Empty(t, manifestStorage.deletedManifests)
259 assert.Empty(t, blobStorage.deletedBlobs)
260}
261
262func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
263 manifestStorage := newFakeManifestStorage()
264 manifestStorage.repositories = []string{"gordon/api"}
265 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
266 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
267 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
268
269 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
270 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
271
272 report, err := svc.PruneRegistry(context.Background(), 1)
273
274 require.NoError(t, err)
275 assert.Equal(t, 1, report.Registry.BlobsRemoved)
276 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
277}
278
279func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280 manifestStorage := newFakeManifestStorage()
281 manifestStorage.repositories = []string{"gordon/api"}
282 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
283 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
284 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
286 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287
288 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
289 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290
291 report, err := svc.PruneRegistry(context.Background(), 1)
292
293 require.NoError(t, err)
294 assert.Equal(t, 1, report.Registry.BlobsRemoved)
295 assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
296}
297
298func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
299 manifestStorage := newFakeManifestStorage()
300 manifestStorage.repositories = []string{"gordon/api"}
301 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
302 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
303 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
304 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
305 manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
306
307 blobStorage := &fakeBlobStorage{blobs: []string{
308 "sha256:child-amd64",
309 "sha256:child-arm64",
310 "sha256:cfg-amd64",
311 "sha256:layer-amd64",
312 "sha256:cfg-arm64",
313 "sha256:layer-arm64",
314 "sha256:orphan",
315 }}
316 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
317
318 report, err := svc.PruneRegistry(context.Background(), 1)
319
320 require.NoError(t, err)
321 assert.Equal(t, 1, report.Registry.BlobsRemoved)
322 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
323}
324
325func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
326 manifestStorage := newFakeManifestStorage()
327 manifestStorage.repositories = []string{"gordon/api"}
328 manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
329 tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
330 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
331 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
332 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
333
334 blobStorage := &fakeBlobStorage{}
335 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
336
337 report, err := svc.PruneRegistry(context.Background(), 1)
338
339 require.NoError(t, err)
340 assert.Equal(t, 1, report.Registry.TagsRemoved)
341 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
342}
343
344func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
345 manifestStorage := newFakeManifestStorage()
346 manifestStorage.repositories = []string{"gordon/api"}
347 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
348 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
349 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
350
351 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
352 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
353
354 _, err := svc.PruneRegistry(context.Background(), 1)
355
356 require.Error(t, err)
357 assert.Contains(t, err.Error(), "failed to read manifest")
358}
359
360func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
361 manifestStorage := newFakeManifestStorage()
362 manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
363 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
364 manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
365 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
366 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
367 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
368 manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
369 manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
370 manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
371 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
372 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
373 manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
374 manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
375
376 blobStorage := &fakeBlobStorage{}
377 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378
379 report, err := svc.PruneRegistry(context.Background(), 2)
380
381 require.NoError(t, err)
382 assert.Equal(t, 2, report.Registry.TagsRemoved)
383 assert.ElementsMatch(t, []manifestRef{
384 {name: "gordon/api", reference: "v1"},
385 {name: "gordon/web", reference: "v2"},
386 }, manifestStorage.deletedManifests)
387}
388
389func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
390 manifestStorage := newFakeManifestStorage()
391 manifestStorage.repositories = []string{"gordon/api"}
392 manifestStorage.tagsByRepo["gordon/api"] = []string{}
393 blobStorage := &fakeBlobStorage{}
394 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
395
396 report, err := svc.PruneRegistry(context.Background(), 2)
397
398 require.NoError(t, err)
399 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
400 assert.Empty(t, manifestStorage.deletedManifests)
401 assert.Empty(t, blobStorage.deletedBlobs)
402}
403
404func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405 manifestStorage := newFakeManifestStorage()
406 manifestStorage.repositories = []string{"gordon/api"}
407 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
408 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
409 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
411 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413
414 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
415
416 report, err := svc.Prune(context.Background(), 1)
417
418 require.NoError(t, err)
419 assert.Equal(t, 1, report.Runtime.DeletedCount)
420 assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
421 assert.Equal(t, 1, report.Registry.TagsRemoved)
422 assert.Equal(t, 1, report.Registry.BlobsRemoved)
423 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
424 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
425}
426
427func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428 manifestStorage := newFakeManifestStorage()
429 manifestStorage.repositories = []string{"gordon/api"}
430 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
431 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
432 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
434 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436
437 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
438
439 report, err := svc.Prune(context.Background(), 1)
440
441 require.NoError(t, err)
442 assert.Equal(t, 0, report.Runtime.DeletedCount)
443 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
444 assert.Equal(t, 1, report.Registry.TagsRemoved)
445 assert.Equal(t, 1, report.Registry.BlobsRemoved)
446 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
447 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
448}
449
450type fakeRuntime struct {
451 pkgruntime.Runtime
452
453 listDetails []pkgruntime.ImageDetail
454 listErr error
455
456 pruneReport pkgruntime.PruneReport
457 pruneErr error
458 pruneCalled bool
459 pruneDanglingOnly bool
460}
461
462type noopManifestStorage struct {
463 out.ManifestStorage
464}
465
466type noopBlobStorage struct {
467 out.BlobStorage
468}
469
470type manifestRef struct {
471 name string
472 reference string
473}
474
475type fakeManifestStorage struct {
476 out.ManifestStorage
477
478 repositories []string
479 tagsByRepo map[string][]string
480 modTimes map[string]time.Time
481 manifests map[string][]byte
482
483 listRepositoriesCalls int
484 deletedManifests []manifestRef
485}
486
487type fakeBlobStorage struct {
488 out.BlobStorage
489
490 blobs []string
491 deletedBlobs []string
492}
493
494func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
495 return f.listDetails, f.listErr
496}
497
498func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
499 f.pruneCalled = true
500 f.pruneDanglingOnly = danglingOnly
501 return f.pruneReport, f.pruneErr
502}
503
504func newFakeManifestStorage() *fakeManifestStorage {
505 return &fakeManifestStorage{
506 tagsByRepo: make(map[string][]string),
507 modTimes: make(map[string]time.Time),
508 manifests: make(map[string][]byte),
509 }
510}
511
512func manifestRefKey(name, reference string) string {
513 return name + "@" + reference
514}
515
516func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
517 t.Helper()
518
519 type descriptor struct {
520 Digest string `json:"digest"`
521 }
522
523 payload := struct {
524 Config descriptor `json:"config"`
525 Layers []descriptor `json:"layers"`
526 }{
527 Config: descriptor{Digest: configDigest},
528 Layers: make([]descriptor, 0, len(layerDigests)),
529 }
530
531 for _, digest := range layerDigests {
532 payload.Layers = append(payload.Layers, descriptor{Digest: digest})
533 }
534
535 data, err := json.Marshal(payload)
536 require.NoError(t, err)
537 return data
538}
539
540func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
541 t.Helper()
542
543 type descriptor struct {
544 Digest string `json:"digest"`
545 }
546
547 payload := struct {
548 Manifests []descriptor `json:"manifests"`
549 }{
550 Manifests: make([]descriptor, 0, len(digests)),
551 }
552
553 for _, digest := range digests {
554 payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
555 }
556
557 data, err := json.Marshal(payload)
558 require.NoError(t, err)
559 return data
560}
561
562func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
563 f.listRepositoriesCalls++
564 return append([]string(nil), f.repositories...), nil
565}
566
567func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
568 return append([]string(nil), f.tagsByRepo[name]...), nil
569}
570
571func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
572 modTime, ok := f.modTimes[manifestRefKey(name, reference)]
573 if !ok {
574 return time.Time{}, errors.New("manifest modtime not found")
575 }
576 return modTime, nil
577}
578
579func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
580 f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
581 tags := f.tagsByRepo[name]
582 filtered := tags[:0]
583 for _, tag := range tags {
584 if tag != reference {
585 filtered = append(filtered, tag)
586 }
587 }
588 f.tagsByRepo[name] = filtered
589 delete(f.manifests, manifestRefKey(name, reference))
590 return nil
591}
592
593func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
594 manifest, ok := f.manifests[manifestRefKey(name, reference)]
595 if !ok {
596 return nil, "", errors.New("manifest not found")
597 }
598 return manifest, "application/vnd.oci.image.manifest.v1+json", nil
599}
600
601func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
602 return append([]string(nil), f.blobs...), nil
603}
604
605func (f *fakeBlobStorage) DeleteBlob(digest string) error {
606 f.deletedBlobs = append(f.deletedBlobs, digest)
607 for i, existing := range f.blobs {
608 if existing != digest {
609 continue
610 }
611 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
612 break
613 }
614 return nil
615}
616
617func TestSplitRepoTag(t *testing.T) {
618 tests := []struct {
619 name string
620 input string
621 wantRepo string
622 wantTag string
623 }{
624 {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
625 {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
626 {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
627 {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
628 {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
629 {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
630 {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
631 }
632
633 for _, tt := range tests {
634 t.Run(tt.name, func(t *testing.T) {
635 repo, tag := splitRepoTag(tt.input)
636 assert.Equal(t, tt.wantRepo, repo)
637 assert.Equal(t, tt.wantTag, tag)
638 })
639 }
640}
641
642var _ pkgruntime.Runtime = (*fakeRuntime)(nil)