Introduce new prune parameter into diff command

This PR introduces new prune and it's dependent parameters to simulate
`kubectl apply --prune` command.

Kubernetes-commit: 56c19f1056ad6d4a4bb926fe90e37f56a31c4e2f
This commit is contained in:
Arda Güçlü 2021-09-17 15:27:21 +03:00 committed by Kubernetes Publisher
parent b450289c8c
commit ff5d018049
3 changed files with 419 additions and 9 deletions

View File

@ -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 <group/version/kind> 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)
}
}

236
pkg/cmd/diff/prune.go Normal file
View File

@ -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 <group/version/kind>", 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
}

View File

@ -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)
}
}
}