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