1package pruneguard
2
3import (
4 "sort"
5 "time"
6
7 "github.com/bnema/gordon/internal/domain"
8)
9
10// RegistryTagCandidate is one registry tag as inventoried.
11type RegistryTagCandidate struct {
12 Ref domain.RegistryTagRef
13 // Digest is the manifest digest the tag resolved to; empty when
14 // unknown.
15 Digest string
16 // ModTime is the tag's last modification time.
17 ModTime time.Time
18 // ManifestKnown is false when the tag's manifest could not be read,
19 // so the content behind it cannot be proven unreferenced.
20 ManifestKnown bool
21}
22
23// RegistryPlanInput is the complete input of one registry tag plan.
24// KeepLast semantics match the product contract: latest plus the
25// keep_last newest non-latest tags per repository. KeepLast <= 0 skips
26// registry tag cleanup entirely, so the function returns no verdicts.
27type RegistryPlanInput struct {
28 Tags []RegistryTagCandidate
29 Snapshot *domain.PruneProtectionSnapshot
30 KeepLast int
31 // Complete is false when the registry inventory could not be read
32 // in full. Candidates that would otherwise be eligible become
33 // unknown instead.
34 Complete bool
35}
36
37// PlanRegistryTags computes one verdict per candidate tag. Output is
38// ordered by repository then tag, so callers and reports are
39// deterministic regardless of input order.
40func PlanRegistryTags(input RegistryPlanInput) []domain.RegistryTagVerdict {
41 if input.KeepLast <= 0 {
42 return nil
43 }
44
45 kept := retentionWindow(input.Tags, input.KeepLast)
46
47 verdicts := make([]domain.RegistryTagVerdict, 0, len(input.Tags))
48 for _, candidate := range input.Tags {
49 verdicts = append(verdicts, planRegistryTag(candidate, kept, input))
50 }
51 sortRegistryTagVerdicts(verdicts)
52 return verdicts
53}
54
55func planRegistryTag(candidate RegistryTagCandidate, kept map[string]struct{}, input RegistryPlanInput) domain.RegistryTagVerdict {
56 verdict := domain.RegistryTagVerdict{Ref: candidate.Ref, Digest: candidate.Digest}
57
58 if !candidate.Ref.Valid() {
59 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownIdentity)
60 return verdict
61 }
62
63 // A durable root wins over every other consideration: protected
64 // candidates stay protected even when the inventory is incomplete.
65 refs := []string{candidate.Ref.Key(), candidate.Ref.String()}
66 if candidate.Digest != "" {
67 refs = append(refs, candidate.Digest)
68 }
69 if matched, reasons := rootReasonsOrFallback(input.Snapshot, refs...); matched {
70 verdict.Verdict, verdict.Reasons = protected(reasons, domain.PruneReasonProtectedSharedContent)
71 return verdict
72 }
73
74 if candidate.Ref.Tag == "latest" {
75 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedLatest)
76 return verdict
77 }
78 if _, inWindow := kept[candidate.Ref.Key()]; inWindow {
79 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedRetentionWindow)
80 return verdict
81 }
82
83 if !candidate.ManifestKnown {
84 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownManifest)
85 return verdict
86 }
87 // Safety is only provable against a complete view of both the
88 // registry and the durable facts that claim it. An incomplete app
89 // snapshot could be hiding a root for this very tag.
90 if !input.Complete || !input.Snapshot.Complete() {
91 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownInventory)
92 return verdict
93 }
94
95 verdict.Verdict, verdict.Reasons = eligibleResult(domain.PruneReasonEligibleRetention)
96 return verdict
97}
98
99// retentionWindow returns the canonical keys of the latest tag plus the
100// keep_last newest non-latest tags of every repository. Ordering is by
101// modification time descending, then tag descending, so equal
102// timestamps still produce one deterministic window.
103func retentionWindow(tags []RegistryTagCandidate, keepLast int) map[string]struct{} {
104 byRepository := make(map[string][]RegistryTagCandidate)
105 for _, tag := range tags {
106 if !tag.Ref.Valid() {
107 continue
108 }
109 byRepository[tag.Ref.Repository] = append(byRepository[tag.Ref.Repository], tag)
110 }
111
112 kept := make(map[string]struct{})
113 for _, repositoryTags := range byRepository {
114 sort.SliceStable(repositoryTags, func(i, j int) bool {
115 if repositoryTags[i].ModTime.Equal(repositoryTags[j].ModTime) {
116 return repositoryTags[i].Ref.Tag > repositoryTags[j].Ref.Tag
117 }
118 return repositoryTags[i].ModTime.After(repositoryTags[j].ModTime)
119 })
120
121 count := 0
122 for _, tag := range repositoryTags {
123 if tag.Ref.Tag == "latest" {
124 kept[tag.Ref.Key()] = struct{}{}
125 continue
126 }
127 if count >= keepLast {
128 break
129 }
130 kept[tag.Ref.Key()] = struct{}{}
131 count++
132 }
133 }
134 return kept
135}
136
137func sortRegistryTagVerdicts(verdicts []domain.RegistryTagVerdict) {
138 sort.SliceStable(verdicts, func(i, j int) bool {
139 if verdicts[i].Ref.Repository != verdicts[j].Ref.Repository {
140 return verdicts[i].Ref.Repository < verdicts[j].Ref.Repository
141 }
142 return verdicts[i].Ref.Tag < verdicts[j].Ref.Tag
143 })
144}