v3.5.0 8345e76

service.go RawHistoryFolder

1// Package images implements the image management use case.
2package images
3
4import (
5	"context"
6	"sort"
7	"strings"
8	"sync"
9
10	"github.com/bnema/zerowrap"
11
12	"github.com/bnema/gordon/internal/boundaries/out"
13	"github.com/bnema/gordon/internal/domain"
14	"github.com/bnema/gordon/internal/usecase/registrystate"
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	mutationMu      *sync.RWMutex
26	registryState   *registrystate.State
27	// protection, pruneRuntime, and barrier are the prune ports. All
28	// three are required for Prune; a missing port fails closed.
29	protection   out.PruneProtectionStore
30	pruneRuntime out.PruneRuntime
31	barrier      out.GCBarrier
32}
33
34type imageRuntime interface {
35	ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
36}
37
38// NewService creates a new images service.
39func NewService(
40	rt imageRuntime,
41	manifestStorage out.ManifestStorage,
42	blobStorage out.BlobStorage,
43	log zerowrap.Logger,
44	states ...*registrystate.State,
45) *Service {
46	registryState := registrystate.New()
47	if len(states) > 0 && states[0] != nil {
48		registryState = states[0]
49	}
50	return &Service{
51		runtime:         rt,
52		manifestStorage: manifestStorage,
53		blobStorage:     blobStorage,
54		log:             log,
55		mutationMu:      &registryState.MutationMu,
56		registryState:   registryState,
57	}
58}
59
60// ListImages returns images known by the runtime and registry tags.
61func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
62	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
63		zerowrap.FieldLayer:   "usecase",
64		zerowrap.FieldUseCase: "ListImages",
65	})
66	log := zerowrap.FromCtx(ctx)
67
68	details, err := s.runtime.ListImagesDetailed(ctx)
69	if err != nil {
70		return nil, log.WrapErr(err, "failed to list images")
71	}
72
73	images, seenRepoTags, repoDisplayByNormalized := buildRuntimeImageIndex(details)
74	images, err = s.appendRegistryImages(log, images, seenRepoTags, repoDisplayByNormalized)
75	if err != nil {
76		return nil, err
77	}
78
79	sort.SliceStable(images, func(i, j int) bool {
80		return lessImageInfo(images[i], images[j])
81	})
82
83	return images, nil
84}
85
86func buildRuntimeImageIndex(details []runtime.ImageDetail) ([]domain.ImageInfo, map[string]struct{}, map[string]string) {
87	images := make([]domain.ImageInfo, 0, len(details))
88	seenRepoTags := make(map[string]struct{}, len(details))
89	repoDisplayByNormalized := make(map[string]string)
90
91	for _, detail := range details {
92		if isDanglingImage(detail.RepoTags) {
93			images = append(images, domain.ImageInfo{
94				Repository: "",
95				Tag:        "",
96				Size:       detail.Size,
97				Created:    detail.Created,
98				ID:         detail.ID,
99				Dangling:   true,
100			})
101			continue
102		}
103
104		for _, repoTag := range detail.RepoTags {
105			if repoTag == "" || repoTag == "<none>:<none>" {
106				continue
107			}
108			repository, tag := splitRepoTag(repoTag)
109			normalizedRepository := normalizeRepository(repository)
110			seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{}
111			if normalizedRepository != "" && normalizedRepository != repository {
112				repoDisplayByNormalized[normalizedRepository] = repository
113			}
114			images = append(images, domain.ImageInfo{
115				Repository: repository,
116				Tag:        tag,
117				Size:       detail.Size,
118				Created:    detail.Created,
119				ID:         detail.ID,
120				Dangling:   false,
121			})
122		}
123	}
124
125	return images, seenRepoTags, repoDisplayByNormalized
126}
127
128func (s *Service) appendRegistryImages(
129	log zerowrap.Logger,
130	images []domain.ImageInfo,
131	seenRepoTags map[string]struct{},
132	repoDisplayByNormalized map[string]string,
133) ([]domain.ImageInfo, error) {
134	repositories, err := s.manifestStorage.ListRepositories()
135	if err != nil {
136		return nil, log.WrapErr(err, "failed to list repositories")
137	}
138
139	sort.Strings(repositories)
140	for _, repository := range repositories {
141		tags, err := s.manifestStorage.ListTags(repository)
142		if err != nil {
143			return nil, log.WrapErr(err, "failed to list repository tags")
144		}
145
146		displayRepository := repository
147		normalizedRepository := normalizeRepository(repository)
148		if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok {
149			displayRepository = mappedRepository
150		}
151
152		for _, tag := range tags {
153			if isRegistryTagPlaceholder(tag) {
154				continue
155			}
156
157			key := repoTagKey(normalizedRepository, tag)
158			if _, exists := seenRepoTags[key]; exists {
159				continue
160			}
161
162			createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag)
163			if err != nil {
164				log.Warn().
165					Err(err).
166					Str("repository", repository).
167					Str("tag", tag).
168					Msg("failed to read manifest modification time")
169			}
170
171			images = append(images, domain.ImageInfo{
172				Repository: displayRepository,
173				Tag:        tag,
174				Created:    createdAt,
175				Dangling:   false,
176			})
177			seenRepoTags[key] = struct{}{}
178		}
179	}
180
181	return images, nil
182}
183
184func isDanglingImage(repoTags []string) bool {
185	if len(repoTags) == 0 {
186		return true
187	}
188
189	for _, tag := range repoTags {
190		if tag == "<none>:<none>" || tag == "" {
191			continue
192		}
193		return false
194	}
195
196	return true
197}
198
199func splitRepoTag(repoTag string) (string, string) {
200	if repoTag == "" {
201		return "", ""
202	}
203	if strings.Contains(repoTag, "@") {
204		return repoTag, ""
205	}
206
207	idx := strings.LastIndex(repoTag, ":")
208	slashIdx := strings.LastIndex(repoTag, "/")
209	if idx <= slashIdx || idx >= len(repoTag)-1 {
210		return repoTag, ""
211	}
212
213	return repoTag[:idx], repoTag[idx+1:]
214}
215
216func normalizeRepository(repository string) string {
217	repository = strings.TrimSpace(repository)
218	if repository == "" {
219		return ""
220	}
221
222	idx := strings.Index(repository, "/")
223	if idx <= 0 {
224		return repository
225	}
226
227	firstComponent := repository[:idx]
228	if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
229		return repository[idx+1:]
230	}
231
232	return repository
233}
234
235func repoTagKey(repository, tag string) string {
236	return repository + "\x00" + tag
237}
238
239func isRegistryTagPlaceholder(tag string) bool {
240	return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
241}
242
243func lessImageInfo(left, right domain.ImageInfo) bool {
244	if left.Dangling != right.Dangling {
245		return !left.Dangling
246	}
247	if left.Repository != right.Repository {
248		return left.Repository < right.Repository
249	}
250	if left.Tag == "latest" && right.Tag != "latest" {
251		return true
252	}
253	if right.Tag == "latest" && left.Tag != "latest" {
254		return false
255	}
256	if !left.Created.Equal(right.Created) {
257		return left.Created.After(right.Created)
258	}
259	if left.Tag != right.Tag {
260		return left.Tag > right.Tag
261	}
262
263	return left.ID < right.ID
264}

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