kubectl delete: Introduce new interactive flag for interactive deletion (#114530)

This commit is contained in:
Arda Güçlü 2023-07-11 16:05:11 +03:00 committed by GitHub
parent 86038ae590
commit 3267dd9d52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 356 additions and 36 deletions

View File

@ -121,6 +121,7 @@ type DeleteOptions struct {
Quiet bool Quiet bool
WarnClusterScope bool WarnClusterScope bool
Raw string Raw string
Interactive bool
GracePeriod int GracePeriod int
Timeout time.Duration Timeout time.Duration
@ -129,9 +130,11 @@ type DeleteOptions struct {
Output string Output string
DynamicClient dynamic.Interface DynamicClient dynamic.Interface
Mapper meta.RESTMapper Mapper meta.RESTMapper
Result *resource.Result Result *resource.Result
PreviewResult *resource.Result
previewResourceMap map[cmdwait.ResourceLocation]struct{}
genericiooptions.IOStreams genericiooptions.IOStreams
WarningPrinter *printers.WarningPrinter WarningPrinter *printers.WarningPrinter
@ -197,8 +200,38 @@ func (o *DeleteOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Co
return err return err
} }
if len(o.Raw) == 0 { // Set default WarningPrinter if not already set.
r := f.NewBuilder(). if o.WarningPrinter == nil {
o.WarningPrinter = printers.NewWarningPrinter(o.ErrOut, printers.WarningPrinterOptions{Color: term.AllowsColorOutput(o.ErrOut)})
}
if len(o.Raw) != 0 {
return nil
}
r := f.NewBuilder().
Unstructured().
ContinueOnError().
NamespaceParam(cmdNamespace).DefaultNamespace().
FilenameParam(enforceNamespace, &o.FilenameOptions).
LabelSelectorParam(o.LabelSelector).
FieldSelectorParam(o.FieldSelector).
SelectAllParam(o.DeleteAll).
AllNamespaces(o.DeleteAllNamespaces).
ResourceTypeOrNameArgs(false, args...).RequireObject(false).
Flatten().
Do()
err = r.Err()
if err != nil {
return err
}
o.Result = r
if o.Interactive {
// preview result will be used to list resources for confirmation prior to actual delete.
// We can not use r as result object because it can only be used once. But we need to traverse
// twice. Parameters in preview result must be equal to genuine result.
previewr := f.NewBuilder().
Unstructured(). Unstructured().
ContinueOnError(). ContinueOnError().
NamespaceParam(cmdNamespace).DefaultNamespace(). NamespaceParam(cmdNamespace).DefaultNamespace().
@ -210,26 +243,22 @@ func (o *DeleteOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Co
ResourceTypeOrNameArgs(false, args...).RequireObject(false). ResourceTypeOrNameArgs(false, args...).RequireObject(false).
Flatten(). Flatten().
Do() Do()
err = r.Err() err = previewr.Err()
if err != nil {
return err
}
o.Result = r
o.Mapper, err = f.ToRESTMapper()
if err != nil {
return err
}
o.DynamicClient, err = f.DynamicClient()
if err != nil { if err != nil {
return err return err
} }
o.PreviewResult = previewr
o.previewResourceMap = make(map[cmdwait.ResourceLocation]struct{})
} }
// Set default WarningPrinter if not already set. o.Mapper, err = f.ToRESTMapper()
if o.WarningPrinter == nil { if err != nil {
o.WarningPrinter = printers.NewWarningPrinter(o.ErrOut, printers.WarningPrinterOptions{Color: term.AllowsColorOutput(o.ErrOut)}) return err
}
o.DynamicClient, err = f.DynamicClient()
if err != nil {
return err
} }
return nil return nil
@ -257,26 +286,31 @@ func (o *DeleteOptions) Validate() error {
return fmt.Errorf("--force and --grace-period greater than 0 cannot be specified together") return fmt.Errorf("--force and --grace-period greater than 0 cannot be specified together")
} }
if len(o.Raw) > 0 { if len(o.Raw) == 0 {
if len(o.FilenameOptions.Filenames) > 1 { return nil
return fmt.Errorf("--raw can only use a single local file or stdin") }
} else if len(o.FilenameOptions.Filenames) == 1 {
if strings.Index(o.FilenameOptions.Filenames[0], "http://") == 0 || strings.Index(o.FilenameOptions.Filenames[0], "https://") == 0 {
return fmt.Errorf("--raw cannot read from a url")
}
}
if o.FilenameOptions.Recursive { if o.Interactive {
return fmt.Errorf("--raw and --recursive are mutually exclusive") return fmt.Errorf("--interactive can not be used with --raw")
} }
if len(o.Output) > 0 { if len(o.FilenameOptions.Filenames) > 1 {
return fmt.Errorf("--raw and --output are mutually exclusive") return fmt.Errorf("--raw can only use a single local file or stdin")
} } else if len(o.FilenameOptions.Filenames) == 1 {
if _, err := url.ParseRequestURI(o.Raw); err != nil { if strings.Index(o.FilenameOptions.Filenames[0], "http://") == 0 || strings.Index(o.FilenameOptions.Filenames[0], "https://") == 0 {
return fmt.Errorf("--raw must be a valid URL path: %v", err) return fmt.Errorf("--raw cannot read from a url")
} }
} }
if o.FilenameOptions.Recursive {
return fmt.Errorf("--raw and --recursive are mutually exclusive")
}
if len(o.Output) > 0 {
return fmt.Errorf("--raw and --output are mutually exclusive")
}
if _, err := url.ParseRequestURI(o.Raw); err != nil {
return fmt.Errorf("--raw must be a valid URL path: %v", err)
}
return nil return nil
} }
@ -291,6 +325,39 @@ func (o *DeleteOptions) RunDelete(f cmdutil.Factory) error {
} }
return rawhttp.RawDelete(restClient, o.IOStreams, o.Raw, o.Filenames[0]) return rawhttp.RawDelete(restClient, o.IOStreams, o.Raw, o.Filenames[0])
} }
if o.Interactive {
previewInfos := []*resource.Info{}
if o.IgnoreNotFound {
o.PreviewResult = o.PreviewResult.IgnoreErrors(errors.IsNotFound)
}
err := o.PreviewResult.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
previewInfos = append(previewInfos, info)
o.previewResourceMap[cmdwait.ResourceLocation{
GroupResource: info.Mapping.Resource.GroupResource(),
Namespace: info.Namespace,
Name: info.Name,
}] = struct{}{}
return nil
})
if err != nil {
return err
}
if len(previewInfos) == 0 {
fmt.Fprintf(o.Out, "No resources found\n")
return nil
}
if !o.confirmation(previewInfos) {
fmt.Fprintf(o.Out, "deletion is cancelled\n")
return nil
}
}
return o.DeleteResult(o.Result) return o.DeleteResult(o.Result)
} }
@ -306,6 +373,18 @@ func (o *DeleteOptions) DeleteResult(r *resource.Result) error {
if err != nil { if err != nil {
return err return err
} }
if o.Interactive {
if _, ok := o.previewResourceMap[cmdwait.ResourceLocation{
GroupResource: info.Mapping.Resource.GroupResource(),
Namespace: info.Namespace,
Name: info.Name,
}]; !ok {
// resource not in the list of previewed resources based on resourceLocation
return nil
}
}
deletedInfos = append(deletedInfos, info) deletedInfos = append(deletedInfos, info)
found++ found++
@ -440,3 +519,24 @@ func (o *DeleteOptions) PrintObj(info *resource.Info) {
// understandable output by default // understandable output by default
fmt.Fprintf(o.Out, "%s \"%s\" %s\n", kindString, info.Name, operation) fmt.Fprintf(o.Out, "%s \"%s\" %s\n", kindString, info.Name, operation)
} }
func (o *DeleteOptions) confirmation(infos []*resource.Info) bool {
fmt.Fprintf(o.Out, i18n.T("You are about to delete the following %d resource(s):\n"), len(infos))
for _, info := range infos {
groupKind := info.Mapping.GroupVersionKind
kindString := fmt.Sprintf("%s.%s", strings.ToLower(groupKind.Kind), groupKind.Group)
if len(groupKind.Group) == 0 {
kindString = strings.ToLower(groupKind.Kind)
}
fmt.Fprintf(o.Out, "%s/%s\n", kindString, info.Name)
}
fmt.Fprintf(o.Out, i18n.T("Do you want to continue?")+" (y/n): ")
var input string
_, err := fmt.Fscan(o.In, &input)
if err != nil {
return false
}
return strings.EqualFold(input, "y")
}

View File

@ -48,6 +48,7 @@ type DeleteFlags struct {
Wait *bool Wait *bool
Output *string Output *string
Raw *string Raw *string
Interactive *bool
} }
func (f *DeleteFlags) ToOptions(dynamicClient dynamic.Interface, streams genericiooptions.IOStreams) (*DeleteOptions, error) { func (f *DeleteFlags) ToOptions(dynamicClient dynamic.Interface, streams genericiooptions.IOStreams) (*DeleteOptions, error) {
@ -106,6 +107,9 @@ func (f *DeleteFlags) ToOptions(dynamicClient dynamic.Interface, streams generic
if f.Raw != nil { if f.Raw != nil {
options.Raw = *f.Raw options.Raw = *f.Raw
} }
if f.Interactive != nil {
options.Interactive = *f.Interactive
}
return options, nil return options, nil
} }
@ -156,6 +160,11 @@ func (f *DeleteFlags) AddFlags(cmd *cobra.Command) {
if f.Raw != nil { if f.Raw != nil {
cmd.Flags().StringVar(f.Raw, "raw", *f.Raw, "Raw URI to DELETE to the server. Uses the transport specified by the kubeconfig file.") cmd.Flags().StringVar(f.Raw, "raw", *f.Raw, "Raw URI to DELETE to the server. Uses the transport specified by the kubeconfig file.")
} }
if cmdutil.InteractiveDelete.IsEnabled() {
if f.Interactive != nil {
cmd.Flags().BoolVarP(f.Interactive, "interactive", "i", *f.Interactive, "If true, delete resource only when user confirms. This flag is in Alpha.")
}
}
} }
// NewDeleteCommandFlags provides default flags and values for use with the "delete" command // NewDeleteCommandFlags provides default flags and values for use with the "delete" command
@ -175,6 +184,7 @@ func NewDeleteCommandFlags(usage string) *DeleteFlags {
timeout := time.Duration(0) timeout := time.Duration(0)
wait := true wait := true
raw := "" raw := ""
interactive := false
filenames := []string{} filenames := []string{}
recursive := false recursive := false
@ -198,6 +208,7 @@ func NewDeleteCommandFlags(usage string) *DeleteFlags {
Wait: &wait, Wait: &wait,
Output: &output, Output: &output,
Raw: &raw, Raw: &raw,
Interactive: &interactive,
} }
} }

View File

@ -18,6 +18,7 @@ package delete
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@ -35,6 +36,7 @@ import (
cmdtesting "k8s.io/kubectl/pkg/cmd/testing" cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
cmdutil "k8s.io/kubectl/pkg/cmd/util" cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/scheme" "k8s.io/kubectl/pkg/scheme"
"k8s.io/utils/pointer"
) )
func fakecmd() *cobra.Command { func fakecmd() *cobra.Command {
@ -47,6 +49,49 @@ func fakecmd() *cobra.Command {
return cmd return cmd
} }
func TestDeleteFlagValidation(t *testing.T) {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
tests := []struct {
flags DeleteFlags
enableAlphas []cmdutil.FeatureGate
args [][]string
expectedErr string
}{
{
flags: DeleteFlags{
Raw: pointer.String("test"),
Interactive: pointer.Bool(true),
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.InteractiveDelete},
expectedErr: "--interactive can not be used with --raw",
},
}
for _, test := range tests {
cmd := fakecmd()
cmdtesting.WithAlphaEnvs(test.enableAlphas, t, func(t *testing.T) {
deleteOptions, err := test.flags.ToOptions(nil, genericiooptions.NewTestIOStreamsDiscard())
if err != nil {
t.Fatalf("unexpected error creating delete options: %s", err)
}
deleteOptions.Filenames = []string{"../../../testdata/redis-master-controller.yaml"}
err = deleteOptions.Complete(f, nil, cmd)
if err != nil {
t.Fatalf("unexpected error creating delete options: %s", err)
}
err = deleteOptions.Validate()
if err == nil {
t.Fatalf("missing expected error")
}
if test.expectedErr != err.Error() {
t.Errorf("expected error %s, got %s", test.expectedErr, err)
}
})
}
}
func TestDeleteObjectByTuple(t *testing.T) { func TestDeleteObjectByTuple(t *testing.T) {
cmdtesting.InitTestErrorHandler(t) cmdtesting.InitTestErrorHandler(t)
_, _, rc := cmdtesting.TestData() _, _, rc := cmdtesting.TestData()
@ -263,6 +308,90 @@ func TestDeleteObject(t *testing.T) {
} }
} }
func TestPreviewResultEqualToResult(t *testing.T) {
deleteFlags := NewDeleteCommandFlags("")
deleteFlags.Interactive = pointer.Bool(true)
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
streams, _, _, _ := genericiooptions.NewTestIOStreams()
deleteOptions, err := deleteFlags.ToOptions(nil, streams)
deleteOptions.Filenames = []string{"../../../testdata/redis-master-controller.yaml"}
if err != nil {
t.Errorf("unexpected error %v", err)
}
err = deleteOptions.Complete(tf, nil, fakecmd())
if err != nil {
t.Errorf("unexpected error %v", err)
}
infos, err := deleteOptions.Result.Infos()
if err != nil {
t.Errorf("unexpected error %v", err)
}
previewInfos, err := deleteOptions.PreviewResult.Infos()
if err != nil {
t.Errorf("unexpected error %v", err)
}
if len(infos) != len(previewInfos) {
t.Errorf("result and previewResult must match")
}
}
func TestDeleteObjectWithInteractive(t *testing.T) {
cmdtesting.InitTestErrorHandler(t)
_, _, rc := cmdtesting.TestData()
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
switch p, m := req.URL.Path, req.Method; {
case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE":
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil
default:
t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
return nil, nil
}
}),
}
cmdtesting.WithAlphaEnvs([]cmdutil.FeatureGate{cmdutil.InteractiveDelete}, t, func(t *testing.T) {
streams, in, buf, _ := genericiooptions.NewTestIOStreams()
fmt.Fprint(in, "y")
cmd := NewCmdDelete(tf, streams)
cmd.Flags().Set("filename", "../../../testdata/redis-master-controller.yaml")
cmd.Flags().Set("output", "name")
cmd.Flags().Set("interactive", "true")
cmd.Run(cmd, []string{})
if buf.String() != "You are about to delete the following 1 resource(s):\nreplicationcontroller/redis-master\nDo you want to continue? (y/n): replicationcontroller/redis-master\n" {
t.Errorf("unexpected output: %s", buf.String())
}
streams, in, buf, _ = genericiooptions.NewTestIOStreams()
fmt.Fprint(in, "n")
cmd = NewCmdDelete(tf, streams)
cmd.Flags().Set("filename", "../../../testdata/redis-master-controller.yaml")
cmd.Flags().Set("output", "name")
cmd.Flags().Set("interactive", "true")
cmd.Run(cmd, []string{})
if buf.String() != "You are about to delete the following 1 resource(s):\nreplicationcontroller/redis-master\nDo you want to continue? (y/n): deletion is cancelled\n" {
t.Errorf("unexpected output: %s", buf.String())
}
if buf.String() == ": replicationcontroller/redis-master\n" {
t.Errorf("unexpected output: %s", buf.String())
}
})
}
func TestGracePeriodScenarios(t *testing.T) { func TestGracePeriodScenarios(t *testing.T) {
pods, _, _ := cmdtesting.TestData() pods, _, _ := cmdtesting.TestData()

View File

@ -428,6 +428,7 @@ const (
ApplySet FeatureGate = "KUBECTL_APPLYSET" ApplySet FeatureGate = "KUBECTL_APPLYSET"
ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3" ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3"
CmdPluginAsSubcommand FeatureGate = "KUBECTL_ENABLE_CMD_SHADOW" CmdPluginAsSubcommand FeatureGate = "KUBECTL_ENABLE_CMD_SHADOW"
InteractiveDelete FeatureGate = "KUBECTL_INTERACTIVE_DELETE"
) )
func (f FeatureGate) IsEnabled() bool { func (f FeatureGate) IsEnabled() bool {

View File

@ -52,3 +52,75 @@ run_kubectl_delete_allnamespaces_tests() {
set +o nounset set +o nounset
set +o errexit set +o errexit
} }
# Runs tests related to kubectl delete --confirm
run_kubectl_delete_interactive_tests() {
set -o nounset
set -o errexit
# enable interactivity flag feature environment variable
export KUBECTL_INTERACTIVE_DELETE=true
ns_one="namespace-$(date +%s)-${RANDOM}"
ns_two="namespace-$(date +%s)-${RANDOM}"
kubectl create namespace "${ns_one}"
kubectl create namespace "${ns_two}"
# create configmaps
kubectl create configmap "one" --namespace="${ns_one}"
kubectl create configmap "two" --namespace="${ns_two}"
kubectl label configmap "one" --namespace="${ns_one}" deletetest=true
kubectl label configmap "two" --namespace="${ns_two}" deletetest=true
# not confirm dry-run=server deletions
output_message=$(kubectl delete configmap --dry-run=server --interactive -l deletetest=true --all-namespaces <<< $'n\n')
kube::test::if_has_string "${output_message}" 'configmap/two' 'configmap/one'
# confirm dry-run=server deletions
kubectl delete configmap --dry-run=server --interactive -l deletetest=true --all-namespaces <<< $'y\n'
# not confirm resource deletions
output_message=$(kubectl delete configmap --interactive -l deletetest=true --all-namespaces <<< $'n\n')
kube::test::if_has_string "${output_message}" 'configmap/two' 'configmap/one'
kubectl config set-context "${CONTEXT}" --namespace="${ns_one}"
kube::test::get_object_assert 'configmap -l deletetest' "{{range.items}}{{${id_field:?}}}:{{end}}" 'one:'
kubectl config set-context "${CONTEXT}" --namespace="${ns_two}"
kube::test::get_object_assert 'configmap -l deletetest' "{{range.items}}{{${id_field:?}}}:{{end}}" 'two:'
# clean configmaps with label deletetest=true
kubectl delete configmap -l deletetest=true --all-namespaces
# create new configmaps
kubectl create configmap "third" --namespace="${ns_one}"
kubectl create configmap "fourth" --namespace="${ns_two}"
kubectl label configmap "third" --namespace="${ns_one}" deletetest2=true
kubectl label configmap "fourth" --namespace="${ns_two}" deletetest2=true
# confirm all resource deletions with waiting
kubectl delete configmaps --interactive -l deletetest2=true --all-namespaces --wait <<< $'y\n'
# no configmaps should be in either of those namespaces with label deletetest2
kubectl config set-context "${CONTEXT}" --namespace="${ns_one}"
kube::test::get_object_assert 'configmap -l deletetest2' "{{range.items}}{{${id_field:?}}}:{{end}}" ''
kubectl config set-context "${CONTEXT}" --namespace="${ns_two}"
kube::test::get_object_assert 'configmap -l deletetest2' "{{range.items}}{{${id_field:?}}}:{{end}}" ''
# clean configmaps with label deletetest2=true
kubectl delete configmap -l deletetest2=true --all-namespaces
# create new configmaps in one namespace
kubectl create configmap "fifth" --namespace="${ns_one}"
kubectl create configmap "sixth" --namespace="${ns_one}"
kubectl label configmap "fifth" --namespace="${ns_one}" deletetest3=true
kubectl label configmap "sixth" --namespace="${ns_one}" deletetest3=true
# confirm all resource deletions with forcing and waiting
kubectl delete configmaps -l deletetest3=true --force --interactive --namespace="${ns_one}" --wait <<< $'y\n'
# no configmaps should be in either of those namespaces with label deletetest3
kubectl config set-context "${CONTEXT}" --namespace="${ns_one}"
kube::test::get_object_assert 'configmap -l deletetest3' "{{range.items}}{{${id_field:?}}}:{{end}}" ''
unset KUBECTL_INTERACTIVE_DELETE
set +o nounset
set +o errexit
}

View File

@ -618,6 +618,13 @@ runTests() {
record_command run_kubectl_delete_allnamespaces_tests record_command run_kubectl_delete_allnamespaces_tests
fi fi
######################
# Delete --interactive #
######################
if kube::test::if_supports_resource "${configmaps}" ; then
record_command run_kubectl_delete_interactive_tests
fi
################## ##################
# Global timeout # # Global timeout #
################## ##################