v2.31.1 f37fe67

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	"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)

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