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