v2.31.1 f37fe67

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

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