v2.16.0 ebd2ab3

service.go RawHistoryFolder

1// Package images implements the image management use case.
2package images
3
4import (
5	"context"
6	"encoding/json"
7	"sort"
8	"strings"
9	"time"
10
11	"github.com/bnema/zerowrap"
12
13	"github.com/bnema/gordon/internal/boundaries/out"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/pkg/runtime"
16	"github.com/bnema/gordon/pkg/validation"
17)
18
19// Service implements image list and prune operations.
20type Service struct {
21	runtime         imageRuntime
22	manifestStorage out.ManifestStorage
23	blobStorage     out.BlobStorage
24	log             zerowrap.Logger
25}
26
27type imageRuntime interface {
28	ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
29	PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
30}
31
32// NewService creates a new images service.
33func NewService(
34	rt imageRuntime,
35	manifestStorage out.ManifestStorage,
36	blobStorage out.BlobStorage,
37	log zerowrap.Logger,
38) *Service {
39	return &Service{
40		runtime:         rt,
41		manifestStorage: manifestStorage,
42		blobStorage:     blobStorage,
43		log:             log,
44	}
45}
46
47// ListImages returns images known by the runtime and registry tags.
48func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
49	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
50		zerowrap.FieldLayer:   "usecase",
51		zerowrap.FieldUseCase: "ListImages",
52	})
53	log := zerowrap.FromCtx(ctx)
54
55	details, err := s.runtime.ListImagesDetailed(ctx)
56	if err != nil {
57		return nil, log.WrapErr(err, "failed to list images")
58	}
59
60	images, seenRepoTags, repoDisplayByNormalized := buildRuntimeImageIndex(details)
61	images, err = s.appendRegistryImages(log, images, seenRepoTags, repoDisplayByNormalized)
62	if err != nil {
63		return nil, err
64	}
65
66	sort.SliceStable(images, func(i, j int) bool {
67		return lessImageInfo(images[i], images[j])
68	})
69
70	return images, nil
71}
72
73func buildRuntimeImageIndex(details []runtime.ImageDetail) ([]domain.ImageInfo, map[string]struct{}, map[string]string) {
74	images := make([]domain.ImageInfo, 0, len(details))
75	seenRepoTags := make(map[string]struct{}, len(details))
76	repoDisplayByNormalized := make(map[string]string)
77
78	for _, detail := range details {
79		if isDanglingImage(detail.RepoTags) {
80			images = append(images, domain.ImageInfo{
81				Repository: "",
82				Tag:        "",
83				Size:       detail.Size,
84				Created:    detail.Created,
85				ID:         detail.ID,
86				Dangling:   true,
87			})
88			continue
89		}
90
91		for _, repoTag := range detail.RepoTags {
92			if repoTag == "" || repoTag == "<none>:<none>" {
93				continue
94			}
95			repository, tag := splitRepoTag(repoTag)
96			normalizedRepository := normalizeRepository(repository)
97			seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{}
98			if normalizedRepository != "" && normalizedRepository != repository {
99				repoDisplayByNormalized[normalizedRepository] = repository
100			}
101			images = append(images, domain.ImageInfo{
102				Repository: repository,
103				Tag:        tag,
104				Size:       detail.Size,
105				Created:    detail.Created,
106				ID:         detail.ID,
107				Dangling:   false,
108			})
109		}
110	}
111
112	return images, seenRepoTags, repoDisplayByNormalized
113}
114
115func (s *Service) appendRegistryImages(
116	log zerowrap.Logger,
117	images []domain.ImageInfo,
118	seenRepoTags map[string]struct{},
119	repoDisplayByNormalized map[string]string,
120) ([]domain.ImageInfo, error) {
121	repositories, err := s.manifestStorage.ListRepositories()
122	if err != nil {
123		return nil, log.WrapErr(err, "failed to list repositories")
124	}
125
126	sort.Strings(repositories)
127	for _, repository := range repositories {
128		tags, err := s.manifestStorage.ListTags(repository)
129		if err != nil {
130			return nil, log.WrapErr(err, "failed to list repository tags")
131		}
132
133		displayRepository := repository
134		normalizedRepository := normalizeRepository(repository)
135		if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok {
136			displayRepository = mappedRepository
137		}
138
139		for _, tag := range tags {
140			if isRegistryTagPlaceholder(tag) {
141				continue
142			}
143
144			key := repoTagKey(normalizedRepository, tag)
145			if _, exists := seenRepoTags[key]; exists {
146				continue
147			}
148
149			createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag)
150			if err != nil {
151				log.Warn().
152					Err(err).
153					Str("repository", repository).
154					Str("tag", tag).
155					Msg("failed to read manifest modification time")
156			}
157
158			images = append(images, domain.ImageInfo{
159				Repository: displayRepository,
160				Tag:        tag,
161				Created:    createdAt,
162				Dangling:   false,
163			})
164			seenRepoTags[key] = struct{}{}
165		}
166	}
167
168	return images, nil
169}
170
171// PruneRuntime prunes dangling images from the runtime.
172func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
173	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
174		zerowrap.FieldLayer:   "usecase",
175		zerowrap.FieldUseCase: "PruneRuntime",
176	})
177	log := zerowrap.FromCtx(ctx)
178
179	pruneReport, err := s.runtime.PruneImages(ctx, true)
180	if err != nil {
181		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
182	}
183
184	return domain.ImagePruneReport{
185		Runtime: domain.RuntimePruneResult{
186			DeletedCount:   len(pruneReport.DeletedIDs),
187			SpaceReclaimed: pruneReport.SpaceReclaimed,
188		},
189	}, nil
190}
191
192// PruneRegistry applies tag retention and blob garbage collection.
193// It keeps the "latest" tag when present and keeps keepLast most-recent
194// non-latest tags from tagInfos.
195func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
196	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
197		zerowrap.FieldLayer:   "usecase",
198		zerowrap.FieldUseCase: "PruneRegistry",
199		"keepLast":            keepLast,
200	})
201	log := zerowrap.FromCtx(ctx)
202
203	if keepLast <= 0 {
204		return domain.ImagePruneReport{}, nil
205	}
206
207	repositories, err := s.manifestStorage.ListRepositories()
208	if err != nil {
209		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
210	}
211
212	referencedDigests := make(map[string]struct{})
213	report := domain.ImagePruneReport{}
214
215	for _, repository := range repositories {
216		tagInfos, err := s.loadRepositoryTagInfos(repository)
217		if err != nil {
218			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to load repository tags")
219		}
220		if len(tagInfos) == 0 {
221			continue
222		}
223
224		keptTags := buildKeptTagSet(tagInfos, keepLast)
225		removed, err := s.deleteUnkeptManifests(repository, tagInfos, keptTags)
226		if err != nil {
227			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
228		}
229		report.Registry.TagsRemoved += removed
230
231		if err := s.collectKeptTagDigests(log, repository, tagInfos, keptTags, referencedDigests); err != nil {
232			return domain.ImagePruneReport{}, err
233		}
234	}
235
236	blobs, err := s.blobStorage.ListBlobs()
237	if err != nil {
238		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
239	}
240
241	for _, digest := range blobs {
242		if _, referenced := referencedDigests[digest]; referenced {
243			continue
244		}
245		if err := s.blobStorage.DeleteBlob(digest); err != nil {
246			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
247		}
248		report.Registry.BlobsRemoved++
249	}
250
251	return report, nil
252}
253
254func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
255	tags, err := s.manifestStorage.ListTags(repository)
256	if err != nil {
257		return nil, err
258	}
259
260	tagInfos := make([]registryTag, 0, len(tags))
261	for _, tag := range tags {
262		modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
263		if err != nil {
264			return nil, err
265		}
266		tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
267	}
268
269	sort.Slice(tagInfos, func(i, j int) bool {
270		if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
271			return tagInfos[i].name > tagInfos[j].name
272		}
273		return tagInfos[i].modTime.After(tagInfos[j].modTime)
274	})
275
276	return tagInfos, nil
277}
278
279func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
280	keptTags := make(map[string]struct{})
281	if containsTag(tagInfos, "latest") {
282		keptTags["latest"] = struct{}{}
283	}
284
285	kept := 0
286	for _, tagInfo := range tagInfos {
287		if tagInfo.name == "latest" {
288			continue
289		}
290		if kept >= keepLast {
291			break
292		}
293		keptTags[tagInfo.name] = struct{}{}
294		kept++
295	}
296
297	return keptTags
298}
299
300func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
301	removed := 0
302	for _, tag := range tagInfos {
303		if _, kept := keptTags[tag.name]; kept {
304			continue
305		}
306		if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
307			return 0, err
308		}
309		removed++
310	}
311
312	return removed, nil
313}
314
315func (s *Service) collectKeptTagDigests(
316	log zerowrap.Logger,
317	repository string,
318	tagInfos []registryTag,
319	keptTags map[string]struct{},
320	referencedDigests map[string]struct{},
321) error {
322	for _, tag := range tagInfos {
323		if _, kept := keptTags[tag.name]; !kept {
324			continue
325		}
326		if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
327			return err
328		}
329	}
330
331	return nil
332}
333
334// Prune runs runtime prune and registry prune and aggregates reports.
335func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
336	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
337		zerowrap.FieldLayer:   "usecase",
338		zerowrap.FieldUseCase: "Prune",
339		"keepLast":            keepLast,
340	})
341	log := zerowrap.FromCtx(ctx)
342
343	var report domain.ImagePruneReport
344
345	runtimeReport, err := s.PruneRuntime(ctx)
346	if err != nil {
347		log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
348	} else {
349		report.Runtime = runtimeReport.Runtime
350	}
351
352	registryReport, err := s.PruneRegistry(ctx, keepLast)
353	if err != nil {
354		return report, err
355	}
356
357	report.Registry = registryReport.Registry
358	return report, nil
359}
360
361type registryTag struct {
362	name    string
363	modTime time.Time
364}
365
366type manifestReferences struct {
367	blobs          []string
368	childManifests []string
369}
370
371func containsTag(tags []registryTag, name string) bool {
372	for _, tag := range tags {
373		if tag.name == name {
374			return true
375		}
376	}
377
378	return false
379}
380
381func (s *Service) collectReferencedDigests(
382	log zerowrap.Logger,
383	repository, reference string,
384	referencedDigests map[string]struct{},
385	visited map[string]struct{},
386) error {
387	visitKey := repository + "@" + reference
388	if _, seen := visited[visitKey]; seen {
389		return nil
390	}
391	visited[visitKey] = struct{}{}
392
393	manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
394	if err != nil {
395		return log.WrapErr(err, "failed to read manifest")
396	}
397
398	refs, err := parseManifestReferences(manifestData)
399	if err != nil {
400		return log.WrapErr(err, "failed to parse manifest")
401	}
402
403	for _, digest := range refs.blobs {
404		referencedDigests[digest] = struct{}{}
405	}
406
407	for _, childRef := range refs.childManifests {
408		referencedDigests[childRef] = struct{}{}
409		if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
410			return err
411		}
412	}
413
414	return nil
415}
416
417func parseManifestReferences(data []byte) (manifestReferences, error) {
418	var manifest struct {
419		Config struct {
420			Digest string `json:"digest"`
421		} `json:"config"`
422		Layers []struct {
423			Digest string `json:"digest"`
424		} `json:"layers"`
425		Manifests []struct {
426			Digest string `json:"digest"`
427		} `json:"manifests"`
428	}
429
430	if err := json.Unmarshal(data, &manifest); err != nil {
431		return manifestReferences{}, err
432	}
433
434	refs := manifestReferences{
435		blobs:          make([]string, 0, len(manifest.Layers)+1),
436		childManifests: make([]string, 0, len(manifest.Manifests)),
437	}
438
439	if manifest.Config.Digest != "" {
440		refs.blobs = append(refs.blobs, manifest.Config.Digest)
441	}
442
443	for _, layer := range manifest.Layers {
444		if layer.Digest == "" {
445			continue
446		}
447		refs.blobs = append(refs.blobs, layer.Digest)
448	}
449
450	for _, child := range manifest.Manifests {
451		if child.Digest == "" {
452			continue
453		}
454		refs.childManifests = append(refs.childManifests, child.Digest)
455	}
456
457	return refs, nil
458}
459
460func isDanglingImage(repoTags []string) bool {
461	if len(repoTags) == 0 {
462		return true
463	}
464
465	for _, tag := range repoTags {
466		if tag == "<none>:<none>" || tag == "" {
467			continue
468		}
469		return false
470	}
471
472	return true
473}
474
475func splitRepoTag(repoTag string) (string, string) {
476	if repoTag == "" {
477		return "", ""
478	}
479	if strings.Contains(repoTag, "@") {
480		return repoTag, ""
481	}
482
483	idx := strings.LastIndex(repoTag, ":")
484	slashIdx := strings.LastIndex(repoTag, "/")
485	if idx <= slashIdx || idx >= len(repoTag)-1 {
486		return repoTag, ""
487	}
488
489	return repoTag[:idx], repoTag[idx+1:]
490}
491
492func normalizeRepository(repository string) string {
493	repository = strings.TrimSpace(repository)
494	if repository == "" {
495		return ""
496	}
497
498	idx := strings.Index(repository, "/")
499	if idx <= 0 {
500		return repository
501	}
502
503	firstComponent := repository[:idx]
504	if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
505		return repository[idx+1:]
506	}
507
508	return repository
509}
510
511func repoTagKey(repository, tag string) string {
512	return repository + "\x00" + tag
513}
514
515func isRegistryTagPlaceholder(tag string) bool {
516	return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
517}
518
519func lessImageInfo(left, right domain.ImageInfo) bool {
520	if left.Dangling != right.Dangling {
521		return !left.Dangling
522	}
523	if left.Repository != right.Repository {
524		return left.Repository < right.Repository
525	}
526	if left.Tag == "latest" && right.Tag != "latest" {
527		return true
528	}
529	if right.Tag == "latest" && left.Tag != "latest" {
530		return false
531	}
532	if !left.Created.Equal(right.Created) {
533		return left.Created.After(right.Created)
534	}
535	if left.Tag != right.Tag {
536		return left.Tag > right.Tag
537	}
538
539	return left.ID < right.ID
540}

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