v2.31.0-dev21 b59533b

service_test.go RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close