From 7b44f71caf08fc8ecdbfaedc5bc7a8c7cb247385 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Thu, 23 Jan 2020 16:37:58 -0800 Subject: [PATCH] Adds the PruneOptions and implements the methods for this struct --- cmd/kubectl/kubectlcobra/prune.go | 258 +++++++++++++++++++++++++ cmd/kubectl/kubectlcobra/prune_test.go | 244 +++++++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 cmd/kubectl/kubectlcobra/prune.go create mode 100644 cmd/kubectl/kubectlcobra/prune_test.go diff --git a/cmd/kubectl/kubectlcobra/prune.go b/cmd/kubectl/kubectlcobra/prune.go new file mode 100644 index 000000000..c7d9ffbe2 --- /dev/null +++ b/cmd/kubectl/kubectlcobra/prune.go @@ -0,0 +1,258 @@ +// Copyright 2019 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +// package kubectlcobra contains cobra commands from kubectl +package kubectlcobra + +import ( + "fmt" + "io" + "strings" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/cmd/apply" + "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/validation" +) + +// PruneOptions encapsulates the necessary information to +// implement the prune functionality. +type PruneOptions struct { + client dynamic.Interface + builder *resource.Builder + mapper meta.RESTMapper + namespace string + // The currently applied objects (as Infos), including the + // current grouping object. These objects are used to + // calculate the prune set after retreiving the previous + // grouping objects. + currentGroupingObject *resource.Info + // The set of retrieved grouping objects (as Infos) selected + // by the grouping label. This set should also include the + // current grouping object. Stored here to make testing + // easier by manually setting the retrieved grouping infos. + pastGroupingObjects []*resource.Info + retrievedGroupingObjects bool + + toPrinter func(string) (printers.ResourcePrinter, error) + out io.Writer + + validator validation.Schema + + // TODO: DeleteOptions--cascade? +} + +// NewPruneOptions returns a struct (PruneOptions) encapsulating the necessary +// information to run the prune. Returns an error if an error occurs +// gathering this information. +// TODO: Add dry-run options. +func NewPruneOptions(f util.Factory, ao *apply.ApplyOptions) (*PruneOptions, error) { + + po := &PruneOptions{} + var err error + // Fields copied from ApplyOptions. + po.namespace = ao.Namespace + po.toPrinter = ao.ToPrinter + po.out = ao.Out + // Client/Builder fields from the Factory. + po.client, err = f.DynamicClient() + if err != nil { + return nil, err + } + po.builder = f.NewBuilder() + po.mapper, err = f.ToRESTMapper() + if err != nil { + return nil, err + } + po.validator, err = f.Validator(false) + if err != nil { + return nil, err + } + // Retrieve/store the grouping object for current apply. + currentObjects, err := ao.GetObjects() + if err != nil { + return nil, err + } + currentGroupingObject, found := findGroupingObject(currentObjects) + if !found { + return nil, fmt.Errorf("Current grouping object not found during prune.") + } + po.currentGroupingObject = currentGroupingObject + // Initialize past grouping objects as empty. + po.pastGroupingObjects = []*resource.Info{} + po.retrievedGroupingObjects = false + + return po, nil +} + +// getPreviousGroupingObjects returns the set of grouping objects +// that have the same label as the current grouping object. Removes +// the current grouping object from this set. Returns an error +// if there is a problem retrieving the grouping objects. +func (po *PruneOptions) getPreviousGroupingObjects() ([]*resource.Info, error) { + + // Ensures the "pastGroupingObjects" is set. + if !po.retrievedGroupingObjects { + if err := po.retrievePreviousGroupingObjects(); err != nil { + return nil, err + } + } + + // Remove the current grouping info from the previous grouping infos. + currentInventory, err := infoToInventory(po.currentGroupingObject) + if err != nil { + return nil, err + } + pastGroupInfos := []*resource.Info{} + for _, pastInfo := range po.pastGroupingObjects { + pastInventory, err := infoToInventory(pastInfo) + if err != nil { + return nil, err + } + if !currentInventory.Equals(pastInventory) { + pastGroupInfos = append(pastGroupInfos, pastInfo) + } + } + return pastGroupInfos, nil +} + +// retrievePreviousGroupingObjects requests the previous grouping objects +// using the grouping label from the current grouping object. Sets +// the field "pastGroupingObjects". Returns an error if the grouping +// label doesn't exist for the current currentGroupingObject does not +// exist or if the call to retrieve the past grouping objects fails. +func (po *PruneOptions) retrievePreviousGroupingObjects() error { + // Get the grouping label for this grouping object, and create + // a label selector from it. + if po.currentGroupingObject == nil || po.currentGroupingObject.Object == nil { + return fmt.Errorf("Missing current grouping object.\n") + } + groupingLabel, err := retrieveGroupingLabel(po.currentGroupingObject.Object) + if err != nil { + return err + } + labelSelector := fmt.Sprintf("%s=%s", GroupingLabel, groupingLabel) + retrievedGroupingInfos, err := po.builder. + Unstructured(). + // TODO: Check if this validator is necessary. + Schema(po.validator). + ContinueOnError(). + NamespaceParam(po.namespace).DefaultNamespace(). + ResourceTypes("configmap"). + LabelSelectorParam(labelSelector). + Flatten(). + Do(). + Infos() + if err != nil { + return err + } + po.pastGroupingObjects = retrievedGroupingInfos + po.retrievedGroupingObjects = true + return nil +} + +// infoToInventory transforms the object represented by the passed "info" +// into its Inventory representation. Returns error if the passed Info +// is nil, or the Object in the Info is empty. +func infoToInventory(info *resource.Info) (*Inventory, error) { + if info == nil || info.Object == nil { + return nil, fmt.Errorf("Empty resource.Info can not calculate as inventory.\n") + } + obj := info.Object + gk := obj.GetObjectKind().GroupVersionKind().GroupKind() + return createInventory(info.Namespace, info.Name, gk) +} + +// unionPastInventory takes a set of grouping objects (infos), returning the +// union of the objects referenced by these grouping objects as an +// InventorySet. Returns an error if any of the passed objects are not +// grouping objects, or if unable to retrieve the inventory from any +// grouping object. +func unionPastInventory(infos []*resource.Info) (*InventorySet, error) { + inventorySet := NewInventorySet([]*Inventory{}) + for _, info := range infos { + inv, err := retrieveInventoryFromGroupingObj([]*resource.Info{info}) + if err != nil { + return nil, err + } + inventorySet.AddItems(inv) + } + return inventorySet, nil +} + +// calcPruneSet returns the InventorySet representing the objects to +// delete (prune). pastGroupInfos are the set of past applied grouping +// objects, storing the inventory of the objects applied at the same time. +// Calculates the prune set as: +// +// prune set = (prev1 U prev2 U ... U prevN) - (curr1, curr2, ..., currN) +// +// Returns an error if we are unable to retrieve the set of previously +// applied objects, or if we are unable to get the currently applied objects +// from the current grouping object. +func (po *PruneOptions) calcPruneSet(pastGroupingInfos []*resource.Info) (*InventorySet, error) { + pastInventory, err := unionPastInventory(pastGroupingInfos) + if err != nil { + return nil, err + } + // Current grouping object as inventory set. + c := []*resource.Info{po.currentGroupingObject} + currentInv, err := retrieveInventoryFromGroupingObj(c) + if err != nil { + return nil, err + } + return pastInventory.Subtract(NewInventorySet(currentInv)) +} + +// Prune deletes the set of resources which were previously applied +// (retrieved from previous grouping objects) but omitted in +// the current apply. Prune also delete all previous grouping +// objects. Returns an error if there was a problem. +func (po *PruneOptions) Prune() error { + + // Retrieve previous grouping objects, and calculate the + // union of the previous applies as an inventory set. + pastGroupingInfos, err := po.getPreviousGroupingObjects() + if err != nil { + return err + } + pruneSet, err := po.calcPruneSet(pastGroupingInfos) + if err != nil { + return err + } + + // Delete the prune objects. + for _, inv := range pruneSet.GetItems() { + mapping, err := po.mapper.RESTMapping(inv.GroupKind) + if err != nil { + return err + } + err = po.client.Resource(mapping.Resource).Namespace(inv.Namespace).Delete(inv.Name, &metav1.DeleteOptions{}) + if err != nil { + return err + } + fmt.Fprintf(po.out, "%s/%s deleted\n", strings.ToLower(inv.GroupKind.Kind), inv.Name) + } + // Delete previous grouping objects. + for _, pastGroupInfo := range pastGroupingInfos { + err = po.client.Resource(pastGroupInfo.Mapping.Resource). + Namespace(pastGroupInfo.Namespace). + Delete(pastGroupInfo.Name, &metav1.DeleteOptions{}) + if err != nil { + return err + } + printer, err := po.toPrinter("deleted") + if err != nil { + return err + } + if err = printer.PrintObj(pastGroupInfo.Object, po.out); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/kubectl/kubectlcobra/prune_test.go b/cmd/kubectl/kubectlcobra/prune_test.go new file mode 100644 index 000000000..20faa9936 --- /dev/null +++ b/cmd/kubectl/kubectlcobra/prune_test.go @@ -0,0 +1,244 @@ +// Copyright 2019 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +// package kubectlcobra contains cobra commands from kubectl +package kubectlcobra + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" +) + +var pod1Inv = &Inventory{ + Namespace: testNamespace, + Name: pod1Name, + GroupKind: schema.GroupKind{ + Group: "", + Kind: "Pod", + }, +} + +var pod2Inv = &Inventory{ + Namespace: testNamespace, + Name: pod2Name, + GroupKind: schema.GroupKind{ + Group: "", + Kind: "Pod", + }, +} + +var pod3Inv = &Inventory{ + Namespace: testNamespace, + Name: pod3Name, + GroupKind: schema.GroupKind{ + Group: "", + Kind: "Pod", + }, +} + +var groupingInv = &Inventory{ + Namespace: testNamespace, + Name: groupingObjName, + GroupKind: schema.GroupKind{ + Group: "", + Kind: "ConfigMap", + }, +} + +func TestInfoToInventory(t *testing.T) { + tests := map[string]struct { + info *resource.Info + expected *Inventory + isError bool + }{ + "Nil info is an error": { + info: nil, + expected: nil, + isError: true, + }, + "Nil info object is an error": { + info: nilInfo, + expected: nil, + isError: true, + }, + "Pod 1 object becomes Pod 1 inventory": { + info: pod1Info, + expected: pod1Inv, + isError: false, + }, + "Grouping object becomes grouping inventory": { + info: copyGroupingInfo(), + expected: groupingInv, + isError: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + actual, err := infoToInventory(test.info) + if test.isError && err == nil { + t.Errorf("Did not receive expected error.\n") + } + if !test.isError { + if err != nil { + t.Errorf("Receieved unexpected error: %s\n", err) + } + if !test.expected.Equals(actual) { + t.Errorf("Expected inventory (%s), got (%s)\n", test.expected, actual) + } + } + }) + } +} + +// Returns a grouping object with the inventory set from +// the passed "children". +func createGroupingInfo(name string, children ...(*resource.Info)) *resource.Info { + groupingObjCopy := groupingObj.DeepCopy() + var groupingInfo = &resource.Info{ + Namespace: testNamespace, + Name: groupingObjName, + Object: groupingObjCopy, + } + infos := []*resource.Info{groupingInfo} + infos = append(infos, children...) + _ = addInventoryToGroupingObj(infos) + return groupingInfo +} + +func TestUnionPastInventory(t *testing.T) { + tests := map[string]struct { + groupingInfos []*resource.Info + expected []*Inventory + }{ + "Empty grouping objects = empty inventory set": { + groupingInfos: []*resource.Info{}, + expected: []*Inventory{}, + }, + "No children in grouping object, equals no inventory": { + groupingInfos: []*resource.Info{createGroupingInfo("test-1")}, + expected: []*Inventory{}, + }, + "Grouping object with Pod1 returns inventory with Pod1": { + groupingInfos: []*resource.Info{createGroupingInfo("test-1", pod1Info)}, + expected: []*Inventory{pod1Inv}, + }, + "Grouping object with three pods returns inventory with three pods": { + groupingInfos: []*resource.Info{ + createGroupingInfo("test-1", pod1Info, pod2Info, pod3Info), + }, + expected: []*Inventory{pod1Inv, pod2Inv, pod3Inv}, + }, + "Two grouping objects with different pods returns inventory with both pods": { + groupingInfos: []*resource.Info{ + createGroupingInfo("test-1", pod1Info), + createGroupingInfo("test-2", pod2Info), + }, + expected: []*Inventory{pod1Inv, pod2Inv}, + }, + "Two grouping objects with overlapping pods returns set of pods": { + groupingInfos: []*resource.Info{ + createGroupingInfo("test-1", pod1Info, pod2Info), + createGroupingInfo("test-2", pod2Info, pod3Info), + }, + expected: []*Inventory{pod1Inv, pod2Inv, pod3Inv}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + actual, err := unionPastInventory(test.groupingInfos) + expected := NewInventorySet(test.expected) + if err != nil { + t.Errorf("Unexpected error received: %s\n", err) + } + if !expected.Equals(actual) { + t.Errorf("Expected inventory (%s), got (%s)\n", expected, actual) + } + }) + } +} + +func TestCalcPruneSet(t *testing.T) { + tests := map[string]struct { + past []*resource.Info + current *resource.Info + expected []*Inventory + isError bool + }{ + "Object not unstructured--error": { + past: []*resource.Info{nonUnstructuredGroupingInfo}, + current: &resource.Info{}, + expected: []*Inventory{}, + isError: true, + }, + "No past group objects--no prune set": { + + past: []*resource.Info{}, + current: createGroupingInfo("test-1"), + expected: []*Inventory{}, + isError: false, + }, + "Empty past grouping object--no prune set": { + past: []*resource.Info{createGroupingInfo("test-1")}, + current: createGroupingInfo("test-1"), + expected: []*Inventory{}, + isError: false, + }, + "Pod1 - Pod1 = empty set": { + past: []*resource.Info{ + createGroupingInfo("test-1", pod1Info), + }, + current: createGroupingInfo("test-1", pod1Info), + expected: []*Inventory{}, + isError: false, + }, + "(Pod1, Pod2) - Pod1 = Pod2": { + past: []*resource.Info{ + createGroupingInfo("test-1", pod1Info, pod2Info), + }, + current: createGroupingInfo("test-1", pod1Info), + expected: []*Inventory{pod2Inv}, + isError: false, + }, + "(Pod1, Pod2) - Pod2 = Pod1": { + past: []*resource.Info{ + createGroupingInfo("test-1", pod1Info, pod2Info), + }, + current: createGroupingInfo("test-1", pod2Info), + expected: []*Inventory{pod1Inv}, + isError: false, + }, + "(Pod1, Pod2, Pod3) - Pod2 = Pod1, Pod3": { + past: []*resource.Info{ + createGroupingInfo("test-1", pod1Info, pod2Info), + createGroupingInfo("test-1", pod2Info, pod3Info), + }, + current: createGroupingInfo("test-1", pod2Info), + expected: []*Inventory{pod1Inv, pod3Inv}, + isError: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + po := &PruneOptions{} + po.currentGroupingObject = test.current + actual, err := po.calcPruneSet(test.past) + expected := NewInventorySet(test.expected) + if test.isError && err == nil { + t.Errorf("Did not receive expected error.\n") + } + if !test.isError { + if err != nil { + t.Errorf("Unexpected error received: %s\n", err) + } + if !expected.Equals(actual) { + t.Errorf("Expected prune set (%s), got (%s)\n", expected, actual) + } + } + }) + } +}