v3.3.2 9697f9d

runtime.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close