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}