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