v2.15.0 cf76057

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 := noopManifestStorage{}
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 := noopManifestStorage{}
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 := noopManifestStorage{}
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 := noopManifestStorage{}
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_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128	manifestStorage := noopManifestStorage{}
129	blobStorage := noopBlobStorage{}
130	rt := &fakeRuntime{
131		pruneReport: pkgruntime.PruneReport{
132			DeletedIDs:     []string{"sha256:a", "sha256:b"},
133			SpaceReclaimed: 2048,
134		},
135	}
136
137	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
138
139	report, err := svc.PruneRuntime(context.Background())
140
141	require.NoError(t, err)
142	assert.Equal(t, 2, report.Runtime.DeletedCount)
143	assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
144	assert.True(t, rt.pruneCalled)
145	assert.True(t, rt.pruneDanglingOnly)
146}
147
148func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
149	manifestStorage := noopManifestStorage{}
150	blobStorage := noopBlobStorage{}
151	rt := &fakeRuntime{
152		pruneReport: pkgruntime.PruneReport{
153			DeletedIDs:     nil,
154			SpaceReclaimed: 0,
155		},
156	}
157
158	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
159
160	report, err := svc.PruneRuntime(context.Background())
161
162	require.NoError(t, err)
163	assert.Equal(t, 0, report.Runtime.DeletedCount)
164	assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
165	assert.True(t, rt.pruneCalled)
166	assert.True(t, rt.pruneDanglingOnly)
167}
168
169func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
170	manifestStorage := noopManifestStorage{}
171	blobStorage := noopBlobStorage{}
172	rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
173
174	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
175
176	_, err := svc.PruneRuntime(context.Background())
177
178	require.Error(t, err)
179	assert.Contains(t, err.Error(), "failed to prune runtime images")
180	assert.True(t, rt.pruneCalled)
181	assert.True(t, rt.pruneDanglingOnly)
182}
183
184func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
185	manifestStorage := newFakeManifestStorage()
186	manifestStorage.repositories = []string{"gordon/api"}
187	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
188	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
189	manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
190	manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
191	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193	manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
194
195	blobStorage := &fakeBlobStorage{}
196	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
197
198	report, err := svc.PruneRegistry(context.Background(), 2)
199
200	require.NoError(t, err)
201	assert.Equal(t, 2, report.Registry.TagsRemoved)
202	assert.ElementsMatch(t, []manifestRef{
203		{name: "gordon/api", reference: "v2"},
204		{name: "gordon/api", reference: "v1"},
205	}, manifestStorage.deletedManifests)
206}
207
208func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
209	manifestStorage := newFakeManifestStorage()
210	manifestStorage.repositories = []string{"gordon/api"}
211	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
212	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
213	manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
214	manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
215	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
216	manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
217
218	blobStorage := &fakeBlobStorage{}
219	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
220
221	report, err := svc.PruneRegistry(context.Background(), 1)
222
223	require.NoError(t, err)
224	assert.Equal(t, 1, report.Registry.TagsRemoved)
225	assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
226}
227
228func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
229	manifestStorage := newFakeManifestStorage()
230	manifestStorage.repositories = []string{"gordon/api"}
231	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
232	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
233	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
234	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
235	manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
236
237	blobStorage := &fakeBlobStorage{}
238	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
239
240	report, err := svc.PruneRegistry(context.Background(), 5)
241
242	require.NoError(t, err)
243	assert.Equal(t, 0, report.Registry.TagsRemoved)
244	assert.Empty(t, manifestStorage.deletedManifests)
245}
246
247func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
248	manifestStorage := newFakeManifestStorage()
249	manifestStorage.repositories = []string{"gordon/api"}
250	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
251	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
252
253	report, err := svc.PruneRegistry(context.Background(), 0)
254
255	require.NoError(t, err)
256	assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
257	assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
258	assert.Empty(t, manifestStorage.deletedManifests)
259	assert.Empty(t, blobStorage.deletedBlobs)
260}
261
262func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
263	manifestStorage := newFakeManifestStorage()
264	manifestStorage.repositories = []string{"gordon/api"}
265	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
266	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
267	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
268
269	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
270	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
271
272	report, err := svc.PruneRegistry(context.Background(), 1)
273
274	require.NoError(t, err)
275	assert.Equal(t, 1, report.Registry.BlobsRemoved)
276	assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
277}
278
279func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280	manifestStorage := newFakeManifestStorage()
281	manifestStorage.repositories = []string{"gordon/api"}
282	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
283	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
284	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
286	manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287
288	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
289	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290
291	report, err := svc.PruneRegistry(context.Background(), 1)
292
293	require.NoError(t, err)
294	assert.Equal(t, 1, report.Registry.BlobsRemoved)
295	assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
296}
297
298func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
299	manifestStorage := newFakeManifestStorage()
300	manifestStorage.repositories = []string{"gordon/api"}
301	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
302	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
303	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
304	manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
305	manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
306
307	blobStorage := &fakeBlobStorage{blobs: []string{
308		"sha256:child-amd64",
309		"sha256:child-arm64",
310		"sha256:cfg-amd64",
311		"sha256:layer-amd64",
312		"sha256:cfg-arm64",
313		"sha256:layer-arm64",
314		"sha256:orphan",
315	}}
316	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
317
318	report, err := svc.PruneRegistry(context.Background(), 1)
319
320	require.NoError(t, err)
321	assert.Equal(t, 1, report.Registry.BlobsRemoved)
322	assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
323}
324
325func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
326	manifestStorage := newFakeManifestStorage()
327	manifestStorage.repositories = []string{"gordon/api"}
328	manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
329	tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
330	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
331	manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
332	manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
333
334	blobStorage := &fakeBlobStorage{}
335	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
336
337	report, err := svc.PruneRegistry(context.Background(), 1)
338
339	require.NoError(t, err)
340	assert.Equal(t, 1, report.Registry.TagsRemoved)
341	assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
342}
343
344func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
345	manifestStorage := newFakeManifestStorage()
346	manifestStorage.repositories = []string{"gordon/api"}
347	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
348	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
349	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
350
351	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
352	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
353
354	_, err := svc.PruneRegistry(context.Background(), 1)
355
356	require.Error(t, err)
357	assert.Contains(t, err.Error(), "failed to read manifest")
358}
359
360func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
361	manifestStorage := newFakeManifestStorage()
362	manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
363	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
364	manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
365	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
366	manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
367	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
368	manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
369	manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
370	manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
371	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
372	manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
373	manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
374	manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
375
376	blobStorage := &fakeBlobStorage{}
377	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378
379	report, err := svc.PruneRegistry(context.Background(), 2)
380
381	require.NoError(t, err)
382	assert.Equal(t, 2, report.Registry.TagsRemoved)
383	assert.ElementsMatch(t, []manifestRef{
384		{name: "gordon/api", reference: "v1"},
385		{name: "gordon/web", reference: "v2"},
386	}, manifestStorage.deletedManifests)
387}
388
389func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
390	manifestStorage := newFakeManifestStorage()
391	manifestStorage.repositories = []string{"gordon/api"}
392	manifestStorage.tagsByRepo["gordon/api"] = []string{}
393	blobStorage := &fakeBlobStorage{}
394	svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
395
396	report, err := svc.PruneRegistry(context.Background(), 2)
397
398	require.NoError(t, err)
399	assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
400	assert.Empty(t, manifestStorage.deletedManifests)
401	assert.Empty(t, blobStorage.deletedBlobs)
402}
403
404func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405	manifestStorage := newFakeManifestStorage()
406	manifestStorage.repositories = []string{"gordon/api"}
407	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
408	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
409	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
411	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412	rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413
414	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
415
416	report, err := svc.Prune(context.Background(), 1)
417
418	require.NoError(t, err)
419	assert.Equal(t, 1, report.Runtime.DeletedCount)
420	assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
421	assert.Equal(t, 1, report.Registry.TagsRemoved)
422	assert.Equal(t, 1, report.Registry.BlobsRemoved)
423	assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
424	assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
425}
426
427func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428	manifestStorage := newFakeManifestStorage()
429	manifestStorage.repositories = []string{"gordon/api"}
430	manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
431	manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
432	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433	manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
434	blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435	rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436
437	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
438
439	report, err := svc.Prune(context.Background(), 1)
440
441	require.NoError(t, err)
442	assert.Equal(t, 0, report.Runtime.DeletedCount)
443	assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
444	assert.Equal(t, 1, report.Registry.TagsRemoved)
445	assert.Equal(t, 1, report.Registry.BlobsRemoved)
446	assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
447	assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
448}
449
450type fakeRuntime struct {
451	pkgruntime.Runtime
452
453	listDetails []pkgruntime.ImageDetail
454	listErr     error
455
456	pruneReport       pkgruntime.PruneReport
457	pruneErr          error
458	pruneCalled       bool
459	pruneDanglingOnly bool
460}
461
462type noopManifestStorage struct {
463	out.ManifestStorage
464}
465
466type noopBlobStorage struct {
467	out.BlobStorage
468}
469
470type manifestRef struct {
471	name      string
472	reference string
473}
474
475type fakeManifestStorage struct {
476	out.ManifestStorage
477
478	repositories []string
479	tagsByRepo   map[string][]string
480	modTimes     map[string]time.Time
481	manifests    map[string][]byte
482
483	listRepositoriesCalls int
484	deletedManifests      []manifestRef
485}
486
487type fakeBlobStorage struct {
488	out.BlobStorage
489
490	blobs        []string
491	deletedBlobs []string
492}
493
494func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
495	return f.listDetails, f.listErr
496}
497
498func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
499	f.pruneCalled = true
500	f.pruneDanglingOnly = danglingOnly
501	return f.pruneReport, f.pruneErr
502}
503
504func newFakeManifestStorage() *fakeManifestStorage {
505	return &fakeManifestStorage{
506		tagsByRepo: make(map[string][]string),
507		modTimes:   make(map[string]time.Time),
508		manifests:  make(map[string][]byte),
509	}
510}
511
512func manifestRefKey(name, reference string) string {
513	return name + "@" + reference
514}
515
516func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
517	t.Helper()
518
519	type descriptor struct {
520		Digest string `json:"digest"`
521	}
522
523	payload := struct {
524		Config descriptor   `json:"config"`
525		Layers []descriptor `json:"layers"`
526	}{
527		Config: descriptor{Digest: configDigest},
528		Layers: make([]descriptor, 0, len(layerDigests)),
529	}
530
531	for _, digest := range layerDigests {
532		payload.Layers = append(payload.Layers, descriptor{Digest: digest})
533	}
534
535	data, err := json.Marshal(payload)
536	require.NoError(t, err)
537	return data
538}
539
540func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
541	t.Helper()
542
543	type descriptor struct {
544		Digest string `json:"digest"`
545	}
546
547	payload := struct {
548		Manifests []descriptor `json:"manifests"`
549	}{
550		Manifests: make([]descriptor, 0, len(digests)),
551	}
552
553	for _, digest := range digests {
554		payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
555	}
556
557	data, err := json.Marshal(payload)
558	require.NoError(t, err)
559	return data
560}
561
562func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
563	f.listRepositoriesCalls++
564	return append([]string(nil), f.repositories...), nil
565}
566
567func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
568	return append([]string(nil), f.tagsByRepo[name]...), nil
569}
570
571func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
572	modTime, ok := f.modTimes[manifestRefKey(name, reference)]
573	if !ok {
574		return time.Time{}, errors.New("manifest modtime not found")
575	}
576	return modTime, nil
577}
578
579func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
580	f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
581	tags := f.tagsByRepo[name]
582	filtered := tags[:0]
583	for _, tag := range tags {
584		if tag != reference {
585			filtered = append(filtered, tag)
586		}
587	}
588	f.tagsByRepo[name] = filtered
589	delete(f.manifests, manifestRefKey(name, reference))
590	return nil
591}
592
593func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
594	manifest, ok := f.manifests[manifestRefKey(name, reference)]
595	if !ok {
596		return nil, "", errors.New("manifest not found")
597	}
598	return manifest, "application/vnd.oci.image.manifest.v1+json", nil
599}
600
601func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
602	return append([]string(nil), f.blobs...), nil
603}
604
605func (f *fakeBlobStorage) DeleteBlob(digest string) error {
606	f.deletedBlobs = append(f.deletedBlobs, digest)
607	for i, existing := range f.blobs {
608		if existing != digest {
609			continue
610		}
611		f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
612		break
613	}
614	return nil
615}
616
617func TestSplitRepoTag(t *testing.T) {
618	tests := []struct {
619		name     string
620		input    string
621		wantRepo string
622		wantTag  string
623	}{
624		{name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
625		{name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
626		{name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
627		{name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
628		{name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
629		{name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
630		{name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
631	}
632
633	for _, tt := range tests {
634		t.Run(tt.name, func(t *testing.T) {
635			repo, tag := splitRepoTag(tt.input)
636			assert.Equal(t, tt.wantRepo, repo)
637			assert.Equal(t, tt.wantTag, tag)
638		})
639	}
640}
641
642var _ 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