diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/diff/diff.go b/staging/src/k8s.io/kubectl/pkg/cmd/diff/diff.go index 5c47744389b..9da6888f11b 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/diff/diff.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/diff/diff.go @@ -25,6 +25,9 @@ import ( "regexp" "strings" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/printers" + "github.com/jonboulle/clockwork" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/api/errors" @@ -107,15 +110,25 @@ type DiffOptions struct { FieldManager string ForceConflicts bool - Selector string - OpenAPISchema openapi.Resources - DiscoveryClient discovery.DiscoveryInterface - DynamicClient dynamic.Interface - DryRunVerifier *resource.DryRunVerifier - CmdNamespace string - EnforceNamespace bool - Builder *resource.Builder - Diff *DiffProgram + Selector string + OpenAPISchema openapi.Resources + DiscoveryClient discovery.DiscoveryInterface + DynamicClient dynamic.Interface + DryRunVerifier *resource.DryRunVerifier + CmdNamespace string + EnforceNamespace bool + Builder *resource.Builder + Diff *DiffProgram + Mapper meta.RESTMapper + Prune bool + PruneResources []pruneResource + VisitedUids sets.String + VisitedNamespaces sets.String + ToPrinter func(string) (printers.ResourcePrinter, error) + PrintFlags *genericclioptions.PrintFlags + All bool + PruneWhitelist []string + genericclioptions.IOStreams } func validateArgs(cmd *cobra.Command, args []string) error { @@ -131,6 +144,10 @@ func NewDiffOptions(ioStreams genericclioptions.IOStreams) *DiffOptions { Exec: exec.New(), IOStreams: ioStreams, }, + VisitedUids: sets.NewString(), + VisitedNamespaces: sets.NewString(), + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, } } @@ -145,6 +162,7 @@ func NewCmdDiff(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.C Run: func(cmd *cobra.Command, args []string) { cmdutil.CheckDiffErr(options.Complete(f, cmd)) cmdutil.CheckDiffErr(validateArgs(cmd, args)) + cmdutil.CheckErr(validatePruneAll(options.Prune, options.All, options.Selector)) // `kubectl diff` propagates the error code from // diff or `KUBECTL_EXTERNAL_DIFF`. Also, we // don't want to print an error if diff returns @@ -170,6 +188,9 @@ func NewCmdDiff(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.C usage := "contains the configuration to diff" cmd.Flags().StringVarP(&options.Selector, "selector", "l", options.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().StringArrayVar(&options.PruneWhitelist, "prune-whitelist", options.PruneWhitelist, "Overwrite the default whitelist with for --prune") + cmd.Flags().BoolVar(&options.Prune, "prune", options.Prune, "Automatically diff for possibly will be deleted resource objects, Should be used with either -l or --all.") + cmd.Flags().BoolVar(&options.All, "all", options.All, "Select all resources in the namespace of the specified resource types.") cmdutil.AddFilenameOptionFlags(cmd, &options.FilenameOptions, usage) cmdutil.AddServerSideApplyFlags(cmd) cmdutil.AddFieldManagerFlagVar(cmd, &options.FieldManager, apply.FieldManagerClientSideApply) @@ -177,6 +198,16 @@ func NewCmdDiff(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.C return cmd } +func validatePruneAll(prune, all bool, selector string) error { + if all && len(selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if prune && !all && selector == "" { + return fmt.Errorf("all resources selected for prune without explicitly passing --all. To prune all resources, pass the --all flag. If you did not mean to prune all resources, specify a label selector") + } + return nil +} + // DiffProgram finds and run the diff program. The value of // KUBECTL_EXTERNAL_DIFF environment variable will be used a diff // program. By default, `diff(1)` will be used. @@ -618,6 +649,12 @@ func (o *DiffOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { return fmt.Errorf("--force-conflicts only works with --server-side") } + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + cmdutil.PrintFlagsWithDryRunStrategy(o.PrintFlags, cmdutil.DryRunServer) + return o.PrintFlags.ToPrinter() + } + if !o.ServerSideApply { o.OpenAPISchema, err = f.OpenAPISchema() if err != nil { @@ -642,6 +679,18 @@ func (o *DiffOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { return err } + if o.Prune { + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.PruneResources, err = parsePruneResources(o.Mapper, o.PruneWhitelist) + if err != nil { + return err + } + } + o.Builder = f.NewBuilder() return nil } @@ -708,6 +757,8 @@ func (o *DiffOptions) Run() error { } err = differ.Diff(obj, printer) + o.MarkNamespaceVisited(info) + o.MarkObjectVisited(info) if !isConflict(err) { break } @@ -717,9 +768,34 @@ func (o *DiffOptions) Run() error { return err }) + + if o.Prune { + prune := newPruner(o) + prune.pruneAll(o) + } + if err != nil { return err } return differ.Run(o.Diff) } + +// MarkObjectVisited keeps track of UIDs of the applied +// objects. Used for pruning. +func (o *DiffOptions) MarkObjectVisited(info *resource.Info) error { + metadata, err := meta.Accessor(info.Object) + if err != nil { + return err + } + o.VisitedUids.Insert(string(metadata.GetUID())) + return nil +} + +// MarkNamespaceVisited keeps track of which namespaces the applied +// objects belong to. Used for pruning. +func (o *DiffOptions) MarkNamespaceVisited(info *resource.Info) { + if info.Namespaced() { + o.VisitedNamespaces.Insert(info.Namespace) + } +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune.go b/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune.go new file mode 100644 index 00000000000..99086429003 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune.go @@ -0,0 +1,236 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diff + +import ( + "context" + "fmt" + "io" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/dynamic" +) + +type pruner struct { + mapper meta.RESTMapper + dynamicClient dynamic.Interface + + visitedUids sets.String + visitedNamespaces sets.String + labelSelector string + fieldSelector string + + cascadingStrategy metav1.DeletionPropagation + gracePeriod int + + toPrinter func(string) (printers.ResourcePrinter, error) + + out io.Writer +} + +func newPruner(o *DiffOptions) pruner { + return pruner{ + mapper: o.Mapper, + dynamicClient: o.DynamicClient, + + labelSelector: o.Selector, + visitedUids: o.VisitedUids, + visitedNamespaces: o.VisitedNamespaces, + + toPrinter: o.ToPrinter, + + cascadingStrategy: metav1.DeletePropagationBackground, + gracePeriod: -1, + + out: o.ErrOut, + } +} + +func (p *pruner) pruneAll(o *DiffOptions) error { + + namespacedRESTMappings, nonNamespacedRESTMappings, err := getRESTMappings(o.Mapper, &(o.PruneResources)) + if err != nil { + return fmt.Errorf("error retrieving RESTMappings to prune: %v", err) + } + + for n := range p.visitedNamespaces { + for _, m := range namespacedRESTMappings { + if err := p.prune(n, m); err != nil { + return fmt.Errorf("error pruning namespaced object %v: %v", m.GroupVersionKind, err) + } + } + } + for _, m := range nonNamespacedRESTMappings { + if err := p.prune(metav1.NamespaceNone, m); err != nil { + return fmt.Errorf("error pruning nonNamespaced object %v: %v", m.GroupVersionKind, err) + } + } + + return nil +} + +func (p *pruner) prune(namespace string, mapping *meta.RESTMapping) error { + objList, err := p.dynamicClient.Resource(mapping.Resource). + Namespace(namespace). + List(context.TODO(), metav1.ListOptions{ + LabelSelector: p.labelSelector, + FieldSelector: p.fieldSelector, + }) + if err != nil { + return err + } + + objs, err := meta.ExtractList(objList) + if err != nil { + return err + } + + for _, obj := range objs { + metadata, err := meta.Accessor(obj) + if err != nil { + return err + } + annots := metadata.GetAnnotations() + if _, ok := annots[corev1.LastAppliedConfigAnnotation]; !ok { + // don't prune resources not created with apply + continue + } + uid := metadata.GetUID() + if p.visitedUids.Has(string(uid)) { + continue + } + name := metadata.GetName() + if err := p.delete(namespace, name, mapping); err != nil { + return err + } + + printer, err := p.toPrinter("pruned") + if err != nil { + return err + } + + printer.PrintObj(obj, p.out) + } + return nil +} + +func (p *pruner) delete(namespace, name string, mapping *meta.RESTMapping) error { + return runDelete(namespace, name, mapping, p.dynamicClient, p.cascadingStrategy, p.gracePeriod, true) +} + +func runDelete(namespace, name string, mapping *meta.RESTMapping, c dynamic.Interface, cascadingStrategy metav1.DeletionPropagation, gracePeriod int, serverDryRun bool) error { + options := asDeleteOptions(cascadingStrategy, gracePeriod) + if serverDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + return c.Resource(mapping.Resource).Namespace(namespace).Delete(context.TODO(), name, options) +} + +func asDeleteOptions(cascadingStrategy metav1.DeletionPropagation, gracePeriod int) metav1.DeleteOptions { + options := metav1.DeleteOptions{} + if gracePeriod >= 0 { + options = *metav1.NewDeleteOptions(int64(gracePeriod)) + } + options.PropagationPolicy = &cascadingStrategy + return options +} + +type pruneResource struct { + group string + version string + kind string + namespaced bool +} + +func (pr pruneResource) String() string { + return fmt.Sprintf("%v/%v, Kind=%v, Namespaced=%v", pr.group, pr.version, pr.kind, pr.namespaced) +} + +func getRESTMappings(mapper meta.RESTMapper, pruneResources *[]pruneResource) (namespaced, nonNamespaced []*meta.RESTMapping, err error) { + if len(*pruneResources) == 0 { + // default allowlist + *pruneResources = []pruneResource{ + {"", "v1", "ConfigMap", true}, + {"", "v1", "Endpoints", true}, + {"", "v1", "Namespace", false}, + {"", "v1", "PersistentVolumeClaim", true}, + {"", "v1", "PersistentVolume", false}, + {"", "v1", "Pod", true}, + {"", "v1", "ReplicationController", true}, + {"", "v1", "Secret", true}, + {"", "v1", "Service", true}, + {"batch", "v1", "Job", true}, + {"batch", "v1", "CronJob", true}, + {"networking.k8s.io", "v1", "Ingress", true}, + {"apps", "v1", "DaemonSet", true}, + {"apps", "v1", "Deployment", true}, + {"apps", "v1", "ReplicaSet", true}, + {"apps", "v1", "StatefulSet", true}, + } + } + + for _, resource := range *pruneResources { + addedMapping, err := mapper.RESTMapping(schema.GroupKind{Group: resource.group, Kind: resource.kind}, resource.version) + if err != nil { + return nil, nil, fmt.Errorf("invalid resource %v: %v", resource, err) + } + if resource.namespaced { + namespaced = append(namespaced, addedMapping) + } else { + nonNamespaced = append(nonNamespaced, addedMapping) + } + } + + return namespaced, nonNamespaced, nil +} + +func parsePruneResources(mapper meta.RESTMapper, gvks []string) ([]pruneResource, error) { + pruneResources := []pruneResource{} + for _, groupVersionKind := range gvks { + gvk := strings.Split(groupVersionKind, "/") + if len(gvk) != 3 { + return nil, fmt.Errorf("invalid GroupVersionKind format: %v, please follow ", groupVersionKind) + } + + if gvk[0] == "core" { + gvk[0] = "" + } + mapping, err := mapper.RESTMapping(schema.GroupKind{Group: gvk[0], Kind: gvk[2]}, gvk[1]) + if err != nil { + return pruneResources, err + } + var namespaced bool + namespaceScope := mapping.Scope.Name() + switch namespaceScope { + case meta.RESTScopeNameNamespace: + namespaced = true + case meta.RESTScopeNameRoot: + namespaced = false + default: + return pruneResources, fmt.Errorf("Unknown namespace scope: %q", namespaceScope) + } + + pruneResources = append(pruneResources, pruneResource{gvk[0], gvk[1], gvk[2], namespaced}) + } + return pruneResources, nil +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune_test.go new file mode 100644 index 00000000000..b6a59c95c17 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/diff/prune_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diff + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type testRESTMapper struct { + meta.RESTMapper + scope meta.RESTScope +} + +func (m *testRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + return &meta.RESTMapping{ + Resource: schema.GroupVersionResource{ + Group: gk.Group, + Version: "", + Resource: "", + }, + GroupVersionKind: schema.GroupVersionKind{ + Group: gk.Group, + Version: "", + Kind: gk.Kind, + }, + Scope: m.scope, + }, nil +} + +func TestParsePruneResources(t *testing.T) { + tests := []struct { + mapper *testRESTMapper + gvks []string + expected []pruneResource + err bool + }{ + { + mapper: &testRESTMapper{ + scope: meta.RESTScopeNamespace, + }, + gvks: nil, + expected: []pruneResource{}, + err: false, + }, + { + mapper: &testRESTMapper{ + scope: meta.RESTScopeNamespace, + }, + gvks: []string{"group/kind/version/test"}, + expected: []pruneResource{}, + err: true, + }, + { + mapper: &testRESTMapper{ + scope: meta.RESTScopeNamespace, + }, + gvks: []string{"group/kind/version"}, + expected: []pruneResource{{group: "group", version: "kind", kind: "version", namespaced: true}}, + err: false, + }, + { + mapper: &testRESTMapper{ + scope: meta.RESTScopeRoot, + }, + gvks: []string{"group/kind/version"}, + expected: []pruneResource{{group: "group", version: "kind", kind: "version", namespaced: false}}, + err: false, + }, + } + + for _, tc := range tests { + actual, err := parsePruneResources(tc.mapper, tc.gvks) + if tc.err { + assert.NotEmptyf(t, err, "parsePruneResources error expected but not fired") + } else { + assert.Equal(t, actual, tc.expected, "parsePruneResources failed expected %v actual %v", tc.expected, actual) + } + } +}