1package pruneguard
2
3import (
4 "sort"
5
6 "github.com/bnema/gordon/internal/domain"
7)
8
9// VolumePlanInput is the complete input of one volume plan.
10type VolumePlanInput struct {
11 Volumes []*domain.VolumeInfo
12 Snapshot *domain.PruneProtectionSnapshot
13 // Complete is false when the runtime volume inventory could not be
14 // read in full. Candidates that would otherwise be eligible become
15 // unknown.
16 Complete bool
17}
18
19// PlanVolumes computes one verdict per runtime volume.
20//
21// A volume is eligible only when a durable ownership record explicitly
22// records it as released, the runtime labels agree with that record, and
23// no container uses it. Labels alone never make a volume deletable, and
24// a retained record is never reinterpreted as released. A legitimately
25// empty result is a success, not an error.
26//
27// Output is ordered by volume name so callers are deterministic.
28func PlanVolumes(input VolumePlanInput) []domain.VolumeVerdict {
29 ownershipIncomplete := input.Snapshot.HasGapFor(domain.InventorySourceOwnership)
30
31 verdicts := make([]domain.VolumeVerdict, 0, len(input.Volumes))
32 for _, volume := range input.Volumes {
33 verdicts = append(verdicts, planVolume(volume, input, ownershipIncomplete))
34 }
35 sort.SliceStable(verdicts, func(i, j int) bool {
36 return verdicts[i].Ref.Name < verdicts[j].Ref.Name
37 })
38 return verdicts
39}
40
41func planVolume(volume *domain.VolumeInfo, input VolumePlanInput, ownershipIncomplete bool) domain.VolumeVerdict {
42 verdict := domain.VolumeVerdict{}
43 if volume == nil {
44 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownIdentity)
45 return verdict
46 }
47 verdict.Ref = domain.RuntimeVolumeRef{Name: volume.Name}
48
49 if !verdict.Ref.Valid() {
50 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownIdentity)
51 return verdict
52 }
53
54 if volume.InUse {
55 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedContainerUse)
56 return verdict
57 }
58
59 claim, hasClaim := input.Snapshot.VolumeClaimFor(volume.Name)
60
61 // Positive protection first: any attached or retained record wins,
62 // even when the rest of the inventory is incomplete, and a newer
63 // incarnation never un-protects an older claim.
64 if input.Snapshot.VolumeProtects(volume.Name) {
65 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedOwnership)
66 return verdict
67 }
68
69 if !hasClaim {
70 switch ClassifyResource(volume.Labels) {
71 case ProvenanceUnmanaged:
72 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedUnmanaged)
73 case ProvenanceLegacyManaged:
74 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedManagedOnly)
75 case ProvenanceServiceManaged:
76 verdict.Verdict, verdict.Reasons = protected(nil, domain.PruneReasonProtectedServiceManaged)
77 default:
78 // App labels with no durable record: ownership history is
79 // missing, and labels never override that.
80 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownProvenance)
81 }
82 return verdict
83 }
84
85 // Released: the durable record and the runtime labels must agree on
86 // the owning app incarnation before the volume can be deleted.
87 if !claim.MatchesLabels(volume.Labels) {
88 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownProvenance)
89 return verdict
90 }
91
92 if ownershipIncomplete || !input.Complete {
93 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownInventory)
94 return verdict
95 }
96 if !claim.Valid() {
97 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownProvenance)
98 return verdict
99 }
100 // Eligibility needs both halves of the proof: a durable released
101 // record and the incarnation UUID that ties the runtime volume to
102 // it. A record without an incarnation cannot prove which volume it
103 // claims, so it never authorizes deletion.
104 if claim.AppID == "" {
105 verdict.Verdict, verdict.Reasons = unknownResult(domain.PruneReasonUnknownProvenance)
106 return verdict
107 }
108
109 verdict.Verdict, verdict.Reasons = eligibleResult(domain.PruneReasonEligibleReleasedVolume)
110 return verdict
111}