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: ®istryState.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}