Merge pull request #105164 from ardaguclu/kubectl-diff-prune

Introduce new prune parameter into diff command
This commit is contained in:
Kubernetes Prow Robot 2021-12-13 11:31:58 -08:00 committed by GitHub
commit e53f93c7bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 449 additions and 86 deletions

View File

@ -22,6 +22,7 @@ import (
"net/http"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
@ -42,6 +43,7 @@ import (
"k8s.io/kubectl/pkg/util"
"k8s.io/kubectl/pkg/util/i18n"
"k8s.io/kubectl/pkg/util/openapi"
"k8s.io/kubectl/pkg/util/prune"
"k8s.io/kubectl/pkg/util/templates"
"k8s.io/kubectl/pkg/validation"
)
@ -60,7 +62,7 @@ type ApplyFlags struct {
FieldManager string
Selector string
Prune bool
PruneResources []pruneResource
PruneResources []prune.Resource
All bool
Overwrite bool
OpenAPIPatch bool
@ -85,7 +87,7 @@ type ApplyOptions struct {
DryRunStrategy cmdutil.DryRunStrategy
DryRunVerifier *resource.DryRunVerifier
Prune bool
PruneResources []pruneResource
PruneResources []prune.Resource
cmdBaseName string
All bool
Overwrite bool
@ -278,7 +280,7 @@ func (flags *ApplyFlags) ToOptions(cmd *cobra.Command, baseName string, args []s
}
if flags.Prune {
flags.PruneResources, err = parsePruneResources(mapper, flags.PruneWhitelist)
flags.PruneResources, err = prune.ParseResources(mapper, flags.PruneWhitelist)
if err != nil {
return nil, err
}

View File

@ -20,16 +20,15 @@ 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"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/util/prune"
)
type pruner struct {
@ -71,7 +70,7 @@ func newPruner(o *ApplyOptions) pruner {
func (p *pruner) pruneAll(o *ApplyOptions) error {
namespacedRESTMappings, nonNamespacedRESTMappings, err := getRESTMappings(o.Mapper, &(o.PruneResources))
namespacedRESTMappings, nonNamespacedRESTMappings, err := prune.GetRESTMappings(o.Mapper, o.PruneResources)
if err != nil {
return fmt.Errorf("error retrieving RESTMappings to prune: %v", err)
}
@ -158,83 +157,3 @@ func asDeleteOptions(cascadingStrategy metav1.DeletionPropagation, gracePeriod i
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

@ -44,6 +44,7 @@ import (
"k8s.io/kubectl/pkg/util"
"k8s.io/kubectl/pkg/util/i18n"
"k8s.io/kubectl/pkg/util/openapi"
"k8s.io/kubectl/pkg/util/prune"
"k8s.io/kubectl/pkg/util/templates"
"k8s.io/utils/exec"
"sigs.k8s.io/yaml"
@ -116,6 +117,7 @@ type DiffOptions struct {
EnforceNamespace bool
Builder *resource.Builder
Diff *DiffProgram
pruner *pruner
}
func validateArgs(cmd *cobra.Command, args []string) error {
@ -170,6 +172,8 @@ 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().StringArray("prune-allowlist", []string{}, "Overwrite the default whitelist with <group/version/kind> for --prune")
cmd.Flags().Bool("prune", false, "Include resources that would be deleted by pruning. Can be used with -l and default shows all resources would be pruned")
cmdutil.AddFilenameOptionFlags(cmd, &options.FilenameOptions, usage)
cmdutil.AddServerSideApplyFlags(cmd)
cmdutil.AddFieldManagerFlagVar(cmd, &options.FieldManager, apply.FieldManagerClientSideApply)
@ -642,6 +646,19 @@ func (o *DiffOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error {
return err
}
if cmdutil.GetFlagBool(cmd, "prune") {
mapper, err := f.ToRESTMapper()
if err != nil {
return err
}
resources, err := prune.ParseResources(mapper, cmdutil.GetFlagStringArray(cmd, "prune-allowlist"))
if err != nil {
return err
}
o.pruner = newPruner(o.DynamicClient, mapper, resources)
}
o.Builder = f.NewBuilder()
return nil
}
@ -707,6 +724,10 @@ func (o *DiffOptions) Run() error {
IOStreams: o.Diff.IOStreams,
}
if o.pruner != nil {
o.pruner.MarkVisited(info)
}
err = differ.Diff(obj, printer)
if !isConflict(err) {
break
@ -717,9 +738,52 @@ func (o *DiffOptions) Run() error {
return err
})
if o.pruner != nil {
prunedObjs, err := o.pruner.pruneAll()
if err != nil {
klog.Warningf("pruning failed and could not be evaluated err: %v", err)
}
// Print pruned objects into old file and thus, diff
// command will show them as pruned.
for _, p := range prunedObjs {
name, err := getObjectName(p)
if err != nil {
klog.Warningf("pruning failed and object name could not be retrieved: %v", err)
continue
}
if err := differ.From.Print(name, p, printer); err != nil {
return err
}
}
}
if err != nil {
return err
}
return differ.Run(o.Diff)
}
func getObjectName(obj runtime.Object) (string, error) {
gvk := obj.GetObjectKind().GroupVersionKind()
metadata, err := meta.Accessor(obj)
if err != nil {
return "", err
}
name := metadata.GetName()
ns := metadata.GetNamespace()
group := ""
if gvk.Group != "" {
group = fmt.Sprintf("%v.", gvk.Group)
}
return group + fmt.Sprintf(
"%v.%v.%v.%v",
gvk.Version,
gvk.Kind,
ns,
name,
), nil
}

View File

@ -0,0 +1,126 @@
/*
Copyright 2021 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"
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"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/dynamic"
"k8s.io/kubectl/pkg/util/prune"
)
type pruner struct {
mapper meta.RESTMapper
dynamicClient dynamic.Interface
visitedUids sets.String
visitedNamespaces sets.String
labelSelector string
resources []prune.Resource
}
func newPruner(dc dynamic.Interface, m meta.RESTMapper, r []prune.Resource) *pruner {
return &pruner{
visitedUids: sets.NewString(),
visitedNamespaces: sets.NewString(),
dynamicClient: dc,
mapper: m,
resources: r,
}
}
func (p *pruner) pruneAll() ([]runtime.Object, error) {
var allPruned []runtime.Object
namespacedRESTMappings, nonNamespacedRESTMappings, err := prune.GetRESTMappings(p.mapper, p.resources)
if err != nil {
return allPruned, fmt.Errorf("error retrieving RESTMappings to prune: %v", err)
}
for n := range p.visitedNamespaces {
for _, m := range namespacedRESTMappings {
if pobjs, err := p.prune(n, m); err != nil {
return pobjs, fmt.Errorf("error pruning namespaced object %v: %v", m.GroupVersionKind, err)
} else {
allPruned = append(allPruned, pobjs...)
}
}
}
for _, m := range nonNamespacedRESTMappings {
if pobjs, err := p.prune(metav1.NamespaceNone, m); err != nil {
return allPruned, fmt.Errorf("error pruning nonNamespaced object %v: %v", m.GroupVersionKind, err)
} else {
allPruned = append(allPruned, pobjs...)
}
}
return allPruned, nil
}
func (p *pruner) prune(namespace string, mapping *meta.RESTMapping) ([]runtime.Object, error) {
objList, err := p.dynamicClient.Resource(mapping.Resource).
Namespace(namespace).
List(context.TODO(), metav1.ListOptions{
LabelSelector: p.labelSelector,
})
if err != nil {
return nil, err
}
objs, err := meta.ExtractList(objList)
if err != nil {
return nil, err
}
var pobjs []runtime.Object
for _, obj := range objs {
metadata, err := meta.Accessor(obj)
if err != nil {
return pobjs, err
}
annots := metadata.GetAnnotations()
if _, ok := annots[corev1.LastAppliedConfigAnnotation]; !ok {
continue
}
uid := metadata.GetUID()
if p.visitedUids.Has(string(uid)) {
continue
}
pobjs = append(pobjs, obj)
}
return pobjs, nil
}
// MarkVisited marks visited namespaces and uids
func (p *pruner) MarkVisited(info *resource.Info) {
if info.Namespaced() {
p.visitedNamespaces.Insert(info.Namespace)
}
metadata, err := meta.Accessor(info.Object)
if err != nil {
return
}
p.visitedUids.Insert(string(metadata.GetUID()))
}

View File

@ -0,0 +1,105 @@
/*
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 prune
import (
"fmt"
"strings"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
)
type Resource struct {
group string
version string
kind string
namespaced bool
}
func (pr Resource) 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 []Resource) (namespaced, nonNamespaced []*meta.RESTMapping, err error) {
if len(pruneResources) == 0 {
// default allowlist
pruneResources = []Resource{
{"", "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 ParseResources(mapper meta.RESTMapper, gvks []string) ([]Resource, error) {
pruneResources := []Resource{}
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, Resource{gvk[0], gvk[1], gvk[2], namespaced})
}
return pruneResources, nil
}

View File

@ -0,0 +1,125 @@
/*
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 prune
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 TestGetRESTMappings(t *testing.T) {
tests := []struct {
mapper *testRESTMapper
pr []Resource
expectedns int
expectednns int
expectederr error
}{
{
mapper: &testRESTMapper{},
pr: []Resource{},
expectedns: 14,
expectednns: 2,
expectederr: nil,
},
}
for _, tc := range tests {
actualns, actualnns, actualerr := GetRESTMappings(tc.mapper, tc.pr)
if tc.expectederr != nil {
assert.NotEmptyf(t, actualerr, "getRESTMappings error expected but not fired")
}
assert.Equal(t, len(actualns), tc.expectedns, "getRESTMappings failed expected number namespaced %d actual %d", tc.expectedns, len(actualns))
assert.Equal(t, len(actualnns), tc.expectednns, "getRESTMappings failed expected number nonnamespaced %d actual %d", tc.expectednns, len(actualnns))
}
}
func TestParsePruneResources(t *testing.T) {
tests := []struct {
mapper *testRESTMapper
gvks []string
expected []Resource
err bool
}{
{
mapper: &testRESTMapper{
scope: meta.RESTScopeNamespace,
},
gvks: nil,
expected: []Resource{},
err: false,
},
{
mapper: &testRESTMapper{
scope: meta.RESTScopeNamespace,
},
gvks: []string{"group/kind/version/test"},
expected: []Resource{},
err: true,
},
{
mapper: &testRESTMapper{
scope: meta.RESTScopeNamespace,
},
gvks: []string{"group/kind/version"},
expected: []Resource{{group: "group", version: "kind", kind: "version", namespaced: true}},
err: false,
},
{
mapper: &testRESTMapper{
scope: meta.RESTScopeRoot,
},
gvks: []string{"group/kind/version"},
expected: []Resource{{group: "group", version: "kind", kind: "version", namespaced: false}},
err: false,
},
}
for _, tc := range tests {
actual, err := ParseResources(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)
}
}
}

View File

@ -88,8 +88,29 @@ run_kubectl_diff_tests() {
output_message=$(kubectl diff --server-side -f hack/testdata/pod-changed.yaml || test $? -eq 1)
kube::test::if_has_string "${output_message}" 'k8s.gcr.io/pause:3.4'
## kubectl diff --prune
kubectl create ns nsb
kubectl apply --namespace nsb -l prune-group=true -f hack/testdata/prune/a.yaml
kube::test::get_object_assert 'pods a -n nsb' "{{${id_field:?}}}" 'a'
# Make sure that kubectl diff does not return pod 'a' without prune flag
output_message=$(kubectl diff -l prune-group=true -f hack/testdata/prune/b.yaml || test $? -eq 1)
kube::test::if_has_not_string "${output_message}" "name: a"
# Make sure that for kubectl diff --prune:
# 1. the exit code for diff is 1 because it found a difference
# 2. the difference contains the pruned pod
output_message=$(kubectl diff --prune -l prune-group=true -f hack/testdata/prune/b.yaml || test $? -eq 1)
# pod 'a' should be in output, it is pruned
kube::test::if_has_string "${output_message}" 'name: a'
# apply b with namespace
kubectl apply --prune --namespace nsb -l prune-group=true -f hack/testdata/prune/b.yaml
# check right pod exists and wrong pod doesn't exist
kube::test::wait_object_assert 'pods -n nsb' "{{range.items}}{{${id_field:?}}}:{{end}}" 'b:'
# Make sure that diff --prune returns nothing (0 exit code) for 'b'.
kubectl diff --prune -l prune-group=true -f hack/testdata/prune/b.yaml
# Cleanup
kubectl delete -f hack/testdata/pod.yaml
kubectl delete -f hack/testdata/prune/b.yaml
set +o nounset
set +o errexit

1
vendor/modules.txt vendored
View File

@ -2153,6 +2153,7 @@ k8s.io/kubectl/pkg/util/openapi
k8s.io/kubectl/pkg/util/openapi/testing
k8s.io/kubectl/pkg/util/openapi/validation
k8s.io/kubectl/pkg/util/podutils
k8s.io/kubectl/pkg/util/prune
k8s.io/kubectl/pkg/util/qos
k8s.io/kubectl/pkg/util/rbac
k8s.io/kubectl/pkg/util/resource