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