v2.30.0-pre6 511705f

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		if err := s.blobStorage.DeleteBlob(digest); err != nil {
247			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
248		}
249		report.Registry.BlobsRemoved++
250	}
251
252	// Clean up stale uploads (abandoned push operations)
253	uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
254	if err != nil {
255		log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
256	} else {
257		report.Registry.UploadsRemoved = uploadsRemoved
258		report.Registry.UploadSpaceReclaimed = uploadBytes
259	}
260
261	return report, nil
262}
263
264func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
265	tags, err := s.manifestStorage.ListTags(repository)
266	if err != nil {
267		return nil, err
268	}
269
270	tagInfos := make([]registryTag, 0, len(tags))
271	for _, tag := range tags {
272		modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
273		if err != nil {
274			return nil, err
275		}
276		tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
277	}
278
279	sort.Slice(tagInfos, func(i, j int) bool {
280		if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
281			return tagInfos[i].name > tagInfos[j].name
282		}
283		return tagInfos[i].modTime.After(tagInfos[j].modTime)
284	})
285
286	return tagInfos, nil
287}
288
289func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
290	keptTags := make(map[string]struct{})
291	if containsTag(tagInfos, "latest") {
292		keptTags["latest"] = struct{}{}
293	}
294
295	kept := 0
296	for _, tagInfo := range tagInfos {
297		if tagInfo.name == "latest" {
298			continue
299		}
300		if kept >= keepLast {
301			break
302		}
303		keptTags[tagInfo.name] = struct{}{}
304		kept++
305	}
306
307	return keptTags
308}
309
310func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
311	removed := 0
312	for _, tag := range tagInfos {
313		if _, kept := keptTags[tag.name]; kept {
314			continue
315		}
316		if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
317			return 0, err
318		}
319		removed++
320	}
321
322	return removed, nil
323}
324
325func (s *Service) collectKeptTagDigests(
326	log zerowrap.Logger,
327	repository string,
328	tagInfos []registryTag,
329	keptTags map[string]struct{},
330	referencedDigests map[string]struct{},
331) error {
332	for _, tag := range tagInfos {
333		if _, kept := keptTags[tag.name]; !kept {
334			continue
335		}
336		if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
337			return err
338		}
339	}
340
341	return nil
342}
343
344// Prune runs runtime and/or registry prune based on options and aggregates reports.
345func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
346	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
347		zerowrap.FieldLayer:   "usecase",
348		zerowrap.FieldUseCase: "Prune",
349		"keepLast":            opts.KeepLast,
350		"pruneDangling":       opts.PruneDangling,
351		"pruneRegistry":       opts.PruneRegistry,
352	})
353	log := zerowrap.FromCtx(ctx)
354
355	var report domain.ImagePruneReport
356
357	if opts.PruneDangling {
358		runtimeReport, err := s.PruneRuntime(ctx)
359		if err != nil {
360			if !opts.PruneRegistry {
361				// Dangling-only: surface the error to the caller.
362				return report, fmt.Errorf("runtime prune failed: %w", err)
363			}
364			log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
365		} else {
366			report.Runtime = runtimeReport.Runtime
367		}
368	}
369
370	if opts.PruneRegistry {
371		registryReport, err := s.PruneRegistry(ctx, opts.KeepLast)
372		if err != nil {
373			return report, err
374		}
375		report.Registry = registryReport.Registry
376	}
377
378	return report, nil
379}
380
381type registryTag struct {
382	name    string
383	modTime time.Time
384}
385
386type manifestReferences struct {
387	blobs          []string
388	childManifests []string
389}
390
391func containsTag(tags []registryTag, name string) bool {
392	for _, tag := range tags {
393		if tag.name == name {
394			return true
395		}
396	}
397
398	return false
399}
400
401func (s *Service) collectReferencedDigests(
402	log zerowrap.Logger,
403	repository, reference string,
404	referencedDigests map[string]struct{},
405	visited map[string]struct{},
406) error {
407	visitKey := repository + "@" + reference
408	if _, seen := visited[visitKey]; seen {
409		return nil
410	}
411	visited[visitKey] = struct{}{}
412
413	manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
414	if err != nil {
415		log.Warn().
416			Str("repository", repository).
417			Str("reference", reference).
418			Err(err).
419			Msg("manifest not found during prune; skipping (orphaned reference)")
420		return nil
421	}
422
423	refs, err := parseManifestReferences(manifestData)
424	if err != nil {
425		return log.WrapErr(err, "failed to parse manifest")
426	}
427
428	for _, digest := range refs.blobs {
429		referencedDigests[digest] = struct{}{}
430	}
431
432	for _, childRef := range refs.childManifests {
433		referencedDigests[childRef] = struct{}{}
434		if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
435			return err
436		}
437	}
438
439	return nil
440}
441
442func parseManifestReferences(data []byte) (manifestReferences, error) {
443	var manifest struct {
444		Config struct {
445			Digest string `json:"digest"`
446		} `json:"config"`
447		Layers []struct {
448			Digest string `json:"digest"`
449		} `json:"layers"`
450		Manifests []struct {
451			Digest string `json:"digest"`
452		} `json:"manifests"`
453	}
454
455	if err := json.Unmarshal(data, &manifest); err != nil {
456		return manifestReferences{}, err
457	}
458
459	refs := manifestReferences{
460		blobs:          make([]string, 0, len(manifest.Layers)+1),
461		childManifests: make([]string, 0, len(manifest.Manifests)),
462	}
463
464	if manifest.Config.Digest != "" {
465		refs.blobs = append(refs.blobs, manifest.Config.Digest)
466	}
467
468	for _, layer := range manifest.Layers {
469		if layer.Digest == "" {
470			continue
471		}
472		refs.blobs = append(refs.blobs, layer.Digest)
473	}
474
475	for _, child := range manifest.Manifests {
476		if child.Digest == "" {
477			continue
478		}
479		refs.childManifests = append(refs.childManifests, child.Digest)
480	}
481
482	return refs, nil
483}
484
485func isDanglingImage(repoTags []string) bool {
486	if len(repoTags) == 0 {
487		return true
488	}
489
490	for _, tag := range repoTags {
491		if tag == "<none>:<none>" || tag == "" {
492			continue
493		}
494		return false
495	}
496
497	return true
498}
499
500func splitRepoTag(repoTag string) (string, string) {
501	if repoTag == "" {
502		return "", ""
503	}
504	if strings.Contains(repoTag, "@") {
505		return repoTag, ""
506	}
507
508	idx := strings.LastIndex(repoTag, ":")
509	slashIdx := strings.LastIndex(repoTag, "/")
510	if idx <= slashIdx || idx >= len(repoTag)-1 {
511		return repoTag, ""
512	}
513
514	return repoTag[:idx], repoTag[idx+1:]
515}
516
517func normalizeRepository(repository string) string {
518	repository = strings.TrimSpace(repository)
519	if repository == "" {
520		return ""
521	}
522
523	idx := strings.Index(repository, "/")
524	if idx <= 0 {
525		return repository
526	}
527
528	firstComponent := repository[:idx]
529	if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
530		return repository[idx+1:]
531	}
532
533	return repository
534}
535
536func repoTagKey(repository, tag string) string {
537	return repository + "\x00" + tag
538}
539
540func isRegistryTagPlaceholder(tag string) bool {
541	return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
542}
543
544func lessImageInfo(left, right domain.ImageInfo) bool {
545	if left.Dangling != right.Dangling {
546		return !left.Dangling
547	}
548	if left.Repository != right.Repository {
549		return left.Repository < right.Repository
550	}
551	if left.Tag == "latest" && right.Tag != "latest" {
552		return true
553	}
554	if right.Tag == "latest" && left.Tag != "latest" {
555		return false
556	}
557	if !left.Created.Equal(right.Created) {
558		return left.Created.After(right.Created)
559	}
560	if left.Tag != right.Tag {
561		return left.Tag > right.Tag
562	}
563
564	return left.ID < right.ID
565}

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 8

Tags 106

↑↓ moveenter openesc close