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)
17
18// Service implements image list and prune operations.
19type Service struct {
20 runtime imageRuntime
21 manifestStorage out.ManifestStorage
22 blobStorage out.BlobStorage
23 log zerowrap.Logger
24}
25
26type imageRuntime interface {
27 ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
28 PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
29}
30
31// NewService creates a new images service.
32func NewService(
33 rt imageRuntime,
34 manifestStorage out.ManifestStorage,
35 blobStorage out.BlobStorage,
36 log zerowrap.Logger,
37) *Service {
38 return &Service{
39 runtime: rt,
40 manifestStorage: manifestStorage,
41 blobStorage: blobStorage,
42 log: log,
43 }
44}
45
46// ListImages returns images known by the runtime.
47func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
48 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
49 zerowrap.FieldLayer: "usecase",
50 zerowrap.FieldUseCase: "ListImages",
51 })
52 log := zerowrap.FromCtx(ctx)
53
54 details, err := s.runtime.ListImagesDetailed(ctx)
55 if err != nil {
56 return nil, log.WrapErr(err, "failed to list images")
57 }
58
59 images := make([]domain.ImageInfo, 0, len(details))
60 for _, detail := range details {
61 if isDanglingImage(detail.RepoTags) {
62 images = append(images, domain.ImageInfo{
63 Repository: "",
64 Tag: "",
65 Size: detail.Size,
66 Created: detail.Created,
67 ID: detail.ID,
68 Dangling: true,
69 })
70 continue
71 }
72
73 for _, repoTag := range detail.RepoTags {
74 if repoTag == "" || repoTag == "<none>:<none>" {
75 continue
76 }
77 repository, tag := splitRepoTag(repoTag)
78 images = append(images, domain.ImageInfo{
79 Repository: repository,
80 Tag: tag,
81 Size: detail.Size,
82 Created: detail.Created,
83 ID: detail.ID,
84 Dangling: false,
85 })
86 }
87 }
88
89 return images, nil
90}
91
92// PruneRuntime prunes dangling images from the runtime.
93func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
94 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95 zerowrap.FieldLayer: "usecase",
96 zerowrap.FieldUseCase: "PruneRuntime",
97 })
98 log := zerowrap.FromCtx(ctx)
99
100 pruneReport, err := s.runtime.PruneImages(ctx, true)
101 if err != nil {
102 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
103 }
104
105 return domain.ImagePruneReport{
106 Runtime: domain.RuntimePruneResult{
107 DeletedCount: len(pruneReport.DeletedIDs),
108 SpaceReclaimed: pruneReport.SpaceReclaimed,
109 },
110 }, nil
111}
112
113// PruneRegistry applies tag retention and blob garbage collection.
114// It keeps the most recent keepLast tags from tagInfos and always keeps
115// the "latest" tag when present, so up to keepLast+1 tags can be retained.
116func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118 zerowrap.FieldLayer: "usecase",
119 zerowrap.FieldUseCase: "PruneRegistry",
120 "keepLast": keepLast,
121 })
122 log := zerowrap.FromCtx(ctx)
123
124 if keepLast <= 0 {
125 return domain.ImagePruneReport{}, nil
126 }
127
128 repositories, err := s.manifestStorage.ListRepositories()
129 if err != nil {
130 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
131 }
132
133 referencedDigests := make(map[string]struct{})
134 report := domain.ImagePruneReport{}
135
136 for _, repository := range repositories {
137 tagInfos, err := s.loadRepositoryTagInfos(repository)
138 if err != nil {
139 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to load repository tags")
140 }
141 if len(tagInfos) == 0 {
142 continue
143 }
144
145 keptTags := buildKeptTagSet(tagInfos, keepLast)
146 removed, err := s.deleteUnkeptManifests(repository, tagInfos, keptTags)
147 if err != nil {
148 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
149 }
150 report.Registry.TagsRemoved += removed
151
152 if err := s.collectKeptTagDigests(log, repository, tagInfos, keptTags, referencedDigests); err != nil {
153 return domain.ImagePruneReport{}, err
154 }
155 }
156
157 blobs, err := s.blobStorage.ListBlobs()
158 if err != nil {
159 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
160 }
161
162 for _, digest := range blobs {
163 if _, referenced := referencedDigests[digest]; referenced {
164 continue
165 }
166 if err := s.blobStorage.DeleteBlob(digest); err != nil {
167 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
168 }
169 report.Registry.BlobsRemoved++
170 }
171
172 return report, nil
173}
174
175func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
176 tags, err := s.manifestStorage.ListTags(repository)
177 if err != nil {
178 return nil, err
179 }
180
181 tagInfos := make([]registryTag, 0, len(tags))
182 for _, tag := range tags {
183 modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
184 if err != nil {
185 return nil, err
186 }
187 tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
188 }
189
190 sort.Slice(tagInfos, func(i, j int) bool {
191 if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
192 return tagInfos[i].name > tagInfos[j].name
193 }
194 return tagInfos[i].modTime.After(tagInfos[j].modTime)
195 })
196
197 return tagInfos, nil
198}
199
200func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
201 keptTags := make(map[string]struct{})
202 if containsTag(tagInfos, "latest") {
203 keptTags["latest"] = struct{}{}
204 }
205
206 for i := 0; i < len(tagInfos) && i < keepLast; i++ {
207 keptTags[tagInfos[i].name] = struct{}{}
208 }
209
210 return keptTags
211}
212
213func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
214 removed := 0
215 for _, tag := range tagInfos {
216 if _, kept := keptTags[tag.name]; kept {
217 continue
218 }
219 if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
220 return 0, err
221 }
222 removed++
223 }
224
225 return removed, nil
226}
227
228func (s *Service) collectKeptTagDigests(
229 log zerowrap.Logger,
230 repository string,
231 tagInfos []registryTag,
232 keptTags map[string]struct{},
233 referencedDigests map[string]struct{},
234) error {
235 for _, tag := range tagInfos {
236 if _, kept := keptTags[tag.name]; !kept {
237 continue
238 }
239 if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
240 return err
241 }
242 }
243
244 return nil
245}
246
247// Prune runs runtime prune and registry prune and aggregates reports.
248func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
249 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
250 zerowrap.FieldLayer: "usecase",
251 zerowrap.FieldUseCase: "Prune",
252 "keepLast": keepLast,
253 })
254 log := zerowrap.FromCtx(ctx)
255
256 var report domain.ImagePruneReport
257
258 runtimeReport, err := s.PruneRuntime(ctx)
259 if err != nil {
260 log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
261 } else {
262 report.Runtime = runtimeReport.Runtime
263 }
264
265 registryReport, err := s.PruneRegistry(ctx, keepLast)
266 if err != nil {
267 return report, err
268 }
269
270 report.Registry = registryReport.Registry
271 return report, nil
272}
273
274type registryTag struct {
275 name string
276 modTime time.Time
277}
278
279type manifestReferences struct {
280 blobs []string
281 childManifests []string
282}
283
284func containsTag(tags []registryTag, name string) bool {
285 for _, tag := range tags {
286 if tag.name == name {
287 return true
288 }
289 }
290
291 return false
292}
293
294func (s *Service) collectReferencedDigests(
295 log zerowrap.Logger,
296 repository, reference string,
297 referencedDigests map[string]struct{},
298 visited map[string]struct{},
299) error {
300 visitKey := repository + "@" + reference
301 if _, seen := visited[visitKey]; seen {
302 return nil
303 }
304 visited[visitKey] = struct{}{}
305
306 manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
307 if err != nil {
308 return log.WrapErr(err, "failed to read manifest")
309 }
310
311 refs, err := parseManifestReferences(manifestData)
312 if err != nil {
313 return log.WrapErr(err, "failed to parse manifest")
314 }
315
316 for _, digest := range refs.blobs {
317 referencedDigests[digest] = struct{}{}
318 }
319
320 for _, childRef := range refs.childManifests {
321 referencedDigests[childRef] = struct{}{}
322 if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
323 return err
324 }
325 }
326
327 return nil
328}
329
330func parseManifestReferences(data []byte) (manifestReferences, error) {
331 var manifest struct {
332 Config struct {
333 Digest string `json:"digest"`
334 } `json:"config"`
335 Layers []struct {
336 Digest string `json:"digest"`
337 } `json:"layers"`
338 Manifests []struct {
339 Digest string `json:"digest"`
340 } `json:"manifests"`
341 }
342
343 if err := json.Unmarshal(data, &manifest); err != nil {
344 return manifestReferences{}, err
345 }
346
347 refs := manifestReferences{
348 blobs: make([]string, 0, len(manifest.Layers)+1),
349 childManifests: make([]string, 0, len(manifest.Manifests)),
350 }
351
352 if manifest.Config.Digest != "" {
353 refs.blobs = append(refs.blobs, manifest.Config.Digest)
354 }
355
356 for _, layer := range manifest.Layers {
357 if layer.Digest == "" {
358 continue
359 }
360 refs.blobs = append(refs.blobs, layer.Digest)
361 }
362
363 for _, child := range manifest.Manifests {
364 if child.Digest == "" {
365 continue
366 }
367 refs.childManifests = append(refs.childManifests, child.Digest)
368 }
369
370 return refs, nil
371}
372
373func isDanglingImage(repoTags []string) bool {
374 if len(repoTags) == 0 {
375 return true
376 }
377
378 for _, tag := range repoTags {
379 if tag == "<none>:<none>" || tag == "" {
380 continue
381 }
382 return false
383 }
384
385 return true
386}
387
388func splitRepoTag(repoTag string) (string, string) {
389 if repoTag == "" {
390 return "", ""
391 }
392 if strings.Contains(repoTag, "@") {
393 return repoTag, ""
394 }
395
396 idx := strings.LastIndex(repoTag, ":")
397 slashIdx := strings.LastIndex(repoTag, "/")
398 if idx <= slashIdx || idx >= len(repoTag)-1 {
399 return repoTag, ""
400 }
401
402 return repoTag[:idx], repoTag[idx+1:]
403}