1package images
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "sort"
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/internal/usecase/pruneguard"
17 "github.com/bnema/gordon/internal/usecase/registrystate"
18)
19
20// Supported OCI/Docker manifest media types. Anything else makes the
21// content behind it unknown rather than unreferenced.
22const (
23 mediaTypeDockerManifest = "application/vnd.docker.distribution.manifest.v2+json"
24 mediaTypeDockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
25 mediaTypeOCIManifest = "application/vnd.oci.image.manifest.v1+json"
26 mediaTypeOCIImageIndex = "application/vnd.oci.image.index.v1+json"
27)
28
29// WithPrunePorts wires the prune ports: the coherent protection store,
30// the runtime inventory/deletion port, and the GC barrier. All three
31// are required for prune; a missing port fails closed.
32func (s *Service) WithPrunePorts(protection out.PruneProtectionStore, pruneRuntime out.PruneRuntime, barrier out.GCBarrier) *Service {
33 s.protection = protection
34 s.pruneRuntime = pruneRuntime
35 s.barrier = barrier
36 return s
37}
38
39// acquireExclusive takes the GC exclusive lease for the whole prune run,
40// or a no-op lease when no barrier is wired.
41func (s *Service) acquireExclusive(ctx context.Context) (out.GCLease, error) {
42 if s.barrier == nil {
43 return noopGCLease{}, nil
44 }
45 return s.barrier.AcquireExclusive(ctx)
46}
47
48// noopGCLease is the lease used when no barrier is wired.
49type noopGCLease struct{}
50
51func (noopGCLease) Release() {}
52
53// Prune plans and (unless DryRun) executes one image prune run.
54//
55// The whole plan is computed and validated before anything is deleted.
56// A valid plan with zero eligible resources succeeds: protected and
57// unknown candidates are normal outcomes, never an operation-wide
58// refusal.
59func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
60 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
61 zerowrap.FieldLayer: "usecase",
62 zerowrap.FieldUseCase: "Prune",
63 "keepLast": opts.KeepLast,
64 "pruneDangling": opts.PruneDangling,
65 "pruneRegistry": opts.PruneRegistry,
66 "dryRun": opts.DryRun,
67 })
68 log := zerowrap.FromCtx(ctx)
69
70 lease, err := s.acquireExclusive(ctx)
71 if err != nil {
72 return domain.ImagePruneReport{}, fmt.Errorf("images: acquire prune lease: %w", err)
73 }
74 defer lease.Release()
75
76 if s.protection == nil {
77 return domain.ImagePruneReport{}, fmt.Errorf("images: prune requires a protection store: %w", domain.ErrPruneDisabled)
78 }
79
80 snapshot, err := s.protection.ProtectionSnapshot(ctx)
81 if err != nil {
82 return domain.ImagePruneReport{}, fmt.Errorf("images: read protection snapshot: %w", err)
83 }
84
85 plan := &domain.PrunePlan{Gaps: append([]domain.InventoryGap(nil), snapshot.Gaps...)}
86
87 if opts.PruneDangling {
88 verdicts, gaps, err := s.planRuntimeImages(ctx, snapshot)
89 if err != nil {
90 return domain.ImagePruneReport{}, err
91 }
92 plan.Images = verdicts
93 plan.Gaps = append(plan.Gaps, gaps...)
94 }
95
96 // Registry planning and deletion share one registry mutation lock,
97 // so a concurrent push can never make content look unreferenced
98 // between the plan and the deletion it authorizes. Runtime deletion
99 // runs outside that lock: it does not touch registry storage, and
100 // holding the lock across it would stall every registry push.
101 if !opts.PruneRegistry {
102 return s.finishPlan(ctx, plan, opts.DryRun, log)
103 }
104
105 s.mutationMu.Lock()
106 tags, blobs, gaps := s.planRegistry(snapshot, opts.KeepLast)
107 plan.Tags = tags
108 plan.Blobs = blobs
109 plan.Gaps = append(plan.Gaps, gaps...)
110
111 if err := plan.Validate(); err != nil {
112 s.mutationMu.Unlock()
113 return domain.ImagePruneReport{}, fmt.Errorf("images: refusing to execute an invalid prune plan: %w", err)
114 }
115 report := domain.ImagePruneReport{Plan: *domain.NewPruneReport(plan, !opts.DryRun)}
116 if opts.DryRun {
117 s.mutationMu.Unlock()
118 return report, nil
119 }
120 if err := s.executeRegistry(ctx, plan, &report, log); err != nil {
121 s.mutationMu.Unlock()
122 return report, err
123 }
124 s.mutationMu.Unlock()
125
126 s.executeRuntimeImages(ctx, plan, &report, log)
127 return report, nil
128}
129
130// finishPlan validates the complete plan and executes the runtime part.
131// Every deletion happens after the whole plan proved itself well formed.
132func (s *Service) finishPlan(ctx context.Context, plan *domain.PrunePlan, dryRun bool, log zerowrap.Logger) (domain.ImagePruneReport, error) {
133 if err := plan.Validate(); err != nil {
134 return domain.ImagePruneReport{}, fmt.Errorf("images: refusing to execute an invalid prune plan: %w", err)
135 }
136 report := domain.ImagePruneReport{Plan: *domain.NewPruneReport(plan, !dryRun)}
137 if dryRun {
138 return report, nil
139 }
140 s.executeRuntimeImages(ctx, plan, &report, log)
141 return report, nil
142}
143
144// planRuntimeImages plans runtime image deletion from one coherent
145// inventory. An unwired or unreadable runtime inventory yields gaps
146// instead of an empty (apparently safe) runtime.
147func (s *Service) planRuntimeImages(ctx context.Context, snapshot *domain.PruneProtectionSnapshot) ([]domain.RuntimeImageVerdict, []domain.InventoryGap, error) {
148 if s.pruneRuntime == nil {
149 return nil, []domain.InventoryGap{{
150 Source: domain.InventorySourceRuntimeImages,
151 Reason: domain.PruneReasonUnknownInventory,
152 Detail: "runtime prune port is not wired",
153 }}, nil
154 }
155 inventory, err := s.pruneRuntime.InventoryRuntime(ctx)
156 if err != nil {
157 return nil, nil, fmt.Errorf("images: read runtime inventory: %w", err)
158 }
159 verdicts := pruneguard.PlanRuntimeImages(pruneguard.RuntimePlanInput{
160 Images: inventory.Images,
161 Containers: inventory.Containers,
162 Snapshot: snapshot,
163 ProtectedContainerIDs: snapshot.RefsOfKind(domain.ProtectionRecoveryInhibition),
164 Complete: inventory.Complete(),
165 })
166 return verdicts, inventory.Gaps, nil
167}
168
169// executeRuntimeImages deletes exactly the planned runtime image IDs.
170// A single deletion failure is isolated: it is reported and the run
171// continues with the remaining candidates.
172func (s *Service) executeRuntimeImages(ctx context.Context, plan *domain.PrunePlan, report *domain.ImagePruneReport, log zerowrap.Logger) {
173 for _, ref := range plan.EligibleImages() {
174 if err := s.pruneRuntime.RemoveImageExact(ctx, ref); err != nil {
175 log.Warn().Err(err).Str("image", ref.ID).Msg("runtime image prune failed; skipping")
176 report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
177 Kind: domain.PruneResourceRuntimeImage, Ref: ref.ID, Err: err.Error(),
178 })
179 continue
180 }
181 report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
182 Kind: domain.PruneResourceRuntimeImage, Ref: ref.ID, Verdict: domain.PruneVerdictEligible,
183 })
184 report.Runtime.DeletedCount++
185 }
186}
187
188// planRegistry plans tag retention and blob garbage collection from the
189// registry inventory. It never deletes: the caller executes only after
190// the complete plan validates.
191//
192// The returned gaps record every fact that could not be read. A tag
193// whose manifest is unreadable stays unknown, and a blob whose
194// reference set cannot be fully proven stays unknown too.
195func (s *Service) planRegistry(snapshot *domain.PruneProtectionSnapshot, keepLast int) ([]domain.RegistryTagVerdict, []domain.OCIBlobVerdict, []domain.InventoryGap) {
196 if keepLast <= 0 {
197 // Documented behavior: keep_last=0 skips registry tag and blob
198 // cleanup entirely.
199 return nil, nil, nil
200 }
201
202 repositories, err := s.manifestStorage.ListRepositories()
203 if err != nil {
204 return nil, nil, []domain.InventoryGap{{
205 Source: domain.InventorySourceRegistry,
206 Reason: domain.PruneReasonUnknownInventory,
207 Detail: "list repositories failed",
208 }}
209 }
210 sort.Strings(repositories)
211
212 var tags []pruneguard.RegistryTagCandidate
213 var gaps []domain.InventoryGap
214 for _, repository := range repositories {
215 repositoryTags, repositoryGaps := s.inventoryRepository(repository)
216 tags = append(tags, repositoryTags...)
217 gaps = append(gaps, repositoryGaps...)
218 }
219
220 complete := len(gaps) == 0
221 verdicts := pruneguard.PlanRegistryTags(pruneguard.RegistryPlanInput{
222 Tags: tags,
223 Snapshot: snapshot,
224 KeepLast: keepLast,
225 Complete: complete,
226 })
227
228 blobs, blobGaps := s.planRegistryBlobs(snapshot, verdicts, tags, complete, repositories)
229 gaps = append(gaps, blobGaps...)
230 return verdicts, blobs, gaps
231}
232
233// inventoryRepository inventories one repository's tags and their
234// manifests. A tag whose manifest cannot be read is kept as a candidate
235// with ManifestKnown=false, so the planner fails closed on it.
236func (s *Service) inventoryRepository(repository string) ([]pruneguard.RegistryTagCandidate, []domain.InventoryGap) {
237 tagNames, err := s.manifestStorage.ListTags(repository)
238 if err != nil {
239 return nil, []domain.InventoryGap{{
240 Source: domain.InventorySourceRegistry,
241 Reason: domain.PruneReasonUnknownInventory,
242 Detail: "list tags failed for " + repository,
243 }}
244 }
245
246 sort.Strings(tagNames)
247 candidates := make([]pruneguard.RegistryTagCandidate, 0, len(tagNames))
248 var gaps []domain.InventoryGap
249 for _, tagName := range tagNames {
250 ref := domain.RegistryTagRef{Repository: repository, Tag: tagName}
251 if !ref.Valid() {
252 // A tag we cannot name is a tag whose content we cannot
253 // prove unreferenced, so the whole registry plan fails closed.
254 gaps = append(gaps, domain.InventoryGap{
255 Source: domain.InventorySourceRegistry,
256 Reason: domain.PruneReasonUnknownIdentity,
257 Detail: "unnamed or malformed tag in " + repository,
258 })
259 continue
260 }
261 candidate := pruneguard.RegistryTagCandidate{Ref: ref}
262 modTime, err := s.manifestStorage.GetManifestModTime(repository, tagName)
263 if err != nil {
264 gaps = append(gaps, domain.InventoryGap{
265 Source: domain.InventorySourceRegistry,
266 Reason: domain.PruneReasonUnknownInventory,
267 Detail: "manifest modification time unreadable for " + ref.String(),
268 })
269 }
270 candidate.ModTime = modTime
271
272 data, _, err := s.manifestStorage.GetManifest(repository, tagName)
273 if err != nil {
274 gaps = append(gaps, domain.InventoryGap{
275 Source: domain.InventorySourceManifests,
276 Reason: domain.PruneReasonUnknownManifest,
277 Detail: "manifest unreadable for " + ref.String(),
278 })
279 candidates = append(candidates, candidate)
280 continue
281 }
282 digest := digestOfManifest(data)
283 _, mediaType, err := parseManifestDocument(data)
284 if err != nil {
285 gaps = append(gaps, domain.InventoryGap{
286 Source: domain.InventorySourceManifests,
287 Reason: domain.PruneReasonUnknownManifest,
288 Detail: "manifest corrupt for " + ref.String(),
289 })
290 candidates = append(candidates, candidate)
291 continue
292 }
293 if !supportedManifestMediaType(mediaType) {
294 gaps = append(gaps, domain.InventoryGap{
295 Source: domain.InventorySourceManifests,
296 Reason: domain.PruneReasonUnknownMediaType,
297 Detail: "unsupported manifest media type for " + ref.String(),
298 })
299 candidates = append(candidates, candidate)
300 continue
301 }
302 candidate.Digest = digest
303 candidate.ManifestKnown = true
304 candidates = append(candidates, candidate)
305 }
306 return candidates, gaps
307}
308
309// planRegistryBlobs computes one verdict per stored blob.
310//
311// Blobs reachable from the retained (protected or kept) manifest set
312// are protected. When any retained closure could not be read or parsed
313// completely, unreferenced blobs become unknown instead of eligible: an
314// incomplete reference set is never proof of unreachability.
315func (s *Service) planRegistryBlobs(
316 snapshot *domain.PruneProtectionSnapshot,
317 verdicts []domain.RegistryTagVerdict,
318 tags []pruneguard.RegistryTagCandidate,
319 inventoryComplete bool,
320 repositories []string,
321) ([]domain.OCIBlobVerdict, []domain.InventoryGap) {
322 now := time.Now().UTC()
323
324 // Roots are every tag the plan does not delete: retained tags and
325 // any tag whose manifest could not be read at all (which has no
326 // verdict entry yet still must keep protecting its content).
327 planned := make(map[string]struct{}, len(verdicts))
328 var roots []retainedRoot
329 for _, verdict := range verdicts {
330 planned[verdict.Ref.Key()] = struct{}{}
331 if verdict.Verdict == domain.PruneVerdictEligible {
332 continue
333 }
334 roots = append(roots, retainedRoot{Repository: verdict.Ref.Repository, Reference: verdict.Ref.Tag})
335 }
336 for _, tag := range tags {
337 if _, hasVerdict := planned[tag.Ref.Key()]; hasVerdict {
338 continue
339 }
340 roots = append(roots, retainedRoot{Repository: tag.Ref.Repository, Reference: tag.Ref.Tag})
341 }
342
343 closure := newClosure()
344 // A tag the inventory could not read may reference any blob, so an
345 // incomplete tag inventory makes the whole reference set unprovable.
346 closure.complete = inventoryComplete
347 for _, root := range roots {
348 s.traverseClosure(root, closure)
349 }
350 // Durable roots qualified by repository and digest keep the FULL OCI
351 // closure of their manifest: a moved tag must not let prune delete the
352 // config or layers of a digest an active deployment still pins. Roots
353 // whose repository has no local content are skipped: the image was
354 // never pushed here, so there is no local closure to preserve. An
355 // unresolved root in a local repository marks the closure incomplete,
356 // so unreferenced blobs stay unknown instead of eligible.
357 localRepositories := make(map[string]struct{}, len(repositories))
358 for _, repository := range repositories {
359 localRepositories[repository] = struct{}{}
360 }
361 for _, root := range snapshot.Roots {
362 if root.Repository == "" || !domain.ValidOCIDigest(root.Ref) {
363 continue
364 }
365 if _, local := localRepositories[root.Repository]; !local {
366 continue
367 }
368 s.traverseClosure(retainedRoot{Repository: root.Repository, Reference: root.Ref}, closure)
369 }
370
371 // Durable roots claim digests directly as well as through tag
372 // closure: an app service, apply intent, or unfinished operation
373 // can pin content by digest alone. Those digests must never be
374 // judged unreferenced just because no retained tag reaches them.
375 snapshotComplete := snapshot.Complete()
376
377 pending := s.registryState.PendingDigests(now)
378
379 blobs, err := s.blobStorage.ListBlobs()
380 if err != nil {
381 return nil, append(closure.gaps, domain.InventoryGap{
382 Source: domain.InventorySourceRegistry,
383 Reason: domain.PruneReasonUnknownInventory,
384 Detail: "list blobs failed",
385 })
386 }
387 sort.Strings(blobs)
388
389 gaps := closure.gaps
390 decisions := blobDecision{
391 snapshot: snapshot,
392 closure: closure,
393 pending: pending,
394 now: now,
395 snapshotComplete: snapshotComplete,
396 }
397 out := make([]domain.OCIBlobVerdict, 0, len(blobs))
398 for _, digest := range blobs {
399 out = append(out, s.decideBlob(digest, decisions))
400 }
401 return out, gaps
402}
403
404// blobDecision carries the shared inputs of one blob verdict.
405type blobDecision struct {
406 snapshot *domain.PruneProtectionSnapshot
407 closure *closure
408 pending map[string]struct{}
409 now time.Time
410 snapshotComplete bool
411}
412
413// decideBlob resolves one stored blob, strongest protection first.
414func (s *Service) decideBlob(digest string, in blobDecision) domain.OCIBlobVerdict {
415 verdict := domain.OCIBlobVerdict{Ref: domain.OCIRef{Digest: digest}}
416 protect := func(reason domain.PruneReason) domain.OCIBlobVerdict {
417 verdict.Verdict = domain.PruneVerdictProtected
418 verdict.Reasons = domain.CanonicalReasons(reason)
419 return verdict
420 }
421 unknown := func(reason domain.PruneReason) domain.OCIBlobVerdict {
422 verdict.Verdict = domain.PruneVerdictUnknown
423 verdict.Reasons = domain.CanonicalReasons(reason)
424 return verdict
425 }
426
427 if !verdict.Ref.Valid() {
428 return unknown(domain.PruneReasonUnknownIdentity)
429 }
430 // Durable state can claim a digest directly, without any retained
431 // tag reaching it.
432 if matched, reasons := rootReasonsForDigest(in.snapshot, digest); matched {
433 verdict.Verdict = domain.PruneVerdictProtected
434 verdict.Reasons = reasons
435 return verdict
436 }
437 if in.closure.references(digest) {
438 return protect(domain.PruneReasonProtectedSharedContent)
439 }
440 if _, isPending := in.pending[digest]; isPending {
441 return protect(domain.PruneReasonProtectedPendingUpload)
442 }
443 if !in.closure.complete || !in.snapshotComplete {
444 return unknown(domain.PruneReasonUnknownManifest)
445 }
446 // A blob that cannot be aged cannot be proven past the upload TTL,
447 // so it stays unknown instead of becoming eligible.
448 modTime, err := s.blobStorage.GetBlobModTime(digest)
449 if err != nil {
450 return unknown(domain.PruneReasonUnknownInventory)
451 }
452 if in.now.Sub(modTime) <= registrystate.PendingBlobTTL {
453 return protect(domain.PruneReasonProtectedPendingUpload)
454 }
455 verdict.Verdict = domain.PruneVerdictEligible
456 verdict.Reasons = domain.CanonicalReasons(domain.PruneReasonEligibleUnreferencedBlob)
457 return verdict
458}
459
460// rootReasonsForDigest reports whether the durable snapshot claims the
461// digest directly, and with which verdict reasons.
462func rootReasonsForDigest(snapshot *domain.PruneProtectionSnapshot, digest string) (bool, []domain.PruneReason) {
463 if snapshot == nil {
464 return false, nil
465 }
466 matched := false
467 for _, root := range snapshot.Roots {
468 if root.Ref == digest {
469 matched = true
470 break
471 }
472 }
473 if !matched {
474 return false, nil
475 }
476 reasons := snapshot.RootReasons(digest)
477 if len(reasons) == 0 {
478 reasons = domain.CanonicalReasons(domain.PruneReasonProtectedSharedContent)
479 }
480 return true, reasons
481}
482
483// retainedRoot is one manifest reference whose closure must survive.
484type retainedRoot struct {
485 Repository string
486 Reference string
487}
488
489// closure is the transitive reference set of every retained manifest.
490type closure struct {
491 digests map[string]struct{}
492 visited map[string]struct{}
493 complete bool
494 gaps []domain.InventoryGap
495}
496
497func newClosure() *closure {
498 return &closure{
499 digests: make(map[string]struct{}),
500 visited: make(map[string]struct{}),
501 complete: true,
502 }
503}
504
505func (c *closure) references(digest string) bool {
506 _, ok := c.digests[digest]
507 return ok
508}
509
510// traverseClosure walks one retained reference transitively. A missing,
511// corrupt, or unsupported node marks the closure incomplete: the caller
512// then treats unreferenced blobs as unknown instead of eligible.
513func (s *Service) traverseClosure(root retainedRoot, c *closure) {
514 visitKey := root.Repository + "\x00" + root.Reference
515 if _, seen := c.visited[visitKey]; seen {
516 return
517 }
518 c.visited[visitKey] = struct{}{}
519
520 data, _, err := s.manifestStorage.GetManifest(root.Repository, root.Reference)
521 if err != nil {
522 c.complete = false
523 c.gaps = append(c.gaps, domain.InventoryGap{
524 Source: domain.InventorySourceManifests,
525 Reason: domain.PruneReasonUnknownManifest,
526 Detail: "retained manifest unreadable for " + root.Repository + "@" + root.Reference,
527 })
528 return
529 }
530 references, mediaType, err := parseManifestDocument(data)
531 if err != nil {
532 c.complete = false
533 c.gaps = append(c.gaps, domain.InventoryGap{
534 Source: domain.InventorySourceManifests,
535 Reason: domain.PruneReasonUnknownManifest,
536 Detail: "retained manifest corrupt for " + root.Repository + "@" + root.Reference,
537 })
538 return
539 }
540 if !supportedManifestMediaType(mediaType) {
541 c.complete = false
542 c.gaps = append(c.gaps, domain.InventoryGap{
543 Source: domain.InventorySourceManifests,
544 Reason: domain.PruneReasonUnknownMediaType,
545 Detail: "retained manifest media type unsupported for " + root.Repository + "@" + root.Reference,
546 })
547 return
548 }
549
550 // The manifest body itself is addressable content: record its digest
551 // so a stored manifest copy is never judged unreferenced.
552 c.digests[digestOfManifest(data)] = struct{}{}
553
554 for _, digest := range references.blobs {
555 c.digests[digest] = struct{}{}
556 }
557 for _, child := range references.childManifests {
558 c.digests[child] = struct{}{}
559 s.traverseClosure(retainedRoot{Repository: root.Repository, Reference: child}, c)
560 }
561}
562
563// executeRegistry deletes exactly the planned tag references and then
564// exactly the planned unreferenced blobs. It runs after the complete
565// plan has been validated, never before.
566func (s *Service) executeRegistry(ctx context.Context, plan *domain.PrunePlan, report *domain.ImagePruneReport, log zerowrap.Logger) error {
567 for _, ref := range plan.EligibleTags() {
568 if err := s.manifestStorage.DeleteManifest(ref.Repository, ref.Tag); err != nil {
569 log.Warn().Err(err).Str("tag", ref.String()).Msg("failed to delete manifest, skipping")
570 report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
571 Kind: domain.PruneResourceRegistryTag, Ref: ref.String(), Err: err.Error(),
572 })
573 continue
574 }
575 report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
576 Kind: domain.PruneResourceRegistryTag, Ref: ref.String(), Verdict: domain.PruneVerdictEligible,
577 })
578 report.Registry.TagsRemoved++
579 }
580
581 for _, ref := range plan.EligibleBlobs() {
582 size, err := s.blobStorage.DeleteBlob(ref.Digest)
583 if err != nil {
584 log.Warn().Err(err).Str("digest", ref.Digest).Msg("failed to delete blob, skipping")
585 report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
586 Kind: domain.PruneResourceOCIBlob, Ref: ref.String(), Err: err.Error(),
587 })
588 continue
589 }
590 report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
591 Kind: domain.PruneResourceOCIBlob, Ref: ref.String(), Verdict: domain.PruneVerdictEligible,
592 })
593 report.Registry.BlobsRemoved++
594 // Each stored blob is deleted once, so its size is exact.
595 report.Registry.SpaceReclaimed += size
596 report.Plan.ReclaimedBytes += size
597 report.Plan.ReclaimedKnown = true
598 }
599
600 uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(registrystate.PendingBlobTTL)
601 if err != nil {
602 log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
603 return nil
604 }
605 report.Registry.UploadsRemoved = uploadsRemoved
606 report.Registry.UploadSpaceReclaimed = uploadBytes
607 return nil
608}
609
610// manifestReferences is the parsed reference set of one manifest.
611type manifestReferences struct {
612 blobs []string
613 childManifests []string
614}
615
616// parseManifestDocument parses one manifest and reports its media type.
617func parseManifestDocument(data []byte) (manifestReferences, string, error) {
618 var document struct {
619 MediaType string `json:"mediaType"`
620 SchemaVersion int `json:"schemaVersion"`
621 Config struct {
622 Digest string `json:"digest"`
623 } `json:"config"`
624 Layers []struct {
625 Digest string `json:"digest"`
626 } `json:"layers"`
627 Manifests []struct {
628 Digest string `json:"digest"`
629 } `json:"manifests"`
630 Subject *struct {
631 Digest string `json:"digest"`
632 } `json:"subject"`
633 }
634 if err := json.Unmarshal(data, &document); err != nil {
635 return manifestReferences{}, "", err
636 }
637
638 refs := manifestReferences{
639 blobs: make([]string, 0, len(document.Layers)+1),
640 childManifests: make([]string, 0, len(document.Manifests)+1),
641 }
642 if document.Config.Digest != "" {
643 refs.blobs = append(refs.blobs, document.Config.Digest)
644 }
645 for _, layer := range document.Layers {
646 if layer.Digest != "" {
647 refs.blobs = append(refs.blobs, layer.Digest)
648 }
649 }
650 for _, child := range document.Manifests {
651 if child.Digest != "" {
652 refs.childManifests = append(refs.childManifests, child.Digest)
653 }
654 }
655 if document.Subject != nil && document.Subject.Digest != "" {
656 refs.childManifests = append(refs.childManifests, document.Subject.Digest)
657 }
658
659 mediaType := document.MediaType
660 if mediaType == "" {
661 // Older clients omit mediaType. Infer from the shape: a
662 // document with manifests is an index, one with layers and a
663 // config is a manifest. Anything else is unsupported.
664 switch {
665 case len(document.Manifests) > 0:
666 mediaType = mediaTypeOCIImageIndex
667 case document.Config.Digest != "" || len(document.Layers) > 0:
668 mediaType = mediaTypeOCIManifest
669 }
670 }
671 return refs, mediaType, nil
672}
673
674// supportedManifestMediaType reports whether the closure walker can
675// trust the reference set of a manifest with this media type.
676func supportedManifestMediaType(mediaType string) bool {
677 switch mediaType {
678 case mediaTypeDockerManifest, mediaTypeDockerManifestList, mediaTypeOCIManifest, mediaTypeOCIImageIndex:
679 return true
680 default:
681 // digest-addressed content often carries a content type that
682 // does not name a media type; treat it as supported only when
683 // it is one of the known values above.
684 return false
685 }
686}
687
688// digestOfManifest returns the sha256 digest of a stored manifest body.
689func digestOfManifest(data []byte) string {
690 sum := sha256.Sum256(data)
691 return "sha256:" + hex.EncodeToString(sum[:])
692}