prune.go RawHistoryFolder

1package domain
2
3import (
4	"fmt"
5	"sort"
6	"strings"
7	"time"
8)
9
10// This file freezes the selective-prune data model: resource identities,
11// protection roots, inventory completeness, verdicts, stable reason
12// codes, and the aggregate plan/report shapes. It is pure data plus
13// validation — no store reads, no runtime calls, no JSON concerns.
14//
15// Fail-closed rule: a candidate is only PruneVerdictEligible when every
16// fact needed to prove it safe was read completely. Missing, unreadable,
17// or unsupported information yields PruneVerdictUnknown for the
18// dependent candidates; it never yields eligibility, and it is never an
19// operation-wide shutdown.
20
21// PruneVerdict is the fail-closed classification of one prune candidate.
22type PruneVerdict string
23
24const (
25	// PruneVerdictEligible means every fact needed to prove the
26	// candidate safe was available and no durable claim covers it.
27	PruneVerdictEligible PruneVerdict = "eligible"
28	// PruneVerdictProtected means a durable fact positively claims the
29	// candidate, so it must survive this prune.
30	PruneVerdictProtected PruneVerdict = "protected"
31	// PruneVerdictUnknown means at least one fact needed to prove the
32	// candidate safe was missing, unreadable, or unsupported.
33	PruneVerdictUnknown PruneVerdict = "unknown"
34)
35
36// Valid reports whether v is one of the defined verdicts.
37func (v PruneVerdict) Valid() bool {
38	switch v {
39	case PruneVerdictEligible, PruneVerdictProtected, PruneVerdictUnknown:
40		return true
41	default:
42		return false
43	}
44}
45
46// PruneReason is a stable machine-readable reason code. The wire forms
47// are frozen: they appear in JSON and human output, so existing values
48// must not be renamed or re-typed.
49type PruneReason string
50
51// Eligible reason codes.
52const (
53	// PruneReasonEligibleRetention marks a registry tag beyond the
54	// latest + keep_last retention window with no protection root.
55	PruneReasonEligibleRetention PruneReason = "eligible-retention-window"
56	// PruneReasonEligibleUnreferencedBlob marks registry content no
57	// retained or protected manifest references.
58	PruneReasonEligibleUnreferencedBlob PruneReason = "eligible-unreferenced-blob"
59	// PruneReasonEligibleDanglingRuntimeImage marks a runtime image with
60	// no tag, no container use, and no protection root.
61	PruneReasonEligibleDanglingRuntimeImage PruneReason = "eligible-dangling-runtime-image"
62	// PruneReasonEligibleReleasedVolume marks an app-owned volume whose
63	// durable lifecycle state explicitly records released/abandoned,
64	// which no container uses.
65	PruneReasonEligibleReleasedVolume PruneReason = "eligible-released-volume"
66)
67
68// Protected reason codes.
69const (
70	// PruneReasonProtectedLatest marks the latest tag of a repository.
71	PruneReasonProtectedLatest PruneReason = "protected-latest"
72	// PruneReasonProtectedRetentionWindow marks a tag inside the
73	// configured latest + keep_last window.
74	PruneReasonProtectedRetentionWindow PruneReason = "protected-retention-window"
75	// PruneReasonProtectedDesiredRevision marks content a desired
76	// revision references.
77	PruneReasonProtectedDesiredRevision PruneReason = "protected-desired-revision"
78	// PruneReasonProtectedActiveService marks content an ACTIVE service
79	// references, including stopped and partially converged services.
80	PruneReasonProtectedActiveService PruneReason = "protected-active-service"
81	// PruneReasonProtectedRecoveryInhibition marks content a durable
82	// recovery inhibition still claims.
83	PruneReasonProtectedRecoveryInhibition PruneReason = "protected-recovery-inhibition"
84	// PruneReasonProtectedApplyIntent marks content a staged or
85	// committed apply intent can still materialize.
86	PruneReasonProtectedApplyIntent PruneReason = "protected-apply-intent"
87	// PruneReasonProtectedOperation marks content an unfinished
88	// operation journal references.
89	PruneReasonProtectedOperation PruneReason = "protected-operation"
90	// PruneReasonProtectedOwnership marks content a durable ownership
91	// record claims as attached or retained.
92	PruneReasonProtectedOwnership PruneReason = "protected-ownership"
93	// PruneReasonProtectedContainerUse marks runtime content any
94	// container uses, running or stopped.
95	PruneReasonProtectedContainerUse PruneReason = "protected-container-use"
96	// PruneReasonProtectedPendingUpload marks registry content a recent
97	// upload may still complete into.
98	PruneReasonProtectedPendingUpload PruneReason = "protected-pending-upload"
99	// PruneReasonProtectedSharedContent marks content referenced by
100	// retained or protected roots through the OCI closure.
101	PruneReasonProtectedSharedContent PruneReason = "protected-shared-content"
102	// PruneReasonProtectedPinned marks explicitly pinned content.
103	PruneReasonProtectedPinned PruneReason = "protected-pinned"
104	// PruneReasonProtectedServiceManaged marks a standalone service
105	// container's own managed resource.
106	PruneReasonProtectedServiceManaged PruneReason = "protected-service-managed"
107	// PruneReasonProtectedManagedOnly marks a Gordon-managed resource
108	// with no app ownership record: the legacy/unknown provenance class.
109	PruneReasonProtectedManagedOnly PruneReason = "protected-managed-without-ownership"
110	// PruneReasonProtectedTagged marks a runtime image that repo tags
111	// still point at: tag retention owns it, not runtime prune.
112	PruneReasonProtectedTagged PruneReason = "protected-tagged-runtime-image"
113	// PruneReasonProtectedUnmanaged marks a resource with no Gordon
114	// provenance at all. Gordon never adopts or deletes it.
115	PruneReasonProtectedUnmanaged PruneReason = "protected-unmanaged"
116)
117
118// Unknown reason codes.
119const (
120	// PruneReasonUnknownIdentity marks a candidate whose exact identity
121	// could not be established.
122	PruneReasonUnknownIdentity PruneReason = "unknown-identity"
123	// PruneReasonUnknownProvenance marks a resource whose ownership
124	// could not be established.
125	PruneReasonUnknownProvenance PruneReason = "unknown-provenance"
126	// PruneReasonUnknownInventory marks a candidate whose safety depends
127	// on an inventory that was read incompletely.
128	PruneReasonUnknownInventory PruneReason = "unknown-incomplete-inventory"
129	// PruneReasonUnknownManifest marks content reachable only through a
130	// missing, corrupt, or unreadable manifest.
131	PruneReasonUnknownManifest PruneReason = "unknown-manifest-unreadable"
132	// PruneReasonUnknownMediaType marks content behind an unsupported
133	// OCI media type or manifest shape.
134	PruneReasonUnknownMediaType PruneReason = "unknown-unsupported-media-type"
135	// PruneReasonUnknownContainerUse marks runtime content whose
136	// container usage could not be established.
137	PruneReasonUnknownContainerUse PruneReason = "unknown-container-use"
138)
139
140// pruneReasons lists every defined reason code in canonical output
141// order, grouped by verdict category.
142var pruneReasons = []PruneReason{
143	PruneReasonEligibleRetention,
144	PruneReasonEligibleUnreferencedBlob,
145	PruneReasonEligibleDanglingRuntimeImage,
146	PruneReasonEligibleReleasedVolume,
147
148	PruneReasonProtectedLatest,
149	PruneReasonProtectedRetentionWindow,
150	PruneReasonProtectedDesiredRevision,
151	PruneReasonProtectedActiveService,
152	PruneReasonProtectedRecoveryInhibition,
153	PruneReasonProtectedApplyIntent,
154	PruneReasonProtectedOperation,
155	PruneReasonProtectedOwnership,
156	PruneReasonProtectedContainerUse,
157	PruneReasonProtectedPendingUpload,
158	PruneReasonProtectedSharedContent,
159	PruneReasonProtectedPinned,
160	PruneReasonProtectedServiceManaged,
161	PruneReasonProtectedManagedOnly,
162	PruneReasonProtectedTagged,
163	PruneReasonProtectedUnmanaged,
164
165	PruneReasonUnknownIdentity,
166	PruneReasonUnknownProvenance,
167	PruneReasonUnknownInventory,
168	PruneReasonUnknownManifest,
169	PruneReasonUnknownMediaType,
170	PruneReasonUnknownContainerUse,
171}
172
173// pruneReasonCategory maps every reason code to the verdict it justifies.
174var pruneReasonCategory = map[PruneReason]PruneVerdict{
175	PruneReasonEligibleRetention:            PruneVerdictEligible,
176	PruneReasonEligibleUnreferencedBlob:     PruneVerdictEligible,
177	PruneReasonEligibleDanglingRuntimeImage: PruneVerdictEligible,
178	PruneReasonEligibleReleasedVolume:       PruneVerdictEligible,
179	PruneReasonProtectedLatest:              PruneVerdictProtected,
180	PruneReasonProtectedRetentionWindow:     PruneVerdictProtected,
181	PruneReasonProtectedDesiredRevision:     PruneVerdictProtected,
182	PruneReasonProtectedActiveService:       PruneVerdictProtected,
183	PruneReasonProtectedRecoveryInhibition:  PruneVerdictProtected,
184	PruneReasonProtectedApplyIntent:         PruneVerdictProtected,
185	PruneReasonProtectedOperation:           PruneVerdictProtected,
186	PruneReasonProtectedOwnership:           PruneVerdictProtected,
187	PruneReasonProtectedContainerUse:        PruneVerdictProtected,
188	PruneReasonProtectedPendingUpload:       PruneVerdictProtected,
189	PruneReasonProtectedSharedContent:       PruneVerdictProtected,
190	PruneReasonProtectedPinned:              PruneVerdictProtected,
191	PruneReasonProtectedServiceManaged:      PruneVerdictProtected,
192	PruneReasonProtectedManagedOnly:         PruneVerdictProtected,
193	PruneReasonProtectedTagged:              PruneVerdictProtected,
194	PruneReasonProtectedUnmanaged:           PruneVerdictProtected,
195	PruneReasonUnknownIdentity:              PruneVerdictUnknown,
196	PruneReasonUnknownProvenance:            PruneVerdictUnknown,
197	PruneReasonUnknownInventory:             PruneVerdictUnknown,
198	PruneReasonUnknownManifest:              PruneVerdictUnknown,
199	PruneReasonUnknownMediaType:             PruneVerdictUnknown,
200	PruneReasonUnknownContainerUse:          PruneVerdictUnknown,
201}
202
203// Valid reports whether r is a defined reason code.
204func (r PruneReason) Valid() bool {
205	_, ok := pruneReasonCategory[r]
206	return ok
207}
208
209// Category returns the verdict r justifies. An undefined reason returns
210// the empty verdict, which fails validation.
211func (r PruneReason) Category() PruneVerdict {
212	return pruneReasonCategory[r]
213}
214
215// PruneReasons returns every defined reason code in canonical order.
216func PruneReasons() []PruneReason {
217	out := make([]PruneReason, len(pruneReasons))
218	copy(out, pruneReasons)
219	return out
220}
221
222// CanonicalReasons returns reasons deduplicated and ordered by the
223// frozen canonical order, so verdict output is deterministic.
224func CanonicalReasons(reasons ...PruneReason) []PruneReason {
225	if len(reasons) == 0 {
226		return nil
227	}
228	seen := make(map[PruneReason]struct{}, len(reasons))
229	for _, reason := range reasons {
230		seen[reason] = struct{}{}
231	}
232	out := make([]PruneReason, 0, len(seen))
233	for _, reason := range pruneReasons {
234		if _, ok := seen[reason]; ok {
235			out = append(out, reason)
236		}
237	}
238	// Undefined reasons still surface, ordered after the known ones, so
239	// a programming error is visible instead of silently dropped.
240	var unknown []PruneReason
241	for reason := range seen {
242		if !reason.Valid() {
243			unknown = append(unknown, reason)
244		}
245	}
246	sort.Slice(unknown, func(i, j int) bool { return unknown[i] < unknown[j] })
247	return append(out, unknown...)
248}
249
250// PruneResourceKind classifies the namespace a candidate identity lives in.
251type PruneResourceKind string
252
253const (
254	// PruneResourceRegistryTag is a repository tag in registry storage.
255	PruneResourceRegistryTag PruneResourceKind = "registry-tag"
256	// PruneResourceOCIBlob is digest-addressed registry content.
257	PruneResourceOCIBlob PruneResourceKind = "oci-blob"
258	// PruneResourceRuntimeImage is a runtime image identified by image ID.
259	PruneResourceRuntimeImage PruneResourceKind = "runtime-image"
260	// PruneResourceVolume is a runtime volume identified by name.
261	PruneResourceVolume PruneResourceKind = "volume"
262)
263
264// RegistryTagRef identifies one registry tag by repository and tag.
265type RegistryTagRef struct {
266	Repository string
267	Tag        string
268}
269
270// NewRegistryTagRef validates and returns a registry tag identity.
271func NewRegistryTagRef(repository, tag string) (RegistryTagRef, error) {
272	ref := RegistryTagRef{Repository: strings.TrimSpace(repository), Tag: strings.TrimSpace(tag)}
273	if !ref.Valid() {
274		return RegistryTagRef{}, fmt.Errorf("invalid registry tag reference %q/%q", repository, tag)
275	}
276	return ref, nil
277}
278
279// Valid reports whether the reference identifies one exact registry tag.
280func (r RegistryTagRef) Valid() bool {
281	return r.Repository != "" && r.Tag != "" && !strings.ContainsAny(r.Repository, " \t\n") && !strings.ContainsAny(r.Tag, " \t\n")
282}
283
284// String renders repository:tag for humans. The form is ambiguous for
285// repositories that themselves contain a colon, so it must never be
286// parsed back: Key is the canonical identity.
287func (r RegistryTagRef) String() string {
288	if !r.Valid() {
289		return ""
290	}
291	return r.Repository + ":" + r.Tag
292}
293
294// Key is the deterministic, unambiguous map key for this reference.
295func (r RegistryTagRef) Key() string {
296	if !r.Valid() {
297		return ""
298	}
299	return r.Repository + "\x00" + r.Tag
300}
301
302// OCIRef identifies digest-addressed registry content. Scope is the
303// repository the content belongs to; an empty scope addresses the
304// global blob store.
305type OCIRef struct {
306	Scope  string
307	Digest string
308}
309
310// NewOCIRef validates and returns a digest identity.
311func NewOCIRef(scope, digest string) (OCIRef, error) {
312	ref := OCIRef{Scope: strings.TrimSpace(scope), Digest: strings.TrimSpace(digest)}
313	if !ref.Valid() {
314		return OCIRef{}, fmt.Errorf("invalid OCI reference %q@%q", scope, digest)
315	}
316	return ref, nil
317}
318
319// Valid reports whether the reference carries a plausible content digest.
320func (r OCIRef) Valid() bool {
321	return ValidOCIDigest(r.Digest) && !strings.ContainsAny(r.Scope, " \t\n")
322}
323
324// String renders scope@digest, or the bare digest for global content.
325func (r OCIRef) String() string {
326	if r.Digest == "" {
327		return ""
328	}
329	if r.Scope == "" {
330		return r.Digest
331	}
332	return r.Scope + "@" + r.Digest
333}
334
335// Key is the deterministic map key for this reference.
336func (r OCIRef) Key() string {
337	if !r.Valid() {
338		return ""
339	}
340	return r.Scope + "\x00" + r.Digest
341}
342
343// RuntimeImageRef identifies one runtime image by its exact image ID.
344type RuntimeImageRef struct {
345	ID string
346}
347
348// NewRuntimeImageRef validates and returns a runtime image identity.
349func NewRuntimeImageRef(id string) (RuntimeImageRef, error) {
350	ref := RuntimeImageRef{ID: strings.TrimSpace(id)}
351	if !ref.Valid() {
352		return RuntimeImageRef{}, fmt.Errorf("invalid runtime image ID %q", id)
353	}
354	return ref, nil
355}
356
357// Valid reports whether the image ID is usable as a deletion target.
358// Runtime placeholders such as <none> never are.
359func (r RuntimeImageRef) Valid() bool {
360	if r.ID == "" || strings.ContainsAny(r.ID, " \t\n") {
361		return false
362	}
363	return !strings.HasPrefix(r.ID, "<none>")
364}
365
366// String renders the image ID.
367func (r RuntimeImageRef) String() string { return r.ID }
368
369// RuntimeVolumeRef identifies one runtime volume by name.
370type RuntimeVolumeRef struct {
371	Name string
372}
373
374// NewRuntimeVolumeRef validates and returns a runtime volume identity.
375func NewRuntimeVolumeRef(name string) (RuntimeVolumeRef, error) {
376	ref := RuntimeVolumeRef{Name: strings.TrimSpace(name)}
377	if !ref.Valid() {
378		return RuntimeVolumeRef{}, fmt.Errorf("invalid runtime volume name %q", name)
379	}
380	return ref, nil
381}
382
383// Valid reports whether the volume name is usable as a deletion target.
384func (r RuntimeVolumeRef) Valid() bool {
385	return r.Name != "" && !strings.ContainsAny(r.Name, " \t\n")
386}
387
388// String renders the volume name.
389func (r RuntimeVolumeRef) String() string { return r.Name }
390
391// ValidOCIDigest reports whether digest has the OCI
392// algorithm:encoded form with a hex-encoded value.
393func ValidOCIDigest(digest string) bool {
394	algorithm, encoded, ok := strings.Cut(digest, ":")
395	if !ok || algorithm == "" || len(encoded) < 32 {
396		return false
397	}
398	for _, r := range algorithm {
399		if !isDigestAlgorithmRune(r) {
400			return false
401		}
402	}
403	for _, r := range encoded {
404		if !isHexRune(r) {
405			return false
406		}
407	}
408	return true
409}
410
411func isDigestAlgorithmRune(r rune) bool {
412	switch {
413	case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
414		return true
415	case r == '.', r == '-', r == '_', r == '+':
416		return true
417	default:
418		return false
419	}
420}
421
422func isHexRune(r rune) bool {
423	switch {
424	case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
425		return true
426	default:
427		return false
428	}
429}
430
431// ProtectionRootKind classifies one durable fact that retains content.
432type ProtectionRootKind string
433
434const (
435	// ProtectionLatest is the latest tag of a repository.
436	ProtectionLatest ProtectionRootKind = "latest"
437	// ProtectionRetentionWindow is the configured latest + keep_last set.
438	ProtectionRetentionWindow ProtectionRootKind = "retention-window"
439	// ProtectionDesiredRevision is a desired revision's image reference.
440	ProtectionDesiredRevision ProtectionRootKind = "desired-revision"
441	// ProtectionActiveService is an ACTIVE service's image reference,
442	// including stopped and partially converged services.
443	ProtectionActiveService ProtectionRootKind = "active-service"
444	// ProtectionRecoveryInhibition is a generation-scoped inhibition.
445	ProtectionRecoveryInhibition ProtectionRootKind = "recovery-inhibition"
446	// ProtectionApplyIntent is a staged or committed apply intent.
447	ProtectionApplyIntent ProtectionRootKind = "apply-intent"
448	// ProtectionOperation is an unfinished operation journal record.
449	ProtectionOperation ProtectionRootKind = "operation"
450	// ProtectionOwnership is a durable ownership claim on a resource.
451	ProtectionOwnership ProtectionRootKind = "ownership"
452	// ProtectionContainerUse is runtime container usage.
453	ProtectionContainerUse ProtectionRootKind = "container-use"
454	// ProtectionPendingUpload is a recent registry upload.
455	ProtectionPendingUpload ProtectionRootKind = "pending-upload"
456	// ProtectionSharedContent is transitive OCI closure content.
457	ProtectionSharedContent ProtectionRootKind = "shared-content"
458	// ProtectionPinned is explicitly pinned content.
459	ProtectionPinned ProtectionRootKind = "pinned"
460	// ProtectionServiceManaged is standalone service ownership.
461	ProtectionServiceManaged ProtectionRootKind = "service-managed"
462)
463
464// ProtectionRoot is one durable fact that must retain content.
465type ProtectionRoot struct {
466	Kind ProtectionRootKind
467	// Ref is the canonical identity the root protects in the namespace
468	// of its resource kind: a RegistryTagRef.Key, an OCIRef digest, a
469	// runtime image ID, or a runtime volume name.
470	Ref string
471	// Repository qualifies a digest Ref with the repository whose
472	// manifest names it, so prune can traverse the full OCI closure
473	// (config, layers, child manifests, subject) instead of protecting
474	// only the digest itself. Empty when not repository-scoped.
475	Repository string
476	// Owner names the app, service, or container carrying the root when
477	// known; it is informational and never used for matching.
478	Owner string
479}
480
481// Valid reports whether the root names a kind and a non-empty identity.
482func (r ProtectionRoot) Valid() bool {
483	return r.Kind != "" && r.Ref != ""
484}
485
486// ReasonForRootKind maps a protection root kind to the verdict reason it
487// justifies. An unmapped kind protects nothing and is reported as absent.
488func ReasonForRootKind(kind ProtectionRootKind) (PruneReason, bool) {
489	reason, ok := protectionRootReason[kind]
490	return reason, ok
491}
492
493var protectionRootReason = map[ProtectionRootKind]PruneReason{
494	ProtectionLatest:             PruneReasonProtectedLatest,
495	ProtectionRetentionWindow:    PruneReasonProtectedRetentionWindow,
496	ProtectionDesiredRevision:    PruneReasonProtectedDesiredRevision,
497	ProtectionActiveService:      PruneReasonProtectedActiveService,
498	ProtectionRecoveryInhibition: PruneReasonProtectedRecoveryInhibition,
499	ProtectionApplyIntent:        PruneReasonProtectedApplyIntent,
500	ProtectionOperation:          PruneReasonProtectedOperation,
501	ProtectionOwnership:          PruneReasonProtectedOwnership,
502	ProtectionContainerUse:       PruneReasonProtectedContainerUse,
503	ProtectionPendingUpload:      PruneReasonProtectedPendingUpload,
504	ProtectionSharedContent:      PruneReasonProtectedSharedContent,
505	ProtectionPinned:             PruneReasonProtectedPinned,
506	ProtectionServiceManaged:     PruneReasonProtectedServiceManaged,
507}
508
509// VolumeClaimState is the durable lifecycle state of one app-owned volume.
510type VolumeClaimState string
511
512const (
513	// VolumeClaimAttached means a live app generation still uses it.
514	VolumeClaimAttached VolumeClaimState = "attached"
515	// VolumeClaimRetained means the app was removed and the volume is
516	// retained without an owner: never implicitly deletable.
517	VolumeClaimRetained VolumeClaimState = "retained"
518	// VolumeClaimReleased means the owning app explicitly released the
519	// volume; only this state is ever eligible for pruning.
520	VolumeClaimReleased VolumeClaimState = "released"
521)
522
523// Valid reports whether s is a defined claim state.
524func (s VolumeClaimState) Valid() bool {
525	switch s {
526	case VolumeClaimAttached, VolumeClaimRetained, VolumeClaimReleased:
527		return true
528	default:
529		return false
530	}
531}
532
533// Protects reports whether the claim state forbids deletion. Unknown or
534// empty states protect by default.
535func (s VolumeClaimState) Protects() bool { return s != VolumeClaimReleased }
536
537// ImageClaim is one durable app ownership claim on a runtime image. The
538// claim is matched by exact identity (the app's pinned reference and/or
539// manifest digest); image labels are never proof on their own.
540type ImageClaim struct {
541	// Reference is the app-pinned image reference (registry/repo:tag).
542	Reference string
543	// Digest is the pinned manifest digest, when known.
544	Digest string
545	// App is the owning app name, AppID its stable internal UUID.
546	App     string
547	AppID   string
548	Service string
549	State   VolumeClaimState
550}
551
552// Valid reports whether the claim is complete enough to act on.
553func (c ImageClaim) Valid() bool {
554	return (c.Reference != "" || c.Digest != "") && c.App != "" && c.State.Valid()
555}
556
557// Identities returns every identity the claim may be matched against,
558// including the registry tag key of a tagged reference.
559func (c ImageClaim) Identities() []string {
560	var out []string
561	if c.Reference != "" {
562		out = append(out, c.Reference)
563		if tagRef, ok := ParseRegistryTagRef(c.Reference); ok {
564			out = append(out, tagRef.Key())
565		}
566	}
567	if c.Digest != "" {
568		out = append(out, c.Digest)
569	}
570	return out
571}
572
573// VolumeClaim is one durable app ownership claim on a runtime volume.
574type VolumeClaim struct {
575	// Name is the exact runtime volume name.
576	Name string
577	// App is the owning app name, AppID its stable internal UUID. They
578	// are informational for matching and must never be inferred from
579	// the volume name.
580	App     string
581	AppID   string
582	Service string
583	State   VolumeClaimState
584}
585
586// Valid reports whether the claim is complete enough to act on.
587func (c VolumeClaim) Valid() bool {
588	return c.Name != "" && c.App != "" && c.State.Valid()
589}
590
591// MatchesLabels reports whether the durable claim agrees with the
592// runtime labels of its volume. Both sides must be present: a claim is
593// never confirmed by absent labels, and labels never override a
594// contradictory or missing durable record.
595func (c VolumeClaim) MatchesLabels(labels map[string]string) bool {
596	if len(labels) == 0 || !c.Valid() {
597		return false
598	}
599	if labels[LabelManaged] != "true" {
600		return false
601	}
602	claimed := labels[LabelApp]
603	if claimed == "" {
604		return false
605	}
606	if claimed != c.App && (c.AppID == "" || claimed != c.AppID) {
607		return false
608	}
609	// A stamped incarnation UUID must agree with the durable record. An
610	// absent stamped ID is not a match either: without it the claim
611	// cannot prove which incarnation owns the volume, so a name-only
612	// match must never authorize deletion.
613	stampedID := labels[LabelAppID]
614	if stampedID == "" || c.AppID == "" || stampedID != c.AppID {
615		return false
616	}
617	if service := labels[LabelAppService]; service != "" && c.Service != "" && service != c.Service {
618		return false
619	}
620	return true
621}
622
623// InventorySource identifies one fact source feeding prune planning.
624type InventorySource string
625
626const (
627	// InventorySourceAppState is desired/active/intent state.
628	InventorySourceAppState InventorySource = "app-state"
629	// InventorySourceOwnership is durable ownership records.
630	InventorySourceOwnership InventorySource = "ownership"
631	// InventorySourceOperation is the operation journal.
632	InventorySourceOperation InventorySource = "operation"
633	// InventorySourceRegistry is registry repositories and tags.
634	InventorySourceRegistry InventorySource = "registry"
635	// InventorySourceManifests is manifest content and closure.
636	InventorySourceManifests InventorySource = "manifests"
637	// InventorySourceRuntimeContainers is runtime container usage.
638	InventorySourceRuntimeContainers InventorySource = "runtime-containers"
639	// InventorySourceRuntimeImages is runtime image inventory.
640	InventorySourceRuntimeImages InventorySource = "runtime-images"
641	// InventorySourceRuntimeVolumes is runtime volume inventory.
642	InventorySourceRuntimeVolumes InventorySource = "runtime-volumes"
643)
644
645// InventoryGap records one fact that could not be established. A gap
646// never blocks an operation by itself: it makes the dependent
647// candidates unknown.
648type InventoryGap struct {
649	Source InventorySource
650	Reason PruneReason
651	Detail string
652}
653
654// Valid reports whether the gap carries a source and an unknown reason.
655func (g InventoryGap) Valid() bool {
656	return g.Source != "" && g.Reason.Valid() && g.Reason.Category() == PruneVerdictUnknown
657}
658
659// PruneProtectionSnapshot is one coherent read of every durable fact
660// that protects prune candidates. Build it in a single read transaction
661// so no candidate is judged against facts from a different moment.
662// The zero value is a complete snapshot that protects nothing.
663type PruneProtectionSnapshot struct {
664	// Roots is every protection root. Refs are canonical identities.
665	Roots []ProtectionRoot
666	// VolumeClaims is every durable app volume claim, including
667	// released ones.
668	VolumeClaims []VolumeClaim
669	// ImageClaims is every durable app image claim, including released
670	// ones. Runtime image prune requires a matching released claim;
671	// image labels alone never authorize deletion.
672	ImageClaims []ImageClaim
673	// Gaps records every fact that could not be read completely.
674	Gaps []InventoryGap
675}
676
677// Complete reports whether the snapshot was read without gaps.
678func (s *PruneProtectionSnapshot) Complete() bool {
679	if s == nil {
680		return false
681	}
682	for _, gap := range s.Gaps {
683		if gap.Valid() {
684			return false
685		}
686	}
687	return true
688}
689
690// Protects reports whether any root of the given kind claims ref.
691// Canonical identity strings are matched exactly: a non-canonical or
692// empty ref never matches, so an unparseable candidate stays unknown
693// rather than silently eligible.
694func (s *PruneProtectionSnapshot) Protects(kind ProtectionRootKind, ref string) bool {
695	if s == nil || ref == "" {
696		return false
697	}
698	for _, root := range s.Roots {
699		if root.Kind == kind && root.Ref == ref {
700			return true
701		}
702	}
703	return false
704}
705
706// ProtectsDigest reports whether any root of any kind claims the digest.
707// Digest identities are shared across registry and runtime namespaces,
708// so protection is namespace-independent.
709func (s *PruneProtectionSnapshot) ProtectsDigest(digest string) bool {
710	if s == nil || !ValidOCIDigest(digest) {
711		return false
712	}
713	for _, root := range s.Roots {
714		if root.Ref == digest || strings.HasSuffix(root.Ref, "@"+digest) {
715			return true
716		}
717	}
718	return false
719}
720
721// ProtectsRuntimeImage reports whether any root claims the image ID or
722// any of its repo digests.
723func (s *PruneProtectionSnapshot) ProtectsRuntimeImage(imageID string, repoDigests []string) bool {
724	if s == nil {
725		return false
726	}
727	if imageID != "" && s.Protects(ProtectionContainerUse, imageID) {
728		return true
729	}
730	if s.ProtectsAnyRef(imageID) {
731		return true
732	}
733	for _, digest := range repoDigests {
734		if s.ProtectsAnyRef(digest) {
735			return true
736		}
737	}
738	return false
739}
740
741// ProtectsAnyRef reports whether any root of any kind claims ref.
742func (s *PruneProtectionSnapshot) ProtectsAnyRef(ref string) bool {
743	if s == nil || ref == "" {
744		return false
745	}
746	for _, root := range s.Roots {
747		if root.Ref == ref || strings.HasSuffix(root.Ref, "@"+ref) {
748			return true
749		}
750	}
751	return false
752}
753
754// ImageClaimsFor returns every claim whose identities include any of the
755// given identities, in record order.
756func (s *PruneProtectionSnapshot) ImageClaimsFor(identities ...string) []ImageClaim {
757	if s == nil || len(identities) == 0 {
758		return nil
759	}
760	wanted := make(map[string]struct{}, len(identities))
761	for _, identity := range identities {
762		if identity != "" {
763			wanted[identity] = struct{}{}
764		}
765	}
766	var out []ImageClaim
767	for _, claim := range s.ImageClaims {
768		for _, identity := range claim.Identities() {
769			if _, ok := wanted[identity]; ok {
770				out = append(out, claim)
771				break
772			}
773		}
774	}
775	return out
776}
777
778// ImageClaimFor returns the last recorded claim matching any identity.
779func (s *PruneProtectionSnapshot) ImageClaimFor(identities ...string) (ImageClaim, bool) {
780	claims := s.ImageClaimsFor(identities...)
781	if len(claims) == 0 {
782		return ImageClaim{}, false
783	}
784	return claims[len(claims)-1], true
785}
786
787// ImageProtects reports whether any matching claim forbids deletion.
788func (s *PruneProtectionSnapshot) ImageProtects(identities ...string) bool {
789	for _, claim := range s.ImageClaimsFor(identities...) {
790		if claim.State.Protects() {
791			return true
792		}
793	}
794	return false
795}
796
797// VolumeClaimFor returns the last recorded claim for name.
798func (s *PruneProtectionSnapshot) VolumeClaimFor(name string) (VolumeClaim, bool) {
799	if s == nil || name == "" {
800		return VolumeClaim{}, false
801	}
802	for index := len(s.VolumeClaims) - 1; index >= 0; index-- {
803		if s.VolumeClaims[index].Name == name {
804			return s.VolumeClaims[index], true
805		}
806	}
807	return VolumeClaim{}, false
808}
809
810// VolumeClaimsFor returns every claim recorded for name, in record
811// order. A volume may be claimed by more than one app incarnation when
812// a name is reused, and every claim must be considered.
813func (s *PruneProtectionSnapshot) VolumeClaimsFor(name string) []VolumeClaim {
814	if s == nil || name == "" {
815		return nil
816	}
817	var claims []VolumeClaim
818	for _, claim := range s.VolumeClaims {
819		if claim.Name == name {
820			claims = append(claims, claim)
821		}
822	}
823	return claims
824}
825
826// VolumeProtects reports whether any durable claim on name forbids
827// deletion. Any protecting claim wins: a new incarnation attaching a
828// volume that an older incarnation released must still protect it.
829func (s *PruneProtectionSnapshot) VolumeProtects(name string) bool {
830	for _, claim := range s.VolumeClaimsFor(name) {
831		if claim.State.Protects() {
832			return true
833		}
834	}
835	return false
836}
837
838// ProtectsTagRef reports whether any root claims the tag identity.
839func (s *PruneProtectionSnapshot) ProtectsTagRef(ref RegistryTagRef) bool {
840	if s == nil || !ref.Valid() {
841		return false
842	}
843	return s.ProtectsAnyRef(ref.Key()) || s.ProtectsAnyRef(ref.String())
844}
845
846// RootReasons returns the canonical, deduplicated verdict reasons of
847// every root claiming any of refs. An unmapped root kind contributes
848// nothing: it retains content for consumers that match it directly but
849// cannot justify a verdict reason on its own.
850func (s *PruneProtectionSnapshot) RootReasons(refs ...string) []PruneReason {
851	if s == nil {
852		return nil
853	}
854	set := make(map[string]struct{}, len(refs))
855	for _, ref := range refs {
856		if ref != "" {
857			set[ref] = struct{}{}
858		}
859	}
860	if len(set) == 0 {
861		return nil
862	}
863	var reasons []PruneReason
864	for _, root := range s.Roots {
865		if _, ok := set[root.Ref]; !ok {
866			continue
867		}
868		reason, mapped := protectionRootReason[root.Kind]
869		if !mapped {
870			continue
871		}
872		reasons = append(reasons, reason)
873	}
874	return CanonicalReasons(reasons...)
875}
876
877// HasGapFor reports whether any recorded gap came from source. A nil
878// snapshot reports a gap: it proves nothing.
879func (s *PruneProtectionSnapshot) HasGapFor(source InventorySource) bool {
880	if s == nil {
881		return true
882	}
883	for _, gap := range s.Gaps {
884		if gap.Source == source && gap.Valid() {
885			return true
886		}
887	}
888	return false
889}
890
891// RefsOfKind returns every distinct root identity of one kind, in
892// first-seen order.
893func (s *PruneProtectionSnapshot) RefsOfKind(kind ProtectionRootKind) []string {
894	if s == nil {
895		return nil
896	}
897	seen := make(map[string]struct{})
898	var refs []string
899	for _, root := range s.Roots {
900		if root.Kind != kind || root.Ref == "" {
901			continue
902		}
903		if _, dup := seen[root.Ref]; dup {
904			continue
905		}
906		seen[root.Ref] = struct{}{}
907		refs = append(refs, root.Ref)
908	}
909	return refs
910}
911
912// WithRoots returns a copy of the snapshot with extra roots appended.
913// It is how one planning stage layers facts it derived itself (for
914// example the OCI closure of already-protected manifests) onto the
915// durable snapshot without mutating it.
916func (s *PruneProtectionSnapshot) WithRoots(roots ...ProtectionRoot) *PruneProtectionSnapshot {
917	if len(roots) == 0 {
918		return s
919	}
920	clone := &PruneProtectionSnapshot{Roots: make([]ProtectionRoot, 0, len(roots))}
921	if s != nil {
922		clone.Roots = make([]ProtectionRoot, 0, len(s.Roots)+len(roots))
923		clone.Roots = append(clone.Roots, s.Roots...)
924		clone.VolumeClaims = append([]VolumeClaim(nil), s.VolumeClaims...)
925		clone.ImageClaims = append([]ImageClaim(nil), s.ImageClaims...)
926		clone.Gaps = append([]InventoryGap(nil), s.Gaps...)
927	}
928	for _, root := range roots {
929		if root.Valid() {
930			clone.Roots = append(clone.Roots, root)
931		}
932	}
933	return clone
934}
935
936// ImageRefRoots returns the protection roots that retain one image
937// reference. A tag reference yields a tag-keyed root; a
938// digest-qualified reference yields both the tag root (when it has a
939// tag) and a digest root, so a candidate matching either identity stays
940// protected. An unparseable reference yields no root: it protects
941// nothing here and is left to the caller to fail closed on.
942func ImageRefRoots(kind ProtectionRootKind, imageRef, owner string) []ProtectionRoot {
943	ref := strings.TrimSpace(imageRef)
944	if ref == "" {
945		return nil
946	}
947
948	var roots []ProtectionRoot
949	name, digest := splitImageDigest(ref)
950	if digest != "" {
951		roots = append(roots, ProtectionRoot{Kind: kind, Ref: digest, Repository: ImageRefRepository(name), Owner: owner})
952	}
953	if name != "" {
954		if repository, tag, ok := splitImageTag(name); ok {
955			tagRef := RegistryTagRef{Repository: repository, Tag: tag}
956			if tagRef.Valid() {
957				roots = append(roots, ProtectionRoot{Kind: kind, Ref: tagRef.Key(), Owner: owner})
958			}
959		} else if ValidOCIDigest(name) {
960			roots = append(roots, ProtectionRoot{Kind: kind, Ref: name, Owner: owner})
961		}
962	}
963	return roots
964}
965
966// ImageRefRepository returns the registry-relative repository of an image
967// reference: registry host, tag, and digest are stripped. It reports ""
968// when no repository can be derived.
969func ImageRefRepository(ref string) string {
970	name, _ := splitImageDigest(ref)
971	name = strings.TrimSpace(name)
972	if name == "" {
973		return ""
974	}
975	if repository, _, ok := splitImageTag(name); ok {
976		name = repository
977	}
978	// Strip a registry host following Docker's rule: the first path
979	// component is a registry only when it contains a dot or colon, or
980	// is localhost.
981	if first, rest, ok := strings.Cut(name, "/"); ok {
982		if strings.ContainsAny(first, ".:") || strings.EqualFold(first, "localhost") {
983			name = rest
984		}
985	}
986	return name
987}
988
989// splitImageDigest splits "name@digest" into its parts. A reference
990// without an @ keeps its whole value as the name.
991func splitImageDigest(ref string) (name, digest string) {
992	at := strings.Index(ref, "@")
993	if at < 0 {
994		return ref, ""
995	}
996	digest = ref[at+1:]
997	if !ValidOCIDigest(digest) {
998		return ref, ""
999	}
1000	return ref[:at], digest
1001}
1002
1003// splitImageTag splits "registry/repo:tag" into repository and tag. A
1004// reference without a tag suffix reports ok=false.
1005func splitImageTag(ref string) (repository, tag string, ok bool) {
1006	colon := strings.LastIndex(ref, ":")
1007	slash := strings.LastIndex(ref, "/")
1008	if colon <= slash || colon >= len(ref)-1 {
1009		return "", "", false
1010	}
1011	return ref[:colon], ref[colon+1:], true
1012}
1013
1014// ParseRegistryTagRef parses a runtime repository tag such as
1015// "registry.example/shop:1" into a registry tag identity. A reference
1016// carrying no usable tag reports ok=false.
1017func ParseRegistryTagRef(repoTag string) (RegistryTagRef, bool) {
1018	repo, tag, ok := splitImageTag(strings.TrimSpace(repoTag))
1019	if !ok {
1020		return RegistryTagRef{}, false
1021	}
1022	ref := RegistryTagRef{Repository: repo, Tag: tag}
1023	if !ref.Valid() {
1024		return RegistryTagRef{}, false
1025	}
1026	return ref, true
1027}
1028
1029// RegistryTagVerdict is the decision for one registry tag.
1030type RegistryTagVerdict struct {
1031	Ref RegistryTagRef
1032	// Digest is the manifest digest the tag resolved to, when known.
1033	Digest  string
1034	Verdict PruneVerdict
1035	Reasons []PruneReason
1036}
1037
1038// OCIBlobVerdict is the decision for digest-addressed registry content.
1039type OCIBlobVerdict struct {
1040	Ref     OCIRef
1041	Verdict PruneVerdict
1042	Reasons []PruneReason
1043}
1044
1045// RuntimeImageVerdict is the decision for one runtime image.
1046type RuntimeImageVerdict struct {
1047	Ref     RuntimeImageRef
1048	Verdict PruneVerdict
1049	Reasons []PruneReason
1050}
1051
1052// VolumeVerdict is the decision for one runtime volume.
1053type VolumeVerdict struct {
1054	Ref     RuntimeVolumeRef
1055	Verdict PruneVerdict
1056	Reasons []PruneReason
1057}
1058
1059// PrunePlan is the complete, validated decision set of one prune run.
1060// Execution deletes exactly the eligible entries: no stage may delete
1061// before the whole plan is computed and validated.
1062type PrunePlan struct {
1063	Tags    []RegistryTagVerdict
1064	Blobs   []OCIBlobVerdict
1065	Images  []RuntimeImageVerdict
1066	Volumes []VolumeVerdict
1067	Gaps    []InventoryGap
1068}
1069
1070// EligibleTags returns the registry tags safe to delete, in plan order.
1071func (p *PrunePlan) EligibleTags() []RegistryTagRef {
1072	out := make([]RegistryTagRef, 0, len(p.Tags))
1073	for _, verdict := range p.Tags {
1074		if verdict.Verdict == PruneVerdictEligible {
1075			out = append(out, verdict.Ref)
1076		}
1077	}
1078	return out
1079}
1080
1081// EligibleBlobs returns the OCI content safe to delete, in plan order.
1082func (p *PrunePlan) EligibleBlobs() []OCIRef {
1083	out := make([]OCIRef, 0, len(p.Blobs))
1084	for _, verdict := range p.Blobs {
1085		if verdict.Verdict == PruneVerdictEligible {
1086			out = append(out, verdict.Ref)
1087		}
1088	}
1089	return out
1090}
1091
1092// EligibleImages returns the runtime images safe to delete, in plan order.
1093func (p *PrunePlan) EligibleImages() []RuntimeImageRef {
1094	out := make([]RuntimeImageRef, 0, len(p.Images))
1095	for _, verdict := range p.Images {
1096		if verdict.Verdict == PruneVerdictEligible {
1097			out = append(out, verdict.Ref)
1098		}
1099	}
1100	return out
1101}
1102
1103// EligibleVolumes returns the runtime volumes safe to delete, in plan order.
1104func (p *PrunePlan) EligibleVolumes() []RuntimeVolumeRef {
1105	out := make([]RuntimeVolumeRef, 0, len(p.Volumes))
1106	for _, verdict := range p.Volumes {
1107		if verdict.Verdict == PruneVerdictEligible {
1108			out = append(out, verdict.Ref)
1109		}
1110	}
1111	return out
1112}
1113
1114// CountByVerdict counts entries of one verdict across every resource kind.
1115func (p *PrunePlan) CountByVerdict(verdict PruneVerdict) int {
1116	count := 0
1117	for _, item := range p.Tags {
1118		if item.Verdict == verdict {
1119			count++
1120		}
1121	}
1122	for _, item := range p.Blobs {
1123		if item.Verdict == verdict {
1124			count++
1125		}
1126	}
1127	for _, item := range p.Images {
1128		if item.Verdict == verdict {
1129			count++
1130		}
1131	}
1132	for _, item := range p.Volumes {
1133		if item.Verdict == verdict {
1134			count++
1135		}
1136	}
1137	return count
1138}
1139
1140// Validate returns the first structural inconsistency of the
1141// plan. It never re-derives safety: it proves the plan is well formed
1142// enough to execute, so a malformed plan can never delete anything.
1143func (p *PrunePlan) Validate() error {
1144	for _, item := range p.Tags {
1145		if !item.Ref.Valid() {
1146			return fmt.Errorf("registry tag verdict has invalid identity %q", item.Ref.String())
1147		}
1148		if err := validateVerdictReasons(item.Verdict, item.Reasons); err != nil {
1149			return fmt.Errorf("registry tag %q: %w", item.Ref.String(), err)
1150		}
1151	}
1152	for _, item := range p.Blobs {
1153		if err := validateOCIVerdict(item.Ref, item.Verdict, item.Reasons); err != nil {
1154			return err
1155		}
1156	}
1157	for _, item := range p.Images {
1158		if err := validateRuntimeImageVerdict(item.Ref, item.Verdict, item.Reasons); err != nil {
1159			return err
1160		}
1161	}
1162	for _, item := range p.Volumes {
1163		if err := validateVolumeVerdict(item.Ref, item.Verdict, item.Reasons); err != nil {
1164			return err
1165		}
1166	}
1167	for _, gap := range p.Gaps {
1168		if !gap.Valid() {
1169			return fmt.Errorf("inventory gap has invalid source or reason %q/%q", gap.Source, gap.Reason)
1170		}
1171	}
1172	return nil
1173}
1174
1175// validateOCIVerdict proves one blob verdict is well formed. An eligible
1176// entry must name a valid digest, so an executable plan can never carry
1177// a deletion target the adapter would reject.
1178func validateOCIVerdict(ref OCIRef, verdict PruneVerdict, reasons []PruneReason) error {
1179	if verdict == PruneVerdictEligible && !ref.Valid() {
1180		return fmt.Errorf("OCI blob verdict has invalid identity %q", ref.String())
1181	}
1182	if err := validateVerdictReasons(verdict, reasons); err != nil {
1183		return fmt.Errorf("OCI blob %q: %w", ref.String(), err)
1184	}
1185	return nil
1186}
1187
1188// validateRuntimeImageVerdict proves one runtime image verdict is well formed.
1189func validateRuntimeImageVerdict(ref RuntimeImageRef, verdict PruneVerdict, reasons []PruneReason) error {
1190	if verdict == PruneVerdictEligible && !ref.Valid() {
1191		return fmt.Errorf("runtime image verdict has invalid identity %q", ref.String())
1192	}
1193	if err := validateVerdictReasons(verdict, reasons); err != nil {
1194		return fmt.Errorf("runtime image %q: %w", ref.String(), err)
1195	}
1196	return nil
1197}
1198
1199// validateVolumeVerdict proves one volume verdict is well formed.
1200func validateVolumeVerdict(ref RuntimeVolumeRef, verdict PruneVerdict, reasons []PruneReason) error {
1201	if verdict == PruneVerdictEligible && !ref.Valid() {
1202		return fmt.Errorf("volume verdict has invalid identity %q", ref.String())
1203	}
1204	if err := validateVerdictReasons(verdict, reasons); err != nil {
1205		return fmt.Errorf("volume %q: %w", ref.String(), err)
1206	}
1207	return nil
1208}
1209
1210// Candidates returns every planned candidate with its verdict, in a
1211// deterministic order: tags, blobs, runtime images, then volumes.
1212func (p *PrunePlan) Candidates() []PruneCandidateReport {
1213	total := len(p.Tags) + len(p.Blobs) + len(p.Images) + len(p.Volumes)
1214	out := make([]PruneCandidateReport, 0, total)
1215	for _, item := range p.Tags {
1216		out = append(out, PruneCandidateReport{
1217			Kind: PruneResourceRegistryTag, Ref: item.Ref.String(), Verdict: item.Verdict, Reasons: item.Reasons,
1218		})
1219	}
1220	for _, item := range p.Blobs {
1221		out = append(out, PruneCandidateReport{
1222			Kind: PruneResourceOCIBlob, Ref: item.Ref.String(), Verdict: item.Verdict, Reasons: item.Reasons,
1223		})
1224	}
1225	for _, item := range p.Images {
1226		out = append(out, PruneCandidateReport{
1227			Kind: PruneResourceRuntimeImage, Ref: item.Ref.String(), Verdict: item.Verdict, Reasons: item.Reasons,
1228		})
1229	}
1230	for _, item := range p.Volumes {
1231		out = append(out, PruneCandidateReport{
1232			Kind: PruneResourceVolume, Ref: item.Ref.String(), Verdict: item.Verdict, Reasons: item.Reasons,
1233		})
1234	}
1235	return out
1236}
1237
1238// PruneCandidateReport is one candidate's verdict in a prune report.
1239type PruneCandidateReport struct {
1240	Kind    PruneResourceKind
1241	Ref     string
1242	Verdict PruneVerdict
1243	Reasons []PruneReason
1244}
1245
1246// PruneFailure records one exact deletion that failed. A failure never
1247// aborts the run and never aborts the other candidates.
1248type PruneFailure struct {
1249	Kind PruneResourceKind
1250	Ref  string
1251	Err  string
1252}
1253
1254// PruneReport is the operator-facing result of one prune run. Dry-run
1255// and execution share this shape; Applied distinguishes them.
1256type PruneReport struct {
1257	// Applied is false for a dry run, where nothing was deleted.
1258	Applied bool
1259	// Candidates lists every planned candidate with its verdict, so an
1260	// operator can see why each resource was kept or skipped.
1261	Candidates []PruneCandidateReport
1262	// Gaps lists every inventory that could not be read in full.
1263	Gaps []InventoryGap
1264	// Deleted lists exactly the identities that were removed.
1265	Deleted []PruneCandidateReport
1266	// Failures lists exact deletions that failed without aborting the run.
1267	Failures []PruneFailure
1268	// ReclaimedBytes and ReclaimedKnown are set only when the adapter
1269	// can attribute exact bytes to the deletions it performed.
1270	ReclaimedBytes int64
1271	ReclaimedKnown bool
1272}
1273
1274// CountByVerdict counts every planned candidate with one verdict.
1275func (r *PruneReport) CountByVerdict(verdict PruneVerdict) int {
1276	count := 0
1277	for _, candidate := range r.Candidates {
1278		if candidate.Verdict == verdict {
1279			count++
1280		}
1281	}
1282	return count
1283}
1284
1285// CandidatesOfKind returns the planned candidates of one resource kind
1286// with one verdict, in report order.
1287func (r *PruneReport) CandidatesOfKind(kind PruneResourceKind, verdict PruneVerdict) []PruneCandidateReport {
1288	var out []PruneCandidateReport
1289	for _, candidate := range r.Candidates {
1290		if candidate.Kind == kind && candidate.Verdict == verdict {
1291			out = append(out, candidate)
1292		}
1293	}
1294	return out
1295}
1296
1297// NewPruneReport builds the shared report shape from a validated plan.
1298// applied reports whether the caller went on to execute it.
1299func NewPruneReport(plan *PrunePlan, applied bool) *PruneReport {
1300	report := &PruneReport{Applied: applied}
1301	if plan == nil {
1302		return report
1303	}
1304	report.Candidates = plan.Candidates()
1305	report.Gaps = plan.Gaps
1306	return report
1307}
1308
1309// validateVerdictReasons proves a verdict is well formed: it is defined,
1310// it carries at least one reason, and every reason justifies it.
1311func validateVerdictReasons(verdict PruneVerdict, reasons []PruneReason) error {
1312	if !verdict.Valid() {
1313		return fmt.Errorf("undefined verdict %q", verdict)
1314	}
1315	if len(reasons) == 0 {
1316		return fmt.Errorf("verdict %q carries no reason", verdict)
1317	}
1318	for _, reason := range reasons {
1319		if !reason.Valid() {
1320			return fmt.Errorf("undefined reason %q", reason)
1321		}
1322		if reason.Category() != verdict {
1323			return fmt.Errorf("reason %q does not justify verdict %q", reason, verdict)
1324		}
1325	}
1326	return nil
1327}
1328
1329// RuntimeImage is one runtime image as the runtime reports it.
1330type RuntimeImage struct {
1331	// ID is the exact runtime image identifier.
1332	ID string
1333	// RepoTags are the repository tags pointing at this image.
1334	RepoTags []string
1335	// RepoDigests are the manifest digests the runtime knows for it.
1336	RepoDigests []string
1337	Labels      map[string]string
1338	// Size is reported in bytes.
1339	Size    int64
1340	Created time.Time
1341}
1342
1343// RuntimeContainerUse is one container's image usage.
1344type RuntimeContainerUse struct {
1345	ContainerID string
1346	// ImageID is the resolved image identity; empty when unknown.
1347	ImageID string
1348	// ImageRef is the reference the container was created from.
1349	ImageRef string
1350	Running  bool
1351	// ImageUnknown marks a container whose image could not be resolved.
1352	// It protects every image candidate whose safety depends on it.
1353	ImageUnknown bool
1354}
1355
1356// RuntimeInventory is one coherent read of runtime resources. It is
1357// what the planner consumes; adapters build it without inspecting
1358// each other.
1359type RuntimeInventory struct {
1360	Images     []RuntimeImage
1361	Containers []RuntimeContainerUse
1362	// Volumes are the runtime's named volumes with labels and usage.
1363	Volumes []*VolumeInfo
1364	Gaps    []InventoryGap
1365}
1366
1367// Complete reports whether the inventory was read without gaps.
1368func (i *RuntimeInventory) Complete() bool {
1369	if i == nil {
1370		return false
1371	}
1372	for _, gap := range i.Gaps {
1373		if gap.Valid() {
1374			return false
1375		}
1376	}
1377	return true
1378}

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