v2.15.1 c808592

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)
17
18// Service implements image list and prune operations.
19type Service struct {
20	runtime         imageRuntime
21	manifestStorage out.ManifestStorage
22	blobStorage     out.BlobStorage
23	log             zerowrap.Logger
24}
25
26type imageRuntime interface {
27	ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
28	PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
29}
30
31// NewService creates a new images service.
32func NewService(
33	rt imageRuntime,
34	manifestStorage out.ManifestStorage,
35	blobStorage out.BlobStorage,
36	log zerowrap.Logger,
37) *Service {
38	return &Service{
39		runtime:         rt,
40		manifestStorage: manifestStorage,
41		blobStorage:     blobStorage,
42		log:             log,
43	}
44}
45
46// ListImages returns images known by the runtime.
47func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
48	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
49		zerowrap.FieldLayer:   "usecase",
50		zerowrap.FieldUseCase: "ListImages",
51	})
52	log := zerowrap.FromCtx(ctx)
53
54	details, err := s.runtime.ListImagesDetailed(ctx)
55	if err != nil {
56		return nil, log.WrapErr(err, "failed to list images")
57	}
58
59	images := make([]domain.ImageInfo, 0, len(details))
60	for _, detail := range details {
61		if isDanglingImage(detail.RepoTags) {
62			images = append(images, domain.ImageInfo{
63				Repository: "",
64				Tag:        "",
65				Size:       detail.Size,
66				Created:    detail.Created,
67				ID:         detail.ID,
68				Dangling:   true,
69			})
70			continue
71		}
72
73		for _, repoTag := range detail.RepoTags {
74			if repoTag == "" || repoTag == "<none>:<none>" {
75				continue
76			}
77			repository, tag := splitRepoTag(repoTag)
78			images = append(images, domain.ImageInfo{
79				Repository: repository,
80				Tag:        tag,
81				Size:       detail.Size,
82				Created:    detail.Created,
83				ID:         detail.ID,
84				Dangling:   false,
85			})
86		}
87	}
88
89	return images, nil
90}
91
92// PruneRuntime prunes dangling images from the runtime.
93func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
94	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95		zerowrap.FieldLayer:   "usecase",
96		zerowrap.FieldUseCase: "PruneRuntime",
97	})
98	log := zerowrap.FromCtx(ctx)
99
100	pruneReport, err := s.runtime.PruneImages(ctx, true)
101	if err != nil {
102		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
103	}
104
105	return domain.ImagePruneReport{
106		Runtime: domain.RuntimePruneResult{
107			DeletedCount:   len(pruneReport.DeletedIDs),
108			SpaceReclaimed: pruneReport.SpaceReclaimed,
109		},
110	}, nil
111}
112
113// PruneRegistry applies tag retention and blob garbage collection.
114// It keeps the most recent keepLast tags from tagInfos and always keeps
115// the "latest" tag when present, so up to keepLast+1 tags can be retained.
116func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118		zerowrap.FieldLayer:   "usecase",
119		zerowrap.FieldUseCase: "PruneRegistry",
120		"keepLast":            keepLast,
121	})
122	log := zerowrap.FromCtx(ctx)
123
124	if keepLast <= 0 {
125		return domain.ImagePruneReport{}, nil
126	}
127
128	repositories, err := s.manifestStorage.ListRepositories()
129	if err != nil {
130		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
131	}
132
133	referencedDigests := make(map[string]struct{})
134	report := domain.ImagePruneReport{}
135
136	for _, repository := range repositories {
137		tagInfos, err := s.loadRepositoryTagInfos(repository)
138		if err != nil {
139			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to load repository tags")
140		}
141		if len(tagInfos) == 0 {
142			continue
143		}
144
145		keptTags := buildKeptTagSet(tagInfos, keepLast)
146		removed, err := s.deleteUnkeptManifests(repository, tagInfos, keptTags)
147		if err != nil {
148			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
149		}
150		report.Registry.TagsRemoved += removed
151
152		if err := s.collectKeptTagDigests(log, repository, tagInfos, keptTags, referencedDigests); err != nil {
153			return domain.ImagePruneReport{}, err
154		}
155	}
156
157	blobs, err := s.blobStorage.ListBlobs()
158	if err != nil {
159		return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
160	}
161
162	for _, digest := range blobs {
163		if _, referenced := referencedDigests[digest]; referenced {
164			continue
165		}
166		if err := s.blobStorage.DeleteBlob(digest); err != nil {
167			return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
168		}
169		report.Registry.BlobsRemoved++
170	}
171
172	return report, nil
173}
174
175func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
176	tags, err := s.manifestStorage.ListTags(repository)
177	if err != nil {
178		return nil, err
179	}
180
181	tagInfos := make([]registryTag, 0, len(tags))
182	for _, tag := range tags {
183		modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
184		if err != nil {
185			return nil, err
186		}
187		tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
188	}
189
190	sort.Slice(tagInfos, func(i, j int) bool {
191		if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
192			return tagInfos[i].name > tagInfos[j].name
193		}
194		return tagInfos[i].modTime.After(tagInfos[j].modTime)
195	})
196
197	return tagInfos, nil
198}
199
200func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
201	keptTags := make(map[string]struct{})
202	if containsTag(tagInfos, "latest") {
203		keptTags["latest"] = struct{}{}
204	}
205
206	for i := 0; i < len(tagInfos) && i < keepLast; i++ {
207		keptTags[tagInfos[i].name] = struct{}{}
208	}
209
210	return keptTags
211}
212
213func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
214	removed := 0
215	for _, tag := range tagInfos {
216		if _, kept := keptTags[tag.name]; kept {
217			continue
218		}
219		if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
220			return 0, err
221		}
222		removed++
223	}
224
225	return removed, nil
226}
227
228func (s *Service) collectKeptTagDigests(
229	log zerowrap.Logger,
230	repository string,
231	tagInfos []registryTag,
232	keptTags map[string]struct{},
233	referencedDigests map[string]struct{},
234) error {
235	for _, tag := range tagInfos {
236		if _, kept := keptTags[tag.name]; !kept {
237			continue
238		}
239		if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
240			return err
241		}
242	}
243
244	return nil
245}
246
247// Prune runs runtime prune and registry prune and aggregates reports.
248func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
249	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
250		zerowrap.FieldLayer:   "usecase",
251		zerowrap.FieldUseCase: "Prune",
252		"keepLast":            keepLast,
253	})
254	log := zerowrap.FromCtx(ctx)
255
256	var report domain.ImagePruneReport
257
258	runtimeReport, err := s.PruneRuntime(ctx)
259	if err != nil {
260		log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
261	} else {
262		report.Runtime = runtimeReport.Runtime
263	}
264
265	registryReport, err := s.PruneRegistry(ctx, keepLast)
266	if err != nil {
267		return report, err
268	}
269
270	report.Registry = registryReport.Registry
271	return report, nil
272}
273
274type registryTag struct {
275	name    string
276	modTime time.Time
277}
278
279type manifestReferences struct {
280	blobs          []string
281	childManifests []string
282}
283
284func containsTag(tags []registryTag, name string) bool {
285	for _, tag := range tags {
286		if tag.name == name {
287			return true
288		}
289	}
290
291	return false
292}
293
294func (s *Service) collectReferencedDigests(
295	log zerowrap.Logger,
296	repository, reference string,
297	referencedDigests map[string]struct{},
298	visited map[string]struct{},
299) error {
300	visitKey := repository + "@" + reference
301	if _, seen := visited[visitKey]; seen {
302		return nil
303	}
304	visited[visitKey] = struct{}{}
305
306	manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
307	if err != nil {
308		return log.WrapErr(err, "failed to read manifest")
309	}
310
311	refs, err := parseManifestReferences(manifestData)
312	if err != nil {
313		return log.WrapErr(err, "failed to parse manifest")
314	}
315
316	for _, digest := range refs.blobs {
317		referencedDigests[digest] = struct{}{}
318	}
319
320	for _, childRef := range refs.childManifests {
321		referencedDigests[childRef] = struct{}{}
322		if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
323			return err
324		}
325	}
326
327	return nil
328}
329
330func parseManifestReferences(data []byte) (manifestReferences, error) {
331	var manifest struct {
332		Config struct {
333			Digest string `json:"digest"`
334		} `json:"config"`
335		Layers []struct {
336			Digest string `json:"digest"`
337		} `json:"layers"`
338		Manifests []struct {
339			Digest string `json:"digest"`
340		} `json:"manifests"`
341	}
342
343	if err := json.Unmarshal(data, &manifest); err != nil {
344		return manifestReferences{}, err
345	}
346
347	refs := manifestReferences{
348		blobs:          make([]string, 0, len(manifest.Layers)+1),
349		childManifests: make([]string, 0, len(manifest.Manifests)),
350	}
351
352	if manifest.Config.Digest != "" {
353		refs.blobs = append(refs.blobs, manifest.Config.Digest)
354	}
355
356	for _, layer := range manifest.Layers {
357		if layer.Digest == "" {
358			continue
359		}
360		refs.blobs = append(refs.blobs, layer.Digest)
361	}
362
363	for _, child := range manifest.Manifests {
364		if child.Digest == "" {
365			continue
366		}
367		refs.childManifests = append(refs.childManifests, child.Digest)
368	}
369
370	return refs, nil
371}
372
373func isDanglingImage(repoTags []string) bool {
374	if len(repoTags) == 0 {
375		return true
376	}
377
378	for _, tag := range repoTags {
379		if tag == "<none>:<none>" || tag == "" {
380			continue
381		}
382		return false
383	}
384
385	return true
386}
387
388func splitRepoTag(repoTag string) (string, string) {
389	if repoTag == "" {
390		return "", ""
391	}
392	if strings.Contains(repoTag, "@") {
393		return repoTag, ""
394	}
395
396	idx := strings.LastIndex(repoTag, ":")
397	slashIdx := strings.LastIndex(repoTag, "/")
398	if idx <= slashIdx || idx >= len(repoTag)-1 {
399		return repoTag, ""
400	}
401
402	return repoTag[:idx], repoTag[idx+1:]
403}

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