1package pruneguard
2
3import (
4 "sort"
5 "strings"
6
7 "github.com/bnema/gordon/internal/domain"
8)
9
10// RuntimePlanInput is the complete input of one runtime image plan.
11type RuntimePlanInput struct {
12 Images []domain.RuntimeImage
13 Containers []domain.RuntimeContainerUse
14 Snapshot *domain.PruneProtectionSnapshot
15 // ProtectedContainerIDs are container IDs that must retain their
16 // images whatever their running state: durable recovery
17 // inhibitions name the generation that a replacement may already
18 // have superseded. A protected ID with no matching container makes
19 // every candidate unknown, because its image cannot be identified.
20 ProtectedContainerIDs []string
21 // Complete is false when the runtime inventory could not be read in
22 // full. Candidates that would otherwise be eligible become unknown.
23 Complete bool
24}
25
26// containerUseIndex indexes container image usage for one planning run.
27type containerUseIndex struct {
28 imageIDs map[string]struct{}
29 refs map[string]struct{}
30 // protectedImageIDs are the images held by protected containers.
31 protectedImageIDs map[string]struct{}
32 // unknown marks a container whose image identity could not be
33 // resolved. It makes every image candidate unknown: an image used
34 // by any container, including an unknown one, must survive.
35 unknown bool
36}
37
38func buildContainerUseIndex(containers []domain.RuntimeContainerUse, protectedIDs []string) containerUseIndex {
39 index := containerUseIndex{
40 imageIDs: make(map[string]struct{}, len(containers)),
41 refs: make(map[string]struct{}, len(containers)),
42 protectedImageIDs: make(map[string]struct{}),
43 }
44 byContainerID := make(map[string]domain.RuntimeContainerUse, len(containers))
45 for _, use := range containers {
46 if use.ContainerID != "" {
47 byContainerID[use.ContainerID] = use
48 }
49 if use.ImageID != "" {
50 index.imageIDs[use.ImageID] = struct{}{}
51 }
52 if use.ImageRef != "" {
53 index.refs[use.ImageRef] = struct{}{}
54 }
55 if use.ImageUnknown || (use.ImageID == "" && use.ImageRef == "") {
56 index.unknown = true
57 }
58 }
59
60 for _, containerID := range protectedIDs {
61 use, ok := byContainerID[containerID]
62 if !ok || use.ImageID == "" {
63 // The protected generation's image cannot be identified, so
64 // no candidate can be proven unused.
65 index.unknown = true
66 continue
67 }
68 index.protectedImageIDs[use.ImageID] = struct{}{}
69 }
70 return index
71}
72
73func (i containerUseIndex) usesImage(image domain.RuntimeImage) bool {
74 if _, ok := i.imageIDs[image.ID]; ok {
75 return true
76 }
77 for _, tag := range image.RepoTags {
78 if _, ok := i.refs[tag]; ok {
79 return true
80 }
81 }
82 for _, digest := range image.RepoDigests {
83 if _, ok := i.refs[digest]; ok {
84 return true
85 }
86 }
87 return false
88}
89
90// PlanRuntimeImages computes one verdict per runtime image. Output is
91// ordered by image ID so callers and reports are deterministic.
92func PlanRuntimeImages(input RuntimePlanInput) []domain.RuntimeImageVerdict {
93 uses := buildContainerUseIndex(input.Containers, input.ProtectedContainerIDs)
94
95 verdicts := make([]domain.RuntimeImageVerdict, 0, len(input.Images))
96 for _, image := range input.Images {
97 verdicts = append(verdicts, planRuntimeImage(image, uses, input))
98 }
99 sort.SliceStable(verdicts, func(i, j int) bool {
100 return verdicts[i].Ref.ID < verdicts[j].Ref.ID
101 })
102 return verdicts
103}
104
105func planRuntimeImage(image domain.RuntimeImage, uses containerUseIndex, input RuntimePlanInput) domain.RuntimeImageVerdict {
106 verdict := domain.RuntimeImageVerdict{Ref: domain.RuntimeImageRef{ID: image.ID}}
107
108 if !verdict.Ref.Valid() {
109 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownIdentity)
110 return verdict
111 }
112
113 // A durable root wins over provenance: a still-referenced image
114 // stays protected even if its labels are gone. Repo tags are the
115 // runtime-side identity of app-pinned content, so they are matched
116 // against tag-keyed roots as well as digests.
117 refs := append([]string{image.ID}, image.RepoDigests...)
118 for _, repoTag := range image.RepoTags {
119 if repoTag == "" || repoTag == "<none>:<none>" || repoTag == "<none>" {
120 continue
121 }
122 refs = append(refs, repoTag)
123 if tagRef, ok := domain.ParseRegistryTagRef(repoTag); ok {
124 refs = append(refs, tagRef.Key())
125 }
126 }
127 if matched, reasons := rootReasonsOrFallback(input.Snapshot, refs...); matched {
128 verdict.Verdict, verdict.Reasons = protected(reasons, domain.PruneReasonProtectedSharedContent)
129 return verdict
130 }
131
132 // Live or ambiguous container use protects before any ownership claim
133 // is consulted: an image a container could be running must survive.
134 if useVerdict, reasons, decided := containerUseVerdict(image, uses); decided {
135 return domain.RuntimeImageVerdict{Ref: domain.RuntimeImageRef{ID: image.ID}, Verdict: useVerdict, Reasons: reasons}
136 }
137
138 // Labels are hints only. Deletion requires positive durable ownership:
139 // a matching claim from an app's ownership record. Every matching
140 // claim is considered: an attached or retained record protects even
141 // when a newer released record for the same image also exists.
142 if claimVerdict, reasons, decided := imageClaimVerdict(image, input.Snapshot); decided {
143 return domain.RuntimeImageVerdict{Ref: domain.RuntimeImageRef{ID: image.ID}, Verdict: claimVerdict, Reasons: reasons}
144 }
145
146 // Safety is only provable against a complete view of both the
147 // runtime and the durable facts that claim the image.
148 if !input.Complete || !input.Snapshot.Complete() {
149 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownInventory)
150 return verdict
151 }
152
153 verdict.Verdict, verdict.Reasons = eligibleResult(domain.PruneReasonEligibleDanglingRuntimeImage)
154 return verdict
155}
156
157// containerUseVerdict decides a candidate from container/tag state alone.
158func containerUseVerdict(image domain.RuntimeImage, uses containerUseIndex) (domain.PruneVerdict, []domain.PruneReason, bool) {
159 if hasRepoTags(image.RepoTags) {
160 verdict, reasons := protected(nil, domain.PruneReasonProtectedTagged)
161 return verdict, reasons, true
162 }
163 if uses.usesImage(image) {
164 verdict, reasons := protected(nil, domain.PruneReasonProtectedContainerUse)
165 return verdict, reasons, true
166 }
167 if _, held := uses.protectedImageIDs[image.ID]; held {
168 verdict, reasons := protected(nil, domain.PruneReasonProtectedRecoveryInhibition)
169 return verdict, reasons, true
170 }
171 if uses.unknown {
172 verdict, reasons := unknownResult(domain.PruneReasonUnknownContainerUse)
173 return verdict, reasons, true
174 }
175 return "", nil, false
176}
177
178// noClaimVerdict classifies a runtime image with no durable ownership
179// record. App labels without a record are never eligible.
180func noClaimVerdict(image domain.RuntimeImage) (domain.PruneVerdict, []domain.PruneReason) {
181 switch ClassifyResource(image.Labels) {
182 case ProvenanceUnmanaged:
183 return protected(nil, domain.PruneReasonProtectedUnmanaged)
184 case ProvenanceLegacyManaged:
185 return protected(nil, domain.PruneReasonProtectedManagedOnly)
186 case ProvenanceServiceManaged:
187 return protected(nil, domain.PruneReasonProtectedServiceManaged)
188 default:
189 return unknownResult(domain.PruneReasonUnknownProvenance)
190 }
191}
192
193// imageClaimVerdict classifies an image from every matching ownership claim.
194// It reports whether the claims decided the verdict; false means all matching
195// claims are released and the ordinary eligibility gates still apply.
196func imageClaimVerdict(image domain.RuntimeImage, snapshot *domain.PruneProtectionSnapshot) (domain.PruneVerdict, []domain.PruneReason, bool) {
197 claims := snapshot.ImageClaimsFor(imageIdentities(image)...)
198 if len(claims) == 0 {
199 verdict, reasons := noClaimVerdict(image)
200 return verdict, reasons, true
201 }
202 protecting, released, invalid := false, false, false
203 for _, claim := range claims {
204 if claim.State.Protects() {
205 protecting = true
206 break
207 }
208 if !claim.Valid() {
209 invalid = true
210 continue
211 }
212 released = true
213 }
214 switch {
215 case protecting:
216 verdict, reasons := protected(nil, domain.PruneReasonProtectedOwnership)
217 return verdict, reasons, true
218 case invalid || !released:
219 verdict, reasons := unknownResult(domain.PruneReasonUnknownProvenance)
220 return verdict, reasons, true
221 default:
222 return "", nil, false
223 }
224}
225
226// imageIdentities returns every identity a durable image claim may match:
227// the runtime image ID, full repo tags and digests, the digest part of a
228// repo digest, and the registry tag key of a repo tag.
229func imageIdentities(image domain.RuntimeImage) []string {
230 identities := make([]string, 0, 1+len(image.RepoTags)+2*len(image.RepoDigests))
231 identities = append(identities, image.ID)
232 for _, repoTag := range image.RepoTags {
233 if repoTag == "" || repoTag == "<none>:<none>" || repoTag == "<none>" {
234 continue
235 }
236 identities = append(identities, repoTag)
237 if tagRef, ok := domain.ParseRegistryTagRef(repoTag); ok {
238 identities = append(identities, tagRef.Key())
239 }
240 }
241 for _, repoDigest := range image.RepoDigests {
242 if repoDigest == "" {
243 continue
244 }
245 identities = append(identities, repoDigest)
246 if _, digest, ok := strings.Cut(repoDigest, "@"); ok && digest != "" {
247 identities = append(identities, digest)
248 }
249 }
250 return identities
251}
252
253// hasRepoTags reports whether the runtime still has a usable tag for the
254// image. Placeholder tags never count.
255func hasRepoTags(repoTags []string) bool {
256 for _, tag := range repoTags {
257 if tag == "" || tag == "<none>:<none>" || tag == "<none>" {
258 continue
259 }
260 return true
261 }
262 return false
263}