From bf6bc5f3462e376afd26cbfd2caec31eb38cf304 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Fri, 25 Jun 2021 20:01:59 -0700 Subject: [PATCH] Adds object dependency sorting and tests --- pkg/apply/applier_test.go | 110 ++---- pkg/object/graph/depends.go | 136 +++++++ pkg/object/graph/depends_test.go | 613 +++++++++++++++++++++++++++++++ pkg/testutil/object.go | 138 +++++++ 4 files changed, 916 insertions(+), 81 deletions(-) create mode 100644 pkg/object/graph/depends.go create mode 100644 pkg/object/graph/depends_test.go create mode 100644 pkg/testutil/object.go diff --git a/pkg/apply/applier_test.go b/pkg/apply/applier_test.go index fe3f9250..e40c9c48 100644 --- a/pkg/apply/applier_test.go +++ b/pkg/apply/applier_test.go @@ -69,49 +69,6 @@ spec: } ) -func Unstructured(t *testing.T, manifest string, mutators ...mutator) *unstructured.Unstructured { - u := &unstructured.Unstructured{} - err := runtime.DecodeInto(codec, []byte(manifest), u) - if !assert.NoError(t, err) { - t.FailNow() - } - for _, m := range mutators { - m.Mutate(u) - } - return u -} - -type mutator interface { - Mutate(u *unstructured.Unstructured) -} - -func addOwningInv(t *testing.T, inv string) mutator { - return owningInvMutator{ - t: t, - inv: inv, - } -} - -type owningInvMutator struct { - t *testing.T - inv string -} - -func (a owningInvMutator) Mutate(u *unstructured.Unstructured) { - annos, found, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations") - if !assert.NoError(a.t, err) { - a.t.FailNow() - } - if !found { - annos = make(map[string]string) - } - annos["config.k8s.io/owning-inventory"] = a.inv - err = unstructured.SetNestedStringMap(u.Object, annos, "metadata", "annotations") - if !assert.NoError(a.t, err) { - a.t.FailNow() - } -} - type resourceInfo struct { resource *unstructured.Unstructured exists bool @@ -157,7 +114,7 @@ func TestApplier(t *testing.T) { "initial apply without status or prune": { namespace: "default", resources: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), }, invInfo: inventoryInfo{ name: "abc-123", @@ -186,8 +143,8 @@ func TestApplier(t *testing.T) { "first apply multiple resources with status and prune": { namespace: "default", resources: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"]), - Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), }, invInfo: inventoryInfo{ name: "inv-123", @@ -202,25 +159,25 @@ func TestApplier(t *testing.T) { { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["deployment"]), + Identifier: testutil.ToIdentifier(t, resources["deployment"]), Status: status.InProgressStatus, - Resource: Unstructured(t, resources["deployment"]), + Resource: testutil.Unstructured(t, resources["deployment"]), }, }, { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["deployment"]), + Identifier: testutil.ToIdentifier(t, resources["deployment"]), Status: status.CurrentStatus, - Resource: Unstructured(t, resources["deployment"]), + Resource: testutil.Unstructured(t, resources["deployment"]), }, }, { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["secret"]), + Identifier: testutil.ToIdentifier(t, resources["secret"]), Status: status.CurrentStatus, - Resource: Unstructured(t, resources["secret"]), + Resource: testutil.Unstructured(t, resources["secret"]), }, }, }, @@ -266,8 +223,8 @@ func TestApplier(t *testing.T) { "apply multiple existing resources with status and prune": { namespace: "default", resources: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"]), - Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), }, invInfo: inventoryInfo{ name: "inv-123", @@ -275,12 +232,12 @@ func TestApplier(t *testing.T) { id: "test", list: []object.ObjMetadata{ object.UnstructuredToObjMeta( - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), ), }, }, clusterObjs: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), }, reconcileTimeout: time.Minute, prune: true, @@ -289,17 +246,17 @@ func TestApplier(t *testing.T) { { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["deployment"]), + Identifier: testutil.ToIdentifier(t, resources["deployment"]), Status: status.CurrentStatus, - Resource: Unstructured(t, resources["deployment"]), + Resource: testutil.Unstructured(t, resources["deployment"]), }, }, { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["secret"]), + Identifier: testutil.ToIdentifier(t, resources["secret"]), Status: status.CurrentStatus, - Resource: Unstructured(t, resources["secret"]), + Resource: testutil.Unstructured(t, resources["secret"]), }, }, }, @@ -348,16 +305,16 @@ func TestApplier(t *testing.T) { id: "test", list: []object.ObjMetadata{ object.UnstructuredToObjMeta( - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), ), object.UnstructuredToObjMeta( - Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["secret"]), ), }, }, clusterObjs: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"], addOwningInv(t, "test")), - Unstructured(t, resources["secret"], addOwningInv(t, "test")), + testutil.Unstructured(t, resources["deployment"], testutil.AddOwningInv(t, "test")), + testutil.Unstructured(t, resources["secret"], testutil.AddOwningInv(t, "test")), }, reconcileTimeout: time.Minute, prune: true, @@ -402,7 +359,7 @@ func TestApplier(t *testing.T) { "apply resource with existing object belonging to different inventory": { namespace: "default", resources: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), }, invInfo: inventoryInfo{ name: "abc-123", @@ -410,7 +367,7 @@ func TestApplier(t *testing.T) { id: "test", }, clusterObjs: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"], addOwningInv(t, "unmatched")), + testutil.Unstructured(t, resources["deployment"], testutil.AddOwningInv(t, "unmatched")), }, reconcileTimeout: time.Minute, prune: true, @@ -419,14 +376,14 @@ func TestApplier(t *testing.T) { { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["deployment"]), + Identifier: testutil.ToIdentifier(t, resources["deployment"]), Status: status.InProgressStatus, }, }, { EventType: pollevent.ResourceUpdateEvent, Resource: &pollevent.ResourceStatus{ - Identifier: toIdentifier(t, resources["deployment"]), + Identifier: testutil.ToIdentifier(t, resources["deployment"]), Status: status.CurrentStatus, }, }, @@ -470,12 +427,12 @@ func TestApplier(t *testing.T) { id: "test", list: []object.ObjMetadata{ object.UnstructuredToObjMeta( - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), ), }, }, clusterObjs: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"], addOwningInv(t, "unmatched")), + testutil.Unstructured(t, resources["deployment"], testutil.AddOwningInv(t, "unmatched")), }, reconcileTimeout: 0, prune: true, @@ -513,12 +470,12 @@ func TestApplier(t *testing.T) { id: "test", list: []object.ObjMetadata{ object.UnstructuredToObjMeta( - Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["deployment"]), ), }, }, clusterObjs: []*unstructured.Unstructured{ - Unstructured(t, resources["deployment"], addOwningInv(t, "test")), + testutil.Unstructured(t, resources["deployment"], testutil.AddOwningInv(t, "test")), }, reconcileTimeout: 0, prune: true, @@ -832,15 +789,6 @@ func newFakeRESTClient(t *testing.T, handlers []handler) *fake.RESTClient { } } -func toIdentifier(t *testing.T, manifest string) object.ObjMetadata { - obj := Unstructured(t, manifest) - return object.ObjMetadata{ - GroupKind: obj.GetObjectKind().GroupVersionKind().GroupKind(), - Name: obj.GetName(), - Namespace: "default", - } -} - // The handler interface allows different testcases to provide // special handling of requests. It also allows a single handler // to keep state between a set of related requests instead of keeping diff --git a/pkg/object/graph/depends.go b/pkg/object/graph/depends.go new file mode 100644 index 00000000..f662972d --- /dev/null +++ b/pkg/object/graph/depends.go @@ -0,0 +1,136 @@ +// Copyright 2021 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +// This package provides a object sorting functionality +// based on the explicit "depends-on" annotation, and +// implicit object dependencies like namespaces and CRD's. +package graph + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/klog/v2" + "sigs.k8s.io/cli-utils/pkg/object" +) + +// SortObjs returns a slice of the sets of objects to apply (in order). +// Each of the objects in an apply set is applied together. The order of +// the returned applied sets is a topological ordering of the sets to apply. +// Returns an single empty apply set if there are no objects to apply. +func SortObjs(objs []*unstructured.Unstructured) [][]*unstructured.Unstructured { + if len(objs) == 0 { + return [][]*unstructured.Unstructured{} + } + // Create the graph, and build a map of object metadata to the object (Unstructured). + g := New() + objToUnstructured := map[object.ObjMetadata]*unstructured.Unstructured{} + for _, obj := range objs { + id := object.UnstructuredToObjMeta(obj) + objToUnstructured[id] = obj + } + // Add object vertices and dependency edges to graph. + addExplicitEdges(g, objs) + addNamespaceEdges(g, objs) + addCRDEdges(g, objs) + // Run topological sort on the graph. + objSets := [][]*unstructured.Unstructured{} + sortedObjSets, err := g.Sort() + if err != nil { + return objSets + } + // Map the object metadata back to the sorted sets of unstructured objects. + for _, objSet := range sortedObjSets { + currentSet := []*unstructured.Unstructured{} + for _, id := range objSet { + var found bool + var obj *unstructured.Unstructured + if obj, found = objToUnstructured[id]; found { + currentSet = append(currentSet, obj) + } + } + objSets = append(objSets, currentSet) + } + return objSets +} + +// ReverseSortObjs is the same as SortObjs but using reverse ordering. +func ReverseSortObjs(objs []*unstructured.Unstructured) [][]*unstructured.Unstructured { + // Sorted objects using normal ordering. + s := SortObjs(objs) + // Reverse the ordering of the object sets using swaps. + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } + return s +} + +// addExplicitEdges updates the graph with edges from objects +// with an explicit "depends-on" annotation. +func addExplicitEdges(g *Graph, objs []*unstructured.Unstructured) { + for _, obj := range objs { + id := object.UnstructuredToObjMeta(obj) + klog.V(3).Infof("adding vertex: %s", id) + g.AddVertex(id) + deps, err := object.DependsOnObjs(obj) + if err == nil { + for _, dep := range deps { + klog.V(3).Infof("adding edge from: %s, to: %s", id, dep) + g.AddEdge(id, dep) + } + } + } +} + +// addCRDEdges adds edges to the dependency graph from custom +// resources to their definitions to ensure the CRD's exist +// before applying the custom resources created with the definition. +func addCRDEdges(g *Graph, objs []*unstructured.Unstructured) { + crds := map[string]object.ObjMetadata{} + // First create a map of all the CRD's. + for _, u := range objs { + if object.IsCRD(u) { + groupKind, found := object.GetCRDGroupKind(u) + if found { + obj := object.UnstructuredToObjMeta(u) + crds[groupKind.String()] = obj + } + } + } + // Iterate through all resources to see if we are applying any + // custom resources defined by previously recorded CRD's. + for _, u := range objs { + gvk := u.GroupVersionKind() + groupKind := gvk.GroupKind() + if to, found := crds[groupKind.String()]; found { + from := object.UnstructuredToObjMeta(u) + klog.V(3).Infof("adding edge from: custom resource %s, to CRD: %s", from, to) + g.AddEdge(from, to) + } + } +} + +// addNamespaceEdges adds edges to the dependency graph from namespaced +// objects to the namespace objects. Ensures the namespaces exist +// before the resources in those namespaces are applied. +func addNamespaceEdges(g *Graph, objs []*unstructured.Unstructured) { + namespaces := map[string]object.ObjMetadata{} + // First create a map of all the namespaces objects live in. + for _, obj := range objs { + if object.IsKindNamespace(obj) { + id := object.UnstructuredToObjMeta(obj) + namespace := obj.GetName() + namespaces[namespace] = id + } + } + // Next, if the namespace of a namespaced object is being applied, + // then create an edge from the namespaced object to its namespace. + for _, obj := range objs { + if object.IsNamespaced(obj) { + objNamespace := obj.GetNamespace() + if namespace, found := namespaces[objNamespace]; found { + id := object.UnstructuredToObjMeta(obj) + klog.V(3).Infof("adding edge from: %s to namespace: %s", id, namespace) + g.AddEdge(id, namespace) + } + } + } +} diff --git a/pkg/object/graph/depends_test.go b/pkg/object/graph/depends_test.go new file mode 100644 index 00000000..4f0c9c4c --- /dev/null +++ b/pkg/object/graph/depends_test.go @@ -0,0 +1,613 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package graph + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/testutil" +) + +var ( + resources = map[string]string{ + "pod": ` +kind: Pod +apiVersion: v1 +metadata: + name: test-pod + namespace: test-namespace +`, + "default-pod": ` +kind: Pod +apiVersion: v1 +metadata: + name: pod-in-default-namespace + namespace: default +`, + "deployment": ` +kind: Deployment +apiVersion: apps/v1 +metadata: + name: foo + namespace: test-namespace + uid: dep-uid + generation: 1 +spec: + replicas: 1 +`, + "secret": ` +kind: Secret +apiVersion: v1 +metadata: + name: secret + namespace: test-namespace + uid: secret-uid + generation: 1 +type: Opaque +spec: + foo: bar +`, + "namespace": ` +kind: Namespace +apiVersion: v1 +metadata: + name: test-namespace +`, + + "crd": ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: crontabs.stable.example.com +spec: + group: stable.example.com + versions: + - name: v1 + served: true + storage: true + scope: Namespaced + names: + plural: crontabs + singular: crontab + kind: CronTab +`, + "crontab1": ` +apiVersion: "stable.example.com/v1" +kind: CronTab +metadata: + name: cron-tab-01 + namespace: test-namespace +`, + "crontab2": ` +apiVersion: "stable.example.com/v1" +kind: CronTab +metadata: + name: cron-tab-02 + namespace: test-namespace +`, + } +) + +func TestSortObjs(t *testing.T) { + testCases := map[string]struct { + objs []*unstructured.Unstructured + expected [][]*unstructured.Unstructured + isError bool + }{ + "no objects returns no object sets": { + objs: []*unstructured.Unstructured{}, + expected: [][]*unstructured.Unstructured{}, + isError: false, + }, + "one object returns single object set": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "two unrelated objects returns single object set with two objs": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + }, + isError: false, + }, + "one object depends on the other; two single object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "three objects depend on another; three single object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["pod"]))), + testutil.Unstructured(t, resources["pod"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["pod"]), + }, + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "Two objects depend on secret; two object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["pod"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["pod"]), + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "two objects applied with their namespace; two object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["secret"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["namespace"]), + }, + { + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "two custom resources applied with their CRD; two object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["crd"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["crd"]), + }, + { + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + }, + isError: false, + }, + "two custom resources wit CRD and namespace; two object sets": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["crd"]), + testutil.Unstructured(t, resources["namespace"]), + }, + { + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + }, + isError: false, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + actual := SortObjs(tc.objs) + verifyObjSets(t, tc.expected, actual) + }) + } +} + +func TestReverseSortObjs(t *testing.T) { + testCases := map[string]struct { + objs []*unstructured.Unstructured + expected [][]*unstructured.Unstructured + isError bool + }{ + "no objects returns no object sets": { + objs: []*unstructured.Unstructured{}, + expected: [][]*unstructured.Unstructured{}, + isError: false, + }, + "one object returns single object set": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + isError: false, + }, + "three objects depend on another; three single object sets in opposite order": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["pod"]))), + testutil.Unstructured(t, resources["pod"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["pod"]), + }, + }, + isError: false, + }, + "two objects applied with their namespace; two sets in opposite order": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["secret"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["namespace"]), + }, + }, + isError: false, + }, + "two custom resources wit CRD and namespace; two object sets in opposite order": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + expected: [][]*unstructured.Unstructured{ + { + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + { + testutil.Unstructured(t, resources["crd"]), + testutil.Unstructured(t, resources["namespace"]), + }, + }, + isError: false, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + actual := ReverseSortObjs(tc.objs) + verifyObjSets(t, tc.expected, actual) + }) + } +} + +func TestAddExplicitEdges(t *testing.T) { + testCases := map[string]struct { + objs []*unstructured.Unstructured + expected []Edge + }{ + "no objects adds no graph edges": { + objs: []*unstructured.Unstructured{}, + expected: []Edge{}, + }, + "no depends-on annotations adds no graph edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + expected: []Edge{}, + }, + "no depends-on annotations, two objects, adds no graph edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + expected: []Edge{}, + }, + "two dependent objects, adds one edge": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["deployment"]), + To: testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + "three dependent objects, adds two edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["pod"], + testutil.AddDependsOn(t, testutil.Unstructured(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["deployment"]), + To: testutil.ToIdentifier(t, resources["secret"]), + }, + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + "pod has two dependencies, adds two edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["pod"], + testutil.AddDependsOn(t, + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + ), + ), + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["secret"]), + }, + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g := New() + addExplicitEdges(g, tc.objs) + actual := g.GetEdges() + verifyEdges(t, tc.expected, actual) + }) + } +} + +func TestAddNamespaceEdges(t *testing.T) { + testCases := map[string]struct { + objs []*unstructured.Unstructured + expected []Edge + }{ + "no namespace objects adds no graph edges": { + objs: []*unstructured.Unstructured{}, + expected: []Edge{}, + }, + "single namespace adds no graph edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["namespace"]), + }, + expected: []Edge{}, + }, + "pod within namespace adds one edge": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["pod"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["namespace"]), + }, + }, + }, + "pod not in namespace does not add edge": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["default-pod"]), + }, + expected: []Edge{}, + }, + "pod, secret, and namespace adds two edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["pod"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["namespace"]), + }, + { + From: testutil.ToIdentifier(t, resources["secret"]), + To: testutil.ToIdentifier(t, resources["namespace"]), + }, + }, + }, + "one pod in namespace, one not, adds only one edge": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["default-pod"]), + testutil.Unstructured(t, resources["pod"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["pod"]), + To: testutil.ToIdentifier(t, resources["namespace"]), + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g := New() + addNamespaceEdges(g, tc.objs) + actual := g.GetEdges() + verifyEdges(t, tc.expected, actual) + }) + } +} + +func TestAddCRDEdges(t *testing.T) { + testCases := map[string]struct { + objs []*unstructured.Unstructured + expected []Edge + }{ + "no CRD objects adds no graph edges": { + objs: []*unstructured.Unstructured{}, + expected: []Edge{}, + }, + "single namespace adds no graph edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crd"]), + }, + expected: []Edge{}, + }, + "two custom resources adds no graph edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + expected: []Edge{}, + }, + "two custom resources with crd adds two edges": { + objs: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["crd"]), + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + expected: []Edge{ + { + From: testutil.ToIdentifier(t, resources["crontab1"]), + To: testutil.ToIdentifier(t, resources["crd"]), + }, + { + From: testutil.ToIdentifier(t, resources["crontab2"]), + To: testutil.ToIdentifier(t, resources["crd"]), + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g := New() + addCRDEdges(g, tc.objs) + actual := g.GetEdges() + verifyEdges(t, tc.expected, actual) + }) + } +} + +// verifyObjSets ensures the expected and actual slice of object sets are the same, +// and the sets are in order. +func verifyObjSets(t *testing.T, expected [][]*unstructured.Unstructured, actual [][]*unstructured.Unstructured) { + if len(expected) != len(actual) { + t.Fatalf("expected (%d) object sets, got (%d)", len(expected), len(actual)) + return + } + // Order matters + for i := range expected { + expectedSet := expected[i] + actualSet := actual[i] + if len(expectedSet) != len(actualSet) { + t.Fatalf("set %d: expected object size (%d), got (%d)", i, len(expectedSet), len(actualSet)) + return + } + for _, actualObj := range actualSet { + if !containsObjs(expectedSet, actualObj) { + t.Fatalf("set #%d: actual object (%v) not found in set of expected objects", i, actualObj) + return + } + } + } +} + +// containsUnstructured returns true if the passed object is within the passed +// slice of objects; false otherwise. Order is not important. +func containsObjs(objs []*unstructured.Unstructured, obj *unstructured.Unstructured) bool { + ids := object.UnstructuredsToObjMetas(objs) + id := object.UnstructuredToObjMeta(obj) + for _, i := range ids { + if i == id { + return true + } + } + return false +} + +// verifyEdges ensures the slices of directed Edges contain the same elements. +// Order is not important. +func verifyEdges(t *testing.T, expected []Edge, actual []Edge) { + if len(expected) != len(actual) { + t.Fatalf("expected (%d) edges, got (%d)", len(expected), len(actual)) + return + } + for _, actualEdge := range actual { + if !containsEdge(expected, actualEdge) { + t.Errorf("actual Edge (%v) not found in expected Edges", actualEdge) + return + } + } +} + +// containsEdge return true if the passed Edge is in the slice of Edges; +// false otherwise. +func containsEdge(edges []Edge, edge Edge) bool { + for _, e := range edges { + if e.To == edge.To && e.From == edge.From { + return true + } + } + return false +} diff --git a/pkg/testutil/object.go b/pkg/testutil/object.go new file mode 100644 index 00000000..7b7eccf5 --- /dev/null +++ b/pkg/testutil/object.go @@ -0,0 +1,138 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 +// +// The testutil package houses utility function for testing. + +package testutil + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kubectl/pkg/scheme" + "sigs.k8s.io/cli-utils/pkg/object" +) + +var codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + +// Unstructured translates the passed object config string into an +// object in Unstructured format. The mutators modify the config +// yaml before returning the object. +func Unstructured(t *testing.T, manifest string, mutators ...Mutator) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + err := runtime.DecodeInto(codec, []byte(manifest), u) + if !assert.NoError(t, err) { + t.FailNow() + } + for _, m := range mutators { + m.Mutate(u) + } + return u +} + +// Mutator inteface defines a function to update an object +// while translating it unto Unstructured format from yaml config. +type Mutator interface { + Mutate(u *unstructured.Unstructured) +} + +// ToIdentifer translates object yaml config into ObjMetadata. +func ToIdentifier(t *testing.T, manifest string) object.ObjMetadata { + obj := Unstructured(t, manifest) + return object.ObjMetadata{ + GroupKind: obj.GetObjectKind().GroupVersionKind().GroupKind(), + Name: obj.GetName(), + Namespace: obj.GetNamespace(), // If cluster-scoped, empty namespace string + } +} + +// AddOwningInv returns a Mutator which adds the passed inv string +// as the owning inventory annotation. +func AddOwningInv(t *testing.T, inv string) Mutator { + return owningInvMutator{ + t: t, + inv: inv, + } +} + +// owningInvMutator encapsulates the fields necessary to modify +// an object by adding the owning inventory annotation. This +// structure implements the Mutator interface. +type owningInvMutator struct { + t *testing.T + inv string +} + +// Mutate updates the passed object by adding the owning +// inventory annotation. Needed to implement the Mutator interface. +func (a owningInvMutator) Mutate(u *unstructured.Unstructured) { + annos, found, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations") + if !assert.NoError(a.t, err) { + a.t.FailNow() + } + if !found { + annos = make(map[string]string) + } + annos["config.k8s.io/owning-inventory"] = a.inv + err = unstructured.SetNestedStringMap(u.Object, annos, "metadata", "annotations") + if !assert.NoError(a.t, err) { + a.t.FailNow() + } +} + +// AddDependsOn returns a Mutator which adds the passed objects as a +// depends-on annotation to the object which is mutated. Multiple objects +// passed in means multiple depends on objects in the annotation separated +// by a comma. +func AddDependsOn(t *testing.T, objs ...*unstructured.Unstructured) Mutator { + return dependsOnMutator{ + t: t, + depObjs: objs, + } +} + +// dependsOnMutator encapsulates fields for adding depends-on annotation +// to a test object. Implements the Mutator interface. +type dependsOnMutator struct { + t *testing.T + depObjs []*unstructured.Unstructured +} + +// Mutate for dependsOnMutator adds the stored object dependencies +// in the depends-on annotation for the passed mutated object. +func (d dependsOnMutator) Mutate(u *unstructured.Unstructured) { + // Add depends on object annotation to passed object. + var objStr string + // Iterate through all dependent objects to create the + // depends-on annotation string. + for i, depObj := range d.depObjs { + if i > 0 { + objStr += "," + } + groupKind := depObj.GroupVersionKind().GroupKind() + group := groupKind.Group + kind := groupKind.Kind + name := depObj.GetName() + if object.IsNamespaced(depObj) { + objStr += fmt.Sprintf("%s/namespaces/%s/%s/%s", + group, depObj.GetNamespace(), kind, name) + } else { + objStr += fmt.Sprintf("%s/%s/%s", group, kind, name) + } + } + annos, found, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations") + if !assert.NoError(d.t, err) { + d.t.FailNow() + } + if !found { + annos = make(map[string]string) + } + annos[object.DependsOnAnnotation] = objStr + err = unstructured.SetNestedStringMap(u.Object, annos, "metadata", "annotations") + if !assert.NoError(d.t, err) { + d.t.FailNow() + } +}