Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions otdfctl/cmd/migrate/prune/namespaced_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func pruneNamespacedPolicy(cmd *cobra.Command, args []string) {
if interactive {
if err := namespacedpolicy.ReviewPrunePlan(cmd.Context(), plan, prompter); err != nil {
if errors.Is(err, namespacedpolicy.ErrInteractiveReviewAborted) {
writeNamespacedPolicyPruneSummary(cmd, plan, false, "aborted")
writeNamespacedPolicyPruneSummary(cmd, plan, false, namespacedpolicy.PruneSummaryResultAborted)
}
cli.ExitWithError("could not review namespaced-policy prune plan", err)
}
Expand All @@ -64,18 +64,18 @@ func pruneNamespacedPolicy(cmd *cobra.Command, args []string) {
executeNamespacedPolicyPruneCommit(cmd, h, plan, interactive, prompter)
}

if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummary(plan, commit) + "\n")); err != nil {
if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummary(plan, commit, namespacedpolicy.PruneSummaryResultSuccess) + "\n")); err != nil {
cli.ExitWithError("could not write namespaced-policy prune summary", err)
}
}

func executeNamespacedPolicyPruneCommit(cmd *cobra.Command, h namespacedpolicy.ExecutorHandler, plan *namespacedpolicy.PrunePlan, interactive bool, prompter namespacedpolicy.InteractivePrompter) {
if interactive {
if err := namespacedpolicy.ConfirmNamespacedPolicyPruneBackup(cmd.Context(), prompter); err != nil {
if errors.Is(err, namespacedpolicy.ErrNamespacedPolicyBackupNotConfirmed) {
writeNamespacedPolicyPruneSummary(cmd, plan, false, "aborted")
if err := reviewNamespacedPolicyPruneInteractiveCommit(cmd, plan, prompter); err != nil {
if namespacedPolicyPruneCommitAborted(err) {
writeNamespacedPolicyPruneSummary(cmd, plan, false, namespacedpolicy.PruneSummaryResultAborted)
}
cli.ExitWithError("could not confirm namespaced-policy prune backup", err)
cli.ExitWithError("could not review namespaced-policy prune commit", err)
}
}

Expand All @@ -85,13 +85,26 @@ func executeNamespacedPolicyPruneCommit(cmd *cobra.Command, h namespacedpolicy.E
}

if err := executor.ExecutePrune(cmd.Context(), plan); err != nil {
writeNamespacedPolicyPruneSummary(cmd, plan, true, "failure")
writeNamespacedPolicyPruneSummary(cmd, plan, true, namespacedpolicy.PruneSummaryResultFailure)
cli.ExitWithError("could not execute namespaced-policy prune commit", err)
}
}

func writeNamespacedPolicyPruneSummary(cmd *cobra.Command, plan *namespacedpolicy.PrunePlan, commit bool, result string) {
if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummaryWithResult(plan, commit, result) + "\n")); err != nil {
func reviewNamespacedPolicyPruneInteractiveCommit(cmd *cobra.Command, plan *namespacedpolicy.PrunePlan, prompter namespacedpolicy.InteractivePrompter) error {
if err := namespacedpolicy.ConfirmNamespacedPolicyPruneBackup(cmd.Context(), prompter); err != nil {
return err
}

return namespacedpolicy.ConfirmPrunePlanDeletes(cmd.Context(), plan, prompter)
}

func namespacedPolicyPruneCommitAborted(err error) bool {
return errors.Is(err, namespacedpolicy.ErrNamespacedPolicyBackupNotConfirmed) ||
errors.Is(err, namespacedpolicy.ErrInteractiveReviewAborted)
}

func writeNamespacedPolicyPruneSummary(cmd *cobra.Command, plan *namespacedpolicy.PrunePlan, executed bool, result namespacedpolicy.PruneSummaryResult) {
if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummary(plan, executed, result) + "\n")); err != nil {
cli.ExitWithError("could not write namespaced-policy prune summary", err)
}
}
103 changes: 103 additions & 0 deletions otdfctl/migrations/namespacedpolicy/prune_commit_confirmation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package namespacedpolicy

import (
"context"
"fmt"
)

const (
confirmPruneDeleteLabel = "Confirm delete"
confirmPruneDeleteDescription = "delete this source object"
abortPruneDeleteLabel = "Abort prune commit"
abortPruneDeleteDescription = "stop before deleting any objects"
)

// ConfirmPrunePlanDeletes prompts for every delete-status prune item before
// commit execution and lets the user confirm, skip, or abort.
func ConfirmPrunePlanDeletes(ctx context.Context, plan *PrunePlan, prompter InteractivePrompter) error {
if plan == nil {
return nil
}
if prompter == nil {
prompter = &HuhPrompter{}
}

if err := confirmDeletePruneItems(ctx, prompter, plan.Actions); err != nil {
return err
}
if err := confirmDeletePruneItems(ctx, prompter, plan.SubjectConditionSets); err != nil {
return err
}
if err := confirmDeletePruneItems(ctx, prompter, plan.SubjectMappings); err != nil {
return err
}
if err := confirmDeletePruneItems(ctx, prompter, plan.RegisteredResources); err != nil {
return err
}

return confirmDeletePruneItems(ctx, prompter, plan.ObligationTriggers)
}

func confirmDeletePruneItems[T pruneReviewItem](
ctx context.Context,
prompter InteractivePrompter,
items []T,
) error {
for _, item := range items {
if !confirmablePruneDeleteItem(item) {
continue
}
prompt := pruneDeleteConfirmationPrompt(item)
if err := applyPruneDeleteConfirmationDecision(ctx, prompter, prompt, func() { markPruneItemSkipped(item) }); err != nil {
return err
}
}

return nil
}

func confirmablePruneDeleteItem(item pruneReviewItem) bool {
return item.hasSource() && item.status() == PruneStatusDelete
}

func markPruneItemSkipped(item pruneReviewItem) {
item.setStatus(PruneStatusSkipped)
item.setReason(newPruneReason(PruneStatusReasonTypeSkippedByUser, pruneStatusReasonMessageSkippedByUser))
}

func applyPruneDeleteConfirmationDecision(ctx context.Context, prompter InteractivePrompter, prompt SelectPrompt, markSkipped func()) error {
choice, err := prompter.Select(ctx, prompt)
if err != nil {
return err
}

switch choice {
case namespacedPolicyCommitConfirm:
return nil
case namespacedPolicyCommitSkip:
markSkipped()
return nil
case namespacedPolicyCommitAbort:
return ErrInteractiveReviewAborted
default:
return fmt.Errorf("invalid prune commit selection %q", choice)
}
}

func pruneDeleteConfirmationPrompt(item pruneReviewItem) SelectPrompt {
summary := item.reviewSummary()

return SelectPrompt{
Title: fmt.Sprintf("Delete %s %q?", summary.Kind, summary.Label),
Description: summary.Description,
Options: pruneDeleteConfirmationOptions(),
}
}

func pruneDeleteConfirmationOptions() []PromptOption {
return []PromptOption{
{Label: confirmPruneDeleteLabel, Value: namespacedPolicyCommitConfirm, Description: confirmPruneDeleteDescription},
{Label: skipObjectLabel, Value: namespacedPolicyCommitSkip, Description: skipObjectDescription},
{Label: abortPruneDeleteLabel, Value: namespacedPolicyCommitAbort, Description: abortPruneDeleteDescription},
}
}
Comment thread
c-r33d marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package namespacedpolicy

import (
"errors"
"testing"

"github.com/opentdf/platform/protocol/go/policy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfirmPrunePlanDeletesConfirmsDeleteItems(t *testing.T) {
t.Parallel()

plan := &PrunePlan{
Actions: []*PruneActionPlan{
{
Source: &policy.Action{Id: "action-1", Name: "archive"},
Status: PruneStatusDelete,
MigratedTargets: []TargetRef{
{ID: "target-action-1", NamespaceFQN: "https://example.com"},
},
},
},
}
prompter := &queuedSelectPrompter{
selectValues: []string{namespacedPolicyCommitConfirm},
}

err := ConfirmPrunePlanDeletes(t.Context(), plan, prompter)
require.NoError(t, err)

require.Equal(t, 1, prompter.selectCalls)
assert.Equal(t, PruneStatusDelete, plan.Actions[0].Status)
assert.True(t, plan.Actions[0].Reason.IsZero())
}

func TestConfirmPrunePlanDeletesSkipsDeleteItems(t *testing.T) {
t.Parallel()

plan := &PrunePlan{
Actions: []*PruneActionPlan{
{
Source: &policy.Action{Id: "action-1", Name: "archive"},
Status: PruneStatusDelete,
MigratedTargets: []TargetRef{{ID: "target-action-1", NamespaceFQN: "https://example.com"}},
},
{
Source: &policy.Action{Id: "action-2", Name: "export"},
Status: PruneStatusDelete,
MigratedTargets: []TargetRef{{ID: "target-action-2", NamespaceFQN: "https://example.com"}},
},
},
}
prompter := &queuedSelectPrompter{
selectValues: []string{
namespacedPolicyCommitSkip,
namespacedPolicyCommitConfirm,
},
}

err := ConfirmPrunePlanDeletes(t.Context(), plan, prompter)
require.NoError(t, err)

require.Equal(t, 2, prompter.selectCalls)
assert.Equal(t, PruneStatusSkipped, plan.Actions[0].Status)
assert.Equal(t, PruneStatusReasonTypeSkippedByUser, plan.Actions[0].Reason.Type)
assert.Equal(t, pruneStatusReasonMessageSkippedByUser, plan.Actions[0].Reason.Message)
assert.Equal(t, PruneStatusDelete, plan.Actions[1].Status)
}

func TestConfirmPrunePlanDeletesAbortStopsWithoutMutatingCurrentItem(t *testing.T) {
t.Parallel()

plan := &PrunePlan{
Actions: []*PruneActionPlan{
{
Source: &policy.Action{Id: "action-1", Name: "archive"},
Status: PruneStatusDelete,
},
{
Source: &policy.Action{Id: "action-2", Name: "export"},
Status: PruneStatusDelete,
},
},
}
prompter := &queuedSelectPrompter{
selectValues: []string{namespacedPolicyCommitAbort},
}

err := ConfirmPrunePlanDeletes(t.Context(), plan, prompter)
require.ErrorIs(t, err, ErrInteractiveReviewAborted)

require.Equal(t, 1, prompter.selectCalls)
assert.Equal(t, PruneStatusDelete, plan.Actions[0].Status)
assert.Equal(t, PruneStatusDelete, plan.Actions[1].Status)
}

func TestConfirmPrunePlanDeletesSkipsNilSourceAndNonDeleteItems(t *testing.T) {
t.Parallel()

plan := &PrunePlan{
Actions: []*PruneActionPlan{
nil,
{Status: PruneStatusDelete},
{
Source: &policy.Action{Id: "action-blocked", Name: "archive"},
Status: PruneStatusBlocked,
},
{
Source: &policy.Action{Id: "action-unresolved", Name: "export"},
Status: PruneStatusUnresolved,
},
{
Source: &policy.Action{Id: "action-skipped", Name: "share"},
Status: PruneStatusSkipped,
},
},
}
prompter := &queuedSelectPrompter{
selectValues: []string{namespacedPolicyCommitSkip},
}

err := ConfirmPrunePlanDeletes(t.Context(), plan, prompter)
require.NoError(t, err)

assert.Equal(t, 0, prompter.selectCalls)
assert.Equal(t, PruneStatusDelete, plan.Actions[1].Status)
assert.Equal(t, PruneStatusBlocked, plan.Actions[2].Status)
assert.Equal(t, PruneStatusUnresolved, plan.Actions[3].Status)
assert.Equal(t, PruneStatusSkipped, plan.Actions[4].Status)
}

func TestConfirmPrunePlanDeletesPromptsAllConstructs(t *testing.T) {
t.Parallel()

plan := &PrunePlan{
Actions: []*PruneActionPlan{
{Source: &policy.Action{Id: "action-1", Name: "archive"}, Status: PruneStatusDelete},
},
SubjectConditionSets: []*PruneSubjectConditionSetPlan{
{Source: &policy.SubjectConditionSet{Id: "scs-1"}, Status: PruneStatusDelete},
},
SubjectMappings: []*PruneSubjectMappingPlan{
{Source: &policy.SubjectMapping{Id: "mapping-1"}, Status: PruneStatusDelete},
},
RegisteredResources: []*PruneRegisteredResourcePlan{
{Source: testRegisteredResource("resource-1", "dataset"), Status: PruneStatusDelete},
},
ObligationTriggers: []*PruneObligationTriggerPlan{
{Source: &policy.ObligationTrigger{Id: "trigger-1"}, Status: PruneStatusDelete},
},
}
prompter := &queuedSelectPrompter{
selectValues: []string{
namespacedPolicyCommitConfirm,
namespacedPolicyCommitConfirm,
namespacedPolicyCommitConfirm,
namespacedPolicyCommitConfirm,
namespacedPolicyCommitConfirm,
},
}

err := ConfirmPrunePlanDeletes(t.Context(), plan, prompter)
require.NoError(t, err)

assert.Equal(t, 5, prompter.selectCalls)
}

func TestApplyPruneDeleteConfirmationDecisionHandlesChoices(t *testing.T) {
t.Parallel()

promptErr := errors.New("boom")
tests := []struct {
name string
selectValue string
selectErr error
wantSkipped bool
wantErr error
}{
{
name: "confirm",
selectValue: namespacedPolicyCommitConfirm,
},
{
name: "skip",
selectValue: namespacedPolicyCommitSkip,
wantSkipped: true,
},
{
name: "abort",
selectValue: namespacedPolicyCommitAbort,
wantErr: ErrInteractiveReviewAborted,
},
{
name: "prompt error",
selectErr: promptErr,
wantErr: promptErr,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

prompter := &queuedSelectPrompter{
selectValues: []string{tt.selectValue},
selectErr: tt.selectErr,
}
skipped := false

err := applyPruneDeleteConfirmationDecision(t.Context(), prompter, SelectPrompt{Title: "test prompt"}, func() {
skipped = true
})
if tt.wantErr == nil {
require.NoError(t, err)
assert.Equal(t, tt.wantSkipped, skipped)
return
}
require.ErrorIs(t, err, tt.wantErr)
assert.False(t, skipped)
})
}
}
Loading
Loading