Skip to content

Commit 3e294e6

Browse files
authored
feat(cli): Add prune planner. (#3411)
## Summary Add a new prune planner for namespaced policy migration cleanup. This branch introduces prune planning for: - actions - subject condition sets - subject mappings - registered resources - obligation triggers It also exposes resolved migration state from the existing planner so prune logic can reuse it where appropriate. ## Details - Added new prune plan models and structured prune status/reason output. - Added a new PrunePlanner with scope-specific behavior: - actions / subject condition sets use direct prune planning based on: - current legacy usage - canonical migrated target lookup - subject mappings / registered resources / obligation triggers use resolved migration state from the planner - Added RR-specific prune verification using both: - resolved source view - authoritative full source reloaded from the global namespace - Added comprehensive prune planner test coverage across statuses: - delete - blocked - unresolved ## Supporting refactors - Split Planner.Plan() so resolved targets can be reused via resolve() - Moved shared helper logic into plan_utils.go - Deduplicated isStandardAction - Cleaned up prune naming around: - PruneStatusReason - PruneStatusReasonType - TargetRef <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added prune planning with detailed status classification (Delete/Blocked/Unresolved), reasons, and options for interactive review and page sizing to support policy migration workflows. * **Refactor** * Reorganized migration planning flow to separate resolution and finalization for clearer, more robust planning. * **Tests** * Added a comprehensive test suite covering prune classification, blocking/unresolved logic, and edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3d2130d commit 3e294e6

7 files changed

Lines changed: 2093 additions & 26 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package namespacedpolicy
2+
3+
import (
4+
"strings"
5+
6+
"github.com/opentdf/platform/protocol/go/policy"
7+
)
8+
9+
func objectIDSet[T interface{ GetId() string }](items []T) map[string]struct{} {
10+
ids := make(map[string]struct{}, len(items))
11+
for _, item := range items {
12+
if id := item.GetId(); id != "" {
13+
ids[id] = struct{}{}
14+
}
15+
}
16+
return ids
17+
}
18+
19+
func isStandardAction(action *policy.Action) bool {
20+
if action == nil {
21+
return false
22+
}
23+
if action.GetStandard() != policy.Action_STANDARD_ACTION_UNSPECIFIED {
24+
return true
25+
}
26+
27+
switch strings.ToLower(strings.TrimSpace(action.GetName())) {
28+
case "create", "read", "update", "delete":
29+
return true
30+
default:
31+
return false
32+
}
33+
}

otdfctl/migrations/namespacedpolicy/planner.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ func WithInteractiveReviewer(reviewer InteractiveReviewer) Option {
101101
}
102102

103103
func (p *Planner) Plan(ctx context.Context) (*Plan, error) {
104+
resolved, err := p.resolve(ctx)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
return finalizePlan(resolved)
110+
}
111+
112+
func (p *Planner) resolve(ctx context.Context) (*ResolvedTargets, error) {
104113
retrieved, err := p.retrieve(ctx)
105114
if err != nil {
106115
return nil, err
@@ -132,7 +141,7 @@ func (p *Planner) Plan(ctx context.Context) (*Plan, error) {
132141
}
133142
}
134143

135-
return finalizePlan(resolved)
144+
return resolved, nil
136145
}
137146

138147
// Retrieve the candidate policy constructs for items within scope or dependent
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package namespacedpolicy
2+
3+
import "github.com/opentdf/platform/protocol/go/policy"
4+
5+
type PruneStatus string
6+
7+
const (
8+
PruneStatusDelete PruneStatus = "delete"
9+
PruneStatusBlocked PruneStatus = "blocked"
10+
PruneStatusUnresolved PruneStatus = "unresolved"
11+
)
12+
13+
type PruneStatusReasonType string
14+
15+
const (
16+
PruneStatusReasonTypeMigratedTargetNotFound PruneStatusReasonType = "MigratedTargetNotFound"
17+
PruneStatusReasonTypeNoMatchingLabelsFound PruneStatusReasonType = "NoMatchingLabelsFound"
18+
PruneStatusReasonTypeMismatchedMigrationLabel PruneStatusReasonType = "MismatchedMigrationLabel"
19+
PruneStatusReasonTypeMissingMigrationLabel PruneStatusReasonType = "MissingMigrationLabel"
20+
PruneStatusReasonTypeInUse PruneStatusReasonType = "InUse"
21+
PruneStatusReasonTypeNeedsMigration PruneStatusReasonType = "NeedsMigration"
22+
PruneStatusReasonTypeRegisteredResourceSourceMismatch PruneStatusReasonType = "RegisteredResourceSourceMismatch"
23+
)
24+
25+
type PruneStatusReason struct {
26+
Type PruneStatusReasonType `json:"type"`
27+
Message string `json:"message"`
28+
}
29+
30+
// TargetRef identifies the migrated target object that the planner
31+
// matched to a source object. For objects that resolve to a single migrated
32+
// target, the prune plan uses `TargetRef`. For objects that may still be
33+
// referenced across multiple migrated namespaces, the prune plan uses
34+
// `TargetRefs`.
35+
type TargetRef struct {
36+
ID string `json:"id"`
37+
NamespaceID string `json:"namespace_id,omitempty"`
38+
NamespaceFQN string `json:"namespace_fqn,omitempty"`
39+
}
40+
41+
func (t TargetRef) IsZero() bool {
42+
return len(t.ID) == 0 && len(t.NamespaceID) == 0 && len(t.NamespaceFQN) == 0
43+
}
44+
45+
func (r PruneStatusReason) IsZero() bool {
46+
return len(r.Type) == 0 && len(r.Message) == 0
47+
}
48+
49+
type PrunePlan struct {
50+
Scopes []Scope `json:"scopes"`
51+
Actions []*PruneActionPlan `json:"actions"`
52+
SubjectConditionSets []*PruneSubjectConditionSetPlan `json:"subject_condition_sets"`
53+
SubjectMappings []*PruneSubjectMappingPlan `json:"subject_mappings"`
54+
RegisteredResources []*PruneRegisteredResourcePlan `json:"registered_resources"`
55+
ObligationTriggers []*PruneObligationTriggerPlan `json:"obligation_triggers"`
56+
}
57+
58+
// PruneActionPlan records the source action being considered for deletion and
59+
// any migrated target actions that still reference or replace it.
60+
type PruneActionPlan struct {
61+
Source *policy.Action `json:"source"`
62+
Status PruneStatus `json:"status"`
63+
MigratedTargets []TargetRef `json:"migrated_targets,omitempty"`
64+
Reason PruneStatusReason `json:"reason,omitzero"`
65+
}
66+
67+
// PruneSubjectConditionSetPlan records the source SCS being considered for
68+
// deletion and any migrated target subject condition sets that still reference
69+
// or replace it.
70+
type PruneSubjectConditionSetPlan struct {
71+
Source *policy.SubjectConditionSet `json:"source"`
72+
Status PruneStatus `json:"status"`
73+
MigratedTargets []TargetRef `json:"migrated_targets,omitempty"`
74+
Reason PruneStatusReason `json:"reason,omitzero"`
75+
}
76+
77+
// PruneSubjectMappingPlan records the source subject mapping being considered
78+
// for deletion and the single migrated target subject mapping matched to it by
79+
// migration metadata.
80+
type PruneSubjectMappingPlan struct {
81+
Source *policy.SubjectMapping `json:"source"`
82+
Status PruneStatus `json:"status"`
83+
MigratedTarget TargetRef `json:"migrated_target,omitzero"`
84+
Reason PruneStatusReason `json:"reason,omitzero"`
85+
}
86+
87+
// PruneRegisteredResourcePlan records the resolved RR source being considered
88+
// for deletion and the single migrated target RR matched to it by migration
89+
// metadata.
90+
type PruneRegisteredResourcePlan struct {
91+
// Source is the resolved RR source from planning and may be filtered by interactive review.
92+
Source *policy.RegisteredResource `json:"source"`
93+
// FullSource is the authoritative RR source reloaded from the global namespace for prune verification.
94+
FullSource *policy.RegisteredResource `json:"full_source,omitempty"`
95+
Status PruneStatus `json:"status"`
96+
MigratedTarget TargetRef `json:"migrated_target,omitzero"`
97+
Reason PruneStatusReason `json:"reason,omitzero"`
98+
}
99+
100+
// PruneObligationTriggerPlan records the source obligation trigger being
101+
// considered for deletion and the single migrated target obligation trigger
102+
// matched to it by migration metadata.
103+
type PruneObligationTriggerPlan struct {
104+
Source *policy.ObligationTrigger `json:"source"`
105+
Status PruneStatus `json:"status"`
106+
MigratedTarget TargetRef `json:"migrated_target,omitzero"`
107+
Reason PruneStatusReason `json:"reason,omitzero"`
108+
}

0 commit comments

Comments
 (0)