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_ReturnsErrorWhenChildManifestMissing(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 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
407
408 _, err := svc.PruneRegistry(context.Background(), 1)
409
410 require.Error(t, err)
411 assert.Contains(t, err.Error(), "failed to read manifest")
412}
413
414func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
415 manifestStorage := newFakeManifestStorage()
416 manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
417 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
418 manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
419 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
420 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
421 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
422 manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
423 manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
424 manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
425 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
426 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
427 manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
428 manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
429
430 blobStorage := &fakeBlobStorage{}
431 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
432
433 report, err := svc.PruneRegistry(context.Background(), 1)
434
435 require.NoError(t, err)
436 assert.Equal(t, 2, report.Registry.TagsRemoved)
437 assert.ElementsMatch(t, []manifestRef{
438 {name: "gordon/api", reference: "v1"},
439 {name: "gordon/web", reference: "v2"},
440 }, manifestStorage.deletedManifests)
441}
442
443func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
444 manifestStorage := newFakeManifestStorage()
445 manifestStorage.repositories = []string{"gordon/api"}
446 manifestStorage.tagsByRepo["gordon/api"] = []string{}
447 blobStorage := &fakeBlobStorage{}
448 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
449
450 report, err := svc.PruneRegistry(context.Background(), 2)
451
452 require.NoError(t, err)
453 assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
454 assert.Empty(t, manifestStorage.deletedManifests)
455 assert.Empty(t, blobStorage.deletedBlobs)
456}
457
458func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
459 manifestStorage := newFakeManifestStorage()
460 manifestStorage.repositories = []string{"gordon/api"}
461 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
462 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
463 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
464 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
465 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
466 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
467 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
468 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
469
470 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
471
472 report, err := svc.Prune(context.Background(), 1)
473
474 require.NoError(t, err)
475 assert.Equal(t, 1, report.Runtime.DeletedCount)
476 assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
477 assert.Equal(t, 1, report.Registry.TagsRemoved)
478 assert.Equal(t, 1, report.Registry.BlobsRemoved)
479 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
480 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
481}
482
483func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
484 manifestStorage := newFakeManifestStorage()
485 manifestStorage.repositories = []string{"gordon/api"}
486 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
487 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
488 manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
489 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
490 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
491 manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
492 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
493 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
494
495 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
496
497 report, err := svc.Prune(context.Background(), 1)
498
499 require.NoError(t, err)
500 assert.Equal(t, 0, report.Runtime.DeletedCount)
501 assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
502 assert.Equal(t, 1, report.Registry.TagsRemoved)
503 assert.Equal(t, 1, report.Registry.BlobsRemoved)
504 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
505 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
506}
507
508type fakeRuntime struct {
509 pkgruntime.Runtime
510
511 listDetails []pkgruntime.ImageDetail
512 listErr error
513
514 pruneReport pkgruntime.PruneReport
515 pruneErr error
516 pruneCalled bool
517 pruneDanglingOnly bool
518}
519
520type noopManifestStorage struct {
521 out.ManifestStorage
522}
523
524type noopBlobStorage struct {
525 out.BlobStorage
526}
527
528type manifestRef struct {
529 name string
530 reference string
531}
532
533type fakeManifestStorage struct {
534 out.ManifestStorage
535
536 repositories []string
537 tagsByRepo map[string][]string
538 modTimes map[string]time.Time
539 manifests map[string][]byte
540
541 listRepositoriesCalls int
542 deletedManifests []manifestRef
543}
544
545type fakeBlobStorage struct {
546 out.BlobStorage
547
548 blobs []string
549 deletedBlobs []string
550}
551
552func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
553 return f.listDetails, f.listErr
554}
555
556func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
557 f.pruneCalled = true
558 f.pruneDanglingOnly = danglingOnly
559 return f.pruneReport, f.pruneErr
560}
561
562func newFakeManifestStorage() *fakeManifestStorage {
563 return &fakeManifestStorage{
564 tagsByRepo: make(map[string][]string),
565 modTimes: make(map[string]time.Time),
566 manifests: make(map[string][]byte),
567 }
568}
569
570func manifestRefKey(name, reference string) string {
571 return name + "@" + reference
572}
573
574func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
575 t.Helper()
576
577 type descriptor struct {
578 Digest string `json:"digest"`
579 }
580
581 payload := struct {
582 Config descriptor `json:"config"`
583 Layers []descriptor `json:"layers"`
584 }{
585 Config: descriptor{Digest: configDigest},
586 Layers: make([]descriptor, 0, len(layerDigests)),
587 }
588
589 for _, digest := range layerDigests {
590 payload.Layers = append(payload.Layers, descriptor{Digest: digest})
591 }
592
593 data, err := json.Marshal(payload)
594 require.NoError(t, err)
595 return data
596}
597
598func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
599 t.Helper()
600
601 type descriptor struct {
602 Digest string `json:"digest"`
603 }
604
605 payload := struct {
606 Manifests []descriptor `json:"manifests"`
607 }{
608 Manifests: make([]descriptor, 0, len(digests)),
609 }
610
611 for _, digest := range digests {
612 payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
613 }
614
615 data, err := json.Marshal(payload)
616 require.NoError(t, err)
617 return data
618}
619
620func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
621 f.listRepositoriesCalls++
622 return append([]string(nil), f.repositories...), nil
623}
624
625func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
626 return append([]string(nil), f.tagsByRepo[name]...), nil
627}
628
629func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
630 modTime, ok := f.modTimes[manifestRefKey(name, reference)]
631 if !ok {
632 return time.Time{}, errors.New("manifest modtime not found")
633 }
634 return modTime, nil
635}
636
637func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
638 f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
639 tags := f.tagsByRepo[name]
640 filtered := tags[:0]
641 for _, tag := range tags {
642 if tag != reference {
643 filtered = append(filtered, tag)
644 }
645 }
646 f.tagsByRepo[name] = filtered
647 delete(f.manifests, manifestRefKey(name, reference))
648 return nil
649}
650
651func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
652 manifest, ok := f.manifests[manifestRefKey(name, reference)]
653 if !ok {
654 return nil, "", errors.New("manifest not found")
655 }
656 return manifest, "application/vnd.oci.image.manifest.v1+json", nil
657}
658
659func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
660 return append([]string(nil), f.blobs...), nil
661}
662
663func (f *fakeBlobStorage) DeleteBlob(digest string) error {
664 f.deletedBlobs = append(f.deletedBlobs, digest)
665 for i, existing := range f.blobs {
666 if existing != digest {
667 continue
668 }
669 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
670 break
671 }
672 return nil
673}
674
675func TestSplitRepoTag(t *testing.T) {
676 tests := []struct {
677 name string
678 input string
679 wantRepo string
680 wantTag string
681 }{
682 {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
683 {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
684 {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
685 {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
686 {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
687 {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
688 {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
689 }
690
691 for _, tt := range tests {
692 t.Run(tt.name, func(t *testing.T) {
693 repo, tag := splitRepoTag(tt.input)
694 assert.Equal(t, tt.wantRepo, repo)
695 assert.Equal(t, tt.wantTag, tag)
696 })
697 }
698}
699
700var _ pkgruntime.Runtime = (*fakeRuntime)(nil)