v2.16.0 ebd2ab3

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

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