v2.30.0-pre 7341e0e

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