v2.31.0-dev21 b59533b

service.go RawHistoryFolder

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

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