diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete.go b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete.go index 6a7dd0f7e15..12df2ff4f7f 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete.go @@ -121,6 +121,7 @@ type DeleteOptions struct { Quiet bool WarnClusterScope bool Raw string + Interactive bool GracePeriod int Timeout time.Duration @@ -129,9 +130,11 @@ type DeleteOptions struct { Output string - DynamicClient dynamic.Interface - Mapper meta.RESTMapper - Result *resource.Result + DynamicClient dynamic.Interface + Mapper meta.RESTMapper + Result *resource.Result + PreviewResult *resource.Result + previewResourceMap map[cmdwait.ResourceLocation]struct{} genericiooptions.IOStreams WarningPrinter *printers.WarningPrinter @@ -197,8 +200,38 @@ func (o *DeleteOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Co return err } - if len(o.Raw) == 0 { - r := f.NewBuilder(). + // Set default WarningPrinter if not already set. + 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(). ContinueOnError(). NamespaceParam(cmdNamespace).DefaultNamespace(). @@ -210,26 +243,22 @@ func (o *DeleteOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Co ResourceTypeOrNameArgs(false, args...).RequireObject(false). Flatten(). Do() - err = r.Err() - if err != nil { - return err - } - o.Result = r - - o.Mapper, err = f.ToRESTMapper() - if err != nil { - return err - } - - o.DynamicClient, err = f.DynamicClient() + err = previewr.Err() if err != nil { return err } + o.PreviewResult = previewr + o.previewResourceMap = make(map[cmdwait.ResourceLocation]struct{}) } - // Set default WarningPrinter if not already set. - if o.WarningPrinter == nil { - o.WarningPrinter = printers.NewWarningPrinter(o.ErrOut, printers.WarningPrinterOptions{Color: term.AllowsColorOutput(o.ErrOut)}) + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err } 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") } - if len(o.Raw) > 0 { - if len(o.FilenameOptions.Filenames) > 1 { - 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 len(o.Raw) == 0 { + return nil + } - 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) + if o.Interactive { + return fmt.Errorf("--interactive can not be used with --raw") + } + if len(o.FilenameOptions.Filenames) > 1 { + 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 { + 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 } @@ -291,6 +325,39 @@ func (o *DeleteOptions) RunDelete(f cmdutil.Factory) error { } 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) } @@ -306,6 +373,18 @@ func (o *DeleteOptions) DeleteResult(r *resource.Result) error { if err != nil { 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) found++ @@ -440,3 +519,24 @@ func (o *DeleteOptions) PrintObj(info *resource.Info) { // understandable output by default 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") +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_flags.go b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_flags.go index cc69490a0b9..83998f1af54 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_flags.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_flags.go @@ -48,6 +48,7 @@ type DeleteFlags struct { Wait *bool Output *string Raw *string + Interactive *bool } 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 { options.Raw = *f.Raw } + if f.Interactive != nil { + options.Interactive = *f.Interactive + } return options, nil } @@ -156,6 +160,11 @@ func (f *DeleteFlags) AddFlags(cmd *cobra.Command) { 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.") } + 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 @@ -175,6 +184,7 @@ func NewDeleteCommandFlags(usage string) *DeleteFlags { timeout := time.Duration(0) wait := true raw := "" + interactive := false filenames := []string{} recursive := false @@ -198,6 +208,7 @@ func NewDeleteCommandFlags(usage string) *DeleteFlags { Wait: &wait, Output: &output, Raw: &raw, + Interactive: &interactive, } } diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_test.go index 1032d676dbb..c47d4fc72e0 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/delete/delete_test.go @@ -18,6 +18,7 @@ package delete import ( "encoding/json" + "fmt" "io" "net/http" "strconv" @@ -35,6 +36,7 @@ import ( cmdtesting "k8s.io/kubectl/pkg/cmd/testing" cmdutil "k8s.io/kubectl/pkg/cmd/util" "k8s.io/kubectl/pkg/scheme" + "k8s.io/utils/pointer" ) func fakecmd() *cobra.Command { @@ -47,6 +49,49 @@ func fakecmd() *cobra.Command { 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) { cmdtesting.InitTestErrorHandler(t) _, _, 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) { pods, _, _ := cmdtesting.TestData() diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go b/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go index db7e7253489..096f3008d3a 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go @@ -428,6 +428,7 @@ const ( ApplySet FeatureGate = "KUBECTL_APPLYSET" ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3" CmdPluginAsSubcommand FeatureGate = "KUBECTL_ENABLE_CMD_SHADOW" + InteractiveDelete FeatureGate = "KUBECTL_INTERACTIVE_DELETE" ) func (f FeatureGate) IsEnabled() bool { diff --git a/test/cmd/delete.sh b/test/cmd/delete.sh index 8de3fdc4255..b510a41a28d 100755 --- a/test/cmd/delete.sh +++ b/test/cmd/delete.sh @@ -52,3 +52,75 @@ run_kubectl_delete_allnamespaces_tests() { set +o nounset 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 +} diff --git a/test/cmd/legacy-script.sh b/test/cmd/legacy-script.sh index 9b75f463a33..d3e0220be25 100755 --- a/test/cmd/legacy-script.sh +++ b/test/cmd/legacy-script.sh @@ -618,6 +618,13 @@ runTests() { record_command run_kubectl_delete_allnamespaces_tests fi + ###################### + # Delete --interactive # + ###################### + if kube::test::if_supports_resource "${configmaps}" ; then + record_command run_kubectl_delete_interactive_tests + fi + ################## # Global timeout # ##################