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 size, err := s.blobStorage.DeleteBlob(digest)
247 if err != nil {
248 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
249 }
250 report.Registry.BlobsRemoved++
251 report.Registry.SpaceReclaimed += size
252 }
253
254 // Clean up stale uploads (abandoned push operations)
255 uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
256 if err != nil {
257 log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
258 } else {
259 report.Registry.UploadsRemoved = uploadsRemoved
260 report.Registry.UploadSpaceReclaimed = uploadBytes
261 }
262
263 return report, nil
264}
265
266func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
267 tags, err := s.manifestStorage.ListTags(repository)
268 if err != nil {
269 return nil, err
270 }
271
272 tagInfos := make([]registryTag, 0, len(tags))
273 for _, tag := range tags {
274 modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
275 if err != nil {
276 return nil, err
277 }
278 tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
279 }
280
281 sort.Slice(tagInfos, func(i, j int) bool {
282 if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
283 return tagInfos[i].name > tagInfos[j].name
284 }
285 return tagInfos[i].modTime.After(tagInfos[j].modTime)
286 })
287
288 return tagInfos, nil
289}
290
291func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
292 keptTags := make(map[string]struct{})
293 if containsTag(tagInfos, "latest") {
294 keptTags["latest"] = struct{}{}
295 }
296
297 kept := 0
298 for _, tagInfo := range tagInfos {
299 if tagInfo.name == "latest" {
300 continue
301 }
302 if kept >= keepLast {
303 break
304 }
305 keptTags[tagInfo.name] = struct{}{}
306 kept++
307 }
308
309 return keptTags
310}
311
312func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
313 removed := 0
314 for _, tag := range tagInfos {
315 if _, kept := keptTags[tag.name]; kept {
316 continue
317 }
318 if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
319 return 0, err
320 }
321 removed++
322 }
323
324 return removed, nil
325}
326
327func (s *Service) collectKeptTagDigests(
328 log zerowrap.Logger,
329 repository string,
330 tagInfos []registryTag,
331 keptTags map[string]struct{},
332 referencedDigests map[string]struct{},
333) error {
334 for _, tag := range tagInfos {
335 if _, kept := keptTags[tag.name]; !kept {
336 continue
337 }
338 if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
339 return err
340 }
341 }
342
343 return nil
344}
345
346// Prune runs runtime and/or registry prune based on options and aggregates reports.
347func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
348 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
349 zerowrap.FieldLayer: "usecase",
350 zerowrap.FieldUseCase: "Prune",
351 "keepLast": opts.KeepLast,
352 "pruneDangling": opts.PruneDangling,
353 "pruneRegistry": opts.PruneRegistry,
354 })
355 log := zerowrap.FromCtx(ctx)
356
357 var report domain.ImagePruneReport
358
359 if opts.PruneDangling {
360 runtimeReport, err := s.PruneRuntime(ctx)
361 if err != nil {
362 if !opts.PruneRegistry {
363 // Dangling-only: surface the error to the caller.
364 return report, fmt.Errorf("runtime prune failed: %w", err)
365 }
366 log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
367 } else {
368 report.Runtime = runtimeReport.Runtime
369 }
370 }
371
372 if opts.PruneRegistry {
373 registryReport, err := s.PruneRegistry(ctx, opts.KeepLast)
374 if err != nil {
375 return report, err
376 }
377 report.Registry = registryReport.Registry
378 }
379
380 return report, nil
381}
382
383type registryTag struct {
384 name string
385 modTime time.Time
386}
387
388type manifestReferences struct {
389 blobs []string
390 childManifests []string
391}
392
393func containsTag(tags []registryTag, name string) bool {
394 for _, tag := range tags {
395 if tag.name == name {
396 return true
397 }
398 }
399
400 return false
401}
402
403func (s *Service) collectReferencedDigests(
404 log zerowrap.Logger,
405 repository, reference string,
406 referencedDigests map[string]struct{},
407 visited map[string]struct{},
408) error {
409 visitKey := repository + "@" + reference
410 if _, seen := visited[visitKey]; seen {
411 return nil
412 }
413 visited[visitKey] = struct{}{}
414
415 manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
416 if err != nil {
417 log.Warn().
418 Str("repository", repository).
419 Str("reference", reference).
420 Err(err).
421 Msg("manifest not found during prune; skipping (orphaned reference)")
422 return nil
423 }
424
425 refs, err := parseManifestReferences(manifestData)
426 if err != nil {
427 return log.WrapErr(err, "failed to parse manifest")
428 }
429
430 for _, digest := range refs.blobs {
431 referencedDigests[digest] = struct{}{}
432 }
433
434 for _, childRef := range refs.childManifests {
435 referencedDigests[childRef] = struct{}{}
436 if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
437 return err
438 }
439 }
440
441 return nil
442}
443
444func parseManifestReferences(data []byte) (manifestReferences, error) {
445 var manifest struct {
446 Config struct {
447 Digest string `json:"digest"`
448 } `json:"config"`
449 Layers []struct {
450 Digest string `json:"digest"`
451 } `json:"layers"`
452 Manifests []struct {
453 Digest string `json:"digest"`
454 } `json:"manifests"`
455 }
456
457 if err := json.Unmarshal(data, &manifest); err != nil {
458 return manifestReferences{}, err
459 }
460
461 refs := manifestReferences{
462 blobs: make([]string, 0, len(manifest.Layers)+1),
463 childManifests: make([]string, 0, len(manifest.Manifests)),
464 }
465
466 if manifest.Config.Digest != "" {
467 refs.blobs = append(refs.blobs, manifest.Config.Digest)
468 }
469
470 for _, layer := range manifest.Layers {
471 if layer.Digest == "" {
472 continue
473 }
474 refs.blobs = append(refs.blobs, layer.Digest)
475 }
476
477 for _, child := range manifest.Manifests {
478 if child.Digest == "" {
479 continue
480 }
481 refs.childManifests = append(refs.childManifests, child.Digest)
482 }
483
484 return refs, nil
485}
486
487func isDanglingImage(repoTags []string) bool {
488 if len(repoTags) == 0 {
489 return true
490 }
491
492 for _, tag := range repoTags {
493 if tag == "<none>:<none>" || tag == "" {
494 continue
495 }
496 return false
497 }
498
499 return true
500}
501
502func splitRepoTag(repoTag string) (string, string) {
503 if repoTag == "" {
504 return "", ""
505 }
506 if strings.Contains(repoTag, "@") {
507 return repoTag, ""
508 }
509
510 idx := strings.LastIndex(repoTag, ":")
511 slashIdx := strings.LastIndex(repoTag, "/")
512 if idx <= slashIdx || idx >= len(repoTag)-1 {
513 return repoTag, ""
514 }
515
516 return repoTag[:idx], repoTag[idx+1:]
517}
518
519func normalizeRepository(repository string) string {
520 repository = strings.TrimSpace(repository)
521 if repository == "" {
522 return ""
523 }
524
525 idx := strings.Index(repository, "/")
526 if idx <= 0 {
527 return repository
528 }
529
530 firstComponent := repository[:idx]
531 if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
532 return repository[idx+1:]
533 }
534
535 return repository
536}
537
538func repoTagKey(repository, tag string) string {
539 return repository + "\x00" + tag
540}
541
542func isRegistryTagPlaceholder(tag string) bool {
543 return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
544}
545
546func lessImageInfo(left, right domain.ImageInfo) bool {
547 if left.Dangling != right.Dangling {
548 return !left.Dangling
549 }
550 if left.Repository != right.Repository {
551 return left.Repository < right.Repository
552 }
553 if left.Tag == "latest" && right.Tag != "latest" {
554 return true
555 }
556 if right.Tag == "latest" && left.Tag != "latest" {
557 return false
558 }
559 if !left.Created.Equal(right.Created) {
560 return left.Created.After(right.Created)
561 }
562 if left.Tag != right.Tag {
563 return left.Tag > right.Tag
564 }
565
566 return left.ID < right.ID
567}