From 4599e4fb003e8f684299b04db340e3d104029bef Mon Sep 17 00:00:00 2001 From: Karl Isenberg Date: Fri, 25 Feb 2022 14:23:17 -0800 Subject: [PATCH] feat: Add dependency filter - Pass TaskContext into TaskBuilder.Build - Combine dependency graph for apply and prune objects. This is required to catch dependencies that would have been deleted. - Replace graph.SortObjs into DependencyGraph + Sort + HydrateSetList - Replace graph.ReverseSortObjs with ReverseSetList to perform on the combined (apply + prune) set list. - Add planned pending applies and prune to the InventoryManager before executing the task queue. This allows the DependencyFilter to validate against the planned actuation strategy of objects that haven't been applied/pruned yet. - Add the dependency graph to the TaskContext, for the DependencyFilter to use. This can be removed in the future if the filters are managed by the solver. - Make Graph.Sort non-destructive, so the graph can be re-used by the DependencyFilter. - Add Graph.EdgesFrom and EdgesTo for the DependencyFilter to use. This requires storing the reverse edge list. - Add an e2e test for the DependencyFilter - Add an e2e test for the LocalNamespaceFilter Fixes https://github.com/kubernetes-sigs/cli-utils/issues/526 Fixes https://github.com/kubernetes-sigs/cli-utils/issues/528 --- pkg/apply/applier.go | 19 +- pkg/apply/destroyer.go | 15 +- pkg/apply/filter/dependency-filter.go | 152 +++++ pkg/apply/filter/dependency-filter_test.go | 451 +++++++++++++ pkg/apply/filter/relationship_string.go | 24 + pkg/apply/solver/solver.go | 63 +- pkg/apply/solver/solver_test.go | 717 ++++++++++++++++++++- pkg/apply/taskrunner/context.go | 11 + pkg/inventory/manager.go | 52 ++ pkg/object/graph/depends.go | 103 ++- pkg/object/graph/depends_test.go | 514 ++++++++++++++- pkg/object/graph/graph.go | 81 ++- pkg/object/graph/graph_test.go | 113 ++++ test/e2e/artifacts_test.go | 7 + test/e2e/dependency_filter_test.go | 411 ++++++++++++ test/e2e/e2e_test.go | 8 + test/e2e/namespace_filter_test.go | 408 ++++++++++++ 17 files changed, 3054 insertions(+), 95 deletions(-) create mode 100644 pkg/apply/filter/dependency-filter.go create mode 100644 pkg/apply/filter/dependency-filter_test.go create mode 100644 pkg/apply/filter/relationship_string.go create mode 100644 test/e2e/dependency_filter_test.go create mode 100644 test/e2e/namespace_filter_test.go diff --git a/pkg/apply/applier.go b/pkg/apply/applier.go index 040727f4..4c42d153 100644 --- a/pkg/apply/applier.go +++ b/pkg/apply/applier.go @@ -14,6 +14,7 @@ import ( "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/klog/v2" + "sigs.k8s.io/cli-utils/pkg/apis/actuation" "sigs.k8s.io/cli-utils/pkg/apply/cache" "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/apply/filter" @@ -125,6 +126,10 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.Info, objects objec } klog.V(4).Infof("calculated %d apply objs; %d prune objs", len(applyObjs), len(pruneObjs)) + // Build a TaskContext for passing info between tasks + resourceCache := cache.NewResourceCacheMap() + taskContext := taskrunner.NewTaskContext(eventChannel, resourceCache) + // Fetch the queue (channel) of tasks that should be executed. klog.V(4).Infoln("applier building task queue...") // Build list of apply validation filters. @@ -135,6 +140,10 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.Info, objects objec Inv: invInfo, InvPolicy: options.InventoryPolicy, }, + filter.DependencyFilter{ + TaskContext: taskContext, + Strategy: actuation.ActuationStrategyApply, + }, } // Build list of prune validation filters. pruneFilters := []filter.ValidationFilter{ @@ -146,9 +155,12 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.Info, objects objec filter.LocalNamespacesFilter{ LocalNamespaces: localNamespaces(invInfo, object.UnstructuredSetToObjMetadataSet(objects)), }, + filter.DependencyFilter{ + TaskContext: taskContext, + Strategy: actuation.ActuationStrategyDelete, + }, } // Build list of apply mutators. - resourceCache := cache.NewResourceCacheMap() applyMutators := []mutator.Interface{ &mutator.ApplyTimeMutator{ Client: a.client, @@ -184,7 +196,7 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.Info, objects objec WithApplyObjects(applyObjs). WithPruneObjects(pruneObjs). WithInventory(invInfo). - Build(opts) + Build(taskContext, opts) klog.V(4).Infof("validation errors: %d", len(vCollector.Errors)) klog.V(4).Infof("invalid objects: %d", len(vCollector.InvalidIds)) @@ -206,9 +218,6 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.Info, objects objec return } - // Build a TaskContext for passing info between tasks - taskContext := taskrunner.NewTaskContext(eventChannel, resourceCache) - // Register invalid objects to be retained in the inventory, if present. for _, id := range vCollector.InvalidIds { taskContext.AddInvalidObject(id) diff --git a/pkg/apply/destroyer.go b/pkg/apply/destroyer.go index 770ade76..d16c0429 100644 --- a/pkg/apply/destroyer.go +++ b/pkg/apply/destroyer.go @@ -11,6 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog/v2" cmdutil "k8s.io/kubectl/pkg/cmd/util" + "sigs.k8s.io/cli-utils/pkg/apis/actuation" "sigs.k8s.io/cli-utils/pkg/apply/cache" "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/apply/filter" @@ -129,6 +130,10 @@ func (d *Destroyer) Run(ctx context.Context, invInfo inventory.Info, options Des } validator.Validate(deleteObjs) + // Build a TaskContext for passing info between tasks + resourceCache := cache.NewResourceCacheMap() + taskContext := taskrunner.NewTaskContext(eventChannel, resourceCache) + klog.V(4).Infoln("destroyer building task queue...") dynamicClient, err := d.factory.DynamicClient() if err != nil { @@ -141,6 +146,10 @@ func (d *Destroyer) Run(ctx context.Context, invInfo inventory.Info, options Des Inv: invInfo, InvPolicy: options.InventoryPolicy, }, + filter.DependencyFilter{ + TaskContext: taskContext, + Strategy: actuation.ActuationStrategyDelete, + }, } taskBuilder := &solver.TaskQueueBuilder{ Pruner: d.pruner, @@ -165,7 +174,7 @@ func (d *Destroyer) Run(ctx context.Context, invInfo inventory.Info, options Des taskQueue := taskBuilder. WithPruneObjects(deleteObjs). WithInventory(invInfo). - Build(opts) + Build(taskContext, opts) klog.V(4).Infof("validation errors: %d", len(vCollector.Errors)) klog.V(4).Infof("invalid objects: %d", len(vCollector.InvalidIds)) @@ -187,10 +196,6 @@ func (d *Destroyer) Run(ctx context.Context, invInfo inventory.Info, options Des return } - // Build a TaskContext for passing info between tasks - resourceCache := cache.NewResourceCacheMap() - taskContext := taskrunner.NewTaskContext(eventChannel, resourceCache) - // Register invalid objects to be retained in the inventory, if present. for _, id := range vCollector.InvalidIds { taskContext.AddInvalidObject(id) diff --git a/pkg/apply/filter/dependency-filter.go b/pkg/apply/filter/dependency-filter.go new file mode 100644 index 00000000..f826b77d --- /dev/null +++ b/pkg/apply/filter/dependency-filter.go @@ -0,0 +1,152 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package filter + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cli-utils/pkg/apis/actuation" + "sigs.k8s.io/cli-utils/pkg/apply/taskrunner" + "sigs.k8s.io/cli-utils/pkg/object" +) + +//go:generate stringer -type=Relationship -linecomment +type Relationship int + +const ( + RelationshipDependent Relationship = iota // Dependent + RelationshipDependency // Dependency +) + +// DependencyFilter implements ValidationFilter interface to determine if an +// object can be applied or deleted based on the status of it's dependencies. +type DependencyFilter struct { + TaskContext *taskrunner.TaskContext + Strategy actuation.ActuationStrategy +} + +const DependencyFilterName = "DependencyFilter" + +// Name returns the name of the filter for logs and events. +func (dnrf DependencyFilter) Name() string { + return DependencyFilterName +} + +// Filter returns true if the specified object should be skipped because at +// least one of its dependencies is Not Found or Not Reconciled. +func (dnrf DependencyFilter) Filter(obj *unstructured.Unstructured) (bool, string, error) { + id := object.UnstructuredToObjMetadata(obj) + + switch dnrf.Strategy { + case actuation.ActuationStrategyApply: + // For apply, check dependencies (outgoing) + for _, depID := range dnrf.TaskContext.Graph().Dependencies(id) { + skip, reason, err := dnrf.filterByRelationStatus(depID, RelationshipDependency) + if err != nil { + return false, "", err + } + if skip { + return skip, reason, nil + } + } + case actuation.ActuationStrategyDelete: + // For delete, check dependents (incoming) + for _, depID := range dnrf.TaskContext.Graph().Dependents(id) { + skip, reason, err := dnrf.filterByRelationStatus(depID, RelationshipDependent) + if err != nil { + return false, "", err + } + if skip { + return skip, reason, nil + } + } + default: + panic(fmt.Sprintf("invalid filter strategy: %q", dnrf.Strategy)) + } + return false, "", nil +} + +func (dnrf DependencyFilter) filterByRelationStatus(id object.ObjMetadata, relationship Relationship) (bool, string, error) { + // Dependency on an invalid object is considered an invalid dependency, making both objects invalid. + // For applies: don't prematurely apply something that depends on something that hasn't been applied (because invalid). + // For deletes: don't prematurely delete something that depends on something that hasn't been deleted (because invalid). + // These can't be caught be subsequent checks, because invalid objects aren't in the inventory. + if dnrf.TaskContext.IsInvalidObject(id) { + // Skip! + return true, fmt.Sprintf("%s invalid: %q", + strings.ToLower(relationship.String()), + id), nil + } + + status, found := dnrf.TaskContext.InventoryManager().ObjectStatus(id) + if !found { + // Status is registered during planning. + // So if status is not found, the object is external (NYI) or invalid. + return false, "", fmt.Errorf("unknown %s actuation strategy: %v", + strings.ToLower(relationship.String()), id) + } + + // Dependencies must have the same actuation strategy. + // If there is a mismatch, skip both. + if status.Strategy != dnrf.Strategy { + return true, fmt.Sprintf("%s skipped because %s is scheduled for %s: %q", + strings.ToLower(dnrf.Strategy.String()), + strings.ToLower(relationship.String()), + strings.ToLower(status.Strategy.String()), + id), nil + } + + switch status.Actuation { + case actuation.ActuationPending: + // If actuation is still pending, dependency sorting is probably broken. + return false, "", fmt.Errorf("premature %s: %s %s actuation %s: %q", + strings.ToLower(dnrf.Strategy.String()), + strings.ToLower(relationship.String()), + strings.ToLower(status.Strategy.String()), + strings.ToLower(status.Actuation.String()), + id) + case actuation.ActuationSkipped, actuation.ActuationFailed: + // Skip! + return true, fmt.Sprintf("%s %s actuation %s: %q", + strings.ToLower(relationship.String()), + strings.ToLower(dnrf.Strategy.String()), + strings.ToLower(status.Actuation.String()), + id), nil + case actuation.ActuationSucceeded: + // Don't skip! + default: + return false, "", fmt.Errorf("invalid %s apply status %q: %q", + strings.ToLower(relationship.String()), + strings.ToLower(status.Actuation.String()), + id) + } + + switch status.Reconcile { + case actuation.ReconcilePending: + // If reconcile is still pending, dependency sorting is probably broken. + return false, "", fmt.Errorf("premature %s: %s %s reconcile %s: %q", + strings.ToLower(dnrf.Strategy.String()), + strings.ToLower(relationship.String()), + strings.ToLower(status.Strategy.String()), + strings.ToLower(status.Reconcile.String()), + id) + case actuation.ReconcileSkipped, actuation.ReconcileFailed, actuation.ReconcileTimeout: + // Skip! + return true, fmt.Sprintf("%s %s reconcile %s: %q", + strings.ToLower(relationship.String()), + strings.ToLower(dnrf.Strategy.String()), + strings.ToLower(status.Reconcile.String()), + id), nil + case actuation.ReconcileSucceeded: + // Don't skip! + default: + return false, "", fmt.Errorf("invalid dependency reconcile status %q: %q", + strings.ToLower(status.Reconcile.String()), id) + } + + // Don't skip! + return false, "", nil +} diff --git a/pkg/apply/filter/dependency-filter_test.go b/pkg/apply/filter/dependency-filter_test.go new file mode 100644 index 00000000..bb9ff927 --- /dev/null +++ b/pkg/apply/filter/dependency-filter_test.go @@ -0,0 +1,451 @@ +// Copyright 2019 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package filter + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/cli-utils/pkg/apis/actuation" + "sigs.k8s.io/cli-utils/pkg/apply/taskrunner" + "sigs.k8s.io/cli-utils/pkg/inventory" + "sigs.k8s.io/cli-utils/pkg/object" +) + +var idInvalid = object.ObjMetadata{ + GroupKind: schema.GroupKind{ + Kind: "", // required + }, + Name: "invalid", // required +} + +var idA = object.ObjMetadata{ + GroupKind: schema.GroupKind{ + Group: "group-a", + Kind: "kind-a", + }, + Name: "name-a", + Namespace: "namespace-a", +} + +var idB = object.ObjMetadata{ + GroupKind: schema.GroupKind{ + Group: "group-b", + Kind: "kind-b", + }, + Name: "name-b", + Namespace: "namespace-b", +} + +func TestDependencyFilter(t *testing.T) { + tests := map[string]struct { + strategy actuation.ActuationStrategy + contextSetup func(*taskrunner.TaskContext) + id object.ObjMetadata + expectedFiltered bool + expectedReason string + expectedError error + }{ + "apply A (no deps)": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.InventoryManager().AddPendingApply(idA) + }, + id: idA, + expectedFiltered: false, + expectedReason: "", + expectedError: nil, + }, + "apply A (A -> B) when B is invalid": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idInvalid) + taskContext.Graph().AddEdge(idA, idInvalid) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.AddInvalidObject(idInvalid) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency invalid: %q", idInvalid), + expectedError: nil, + }, + "apply A (A -> B) before B is applied": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().AddPendingApply(idB) + }, + id: idA, + expectedError: fmt.Errorf("premature apply: dependency apply actuation pending: %q", idB), + }, + "apply A (A -> B) before B is reconciled": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcilePending, + }) + }, + id: idA, + expectedError: fmt.Errorf("premature apply: dependency apply reconcile pending: %q", idB), + }, + "apply A (A -> B) after B is reconciled": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileSucceeded, + }) + }, + id: idA, + expectedFiltered: false, + expectedReason: "", + expectedError: nil, + }, + "apply A (A -> B) after B apply failed": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationFailed, + Reconcile: actuation.ReconcilePending, + }) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency apply actuation failed: %q", idB), + expectedError: nil, + }, + "apply A (A -> B) after B apply skipped": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSkipped, + Reconcile: actuation.ReconcileSkipped, + }) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency apply actuation skipped: %q", idB), + expectedError: nil, + }, + "apply A (A -> B) after B reconcile failed": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileFailed, + }) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency apply reconcile failed: %q", idB), + expectedError: nil, + }, + "apply A (A -> B) after B reconcile timeout": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileTimeout, + }) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency apply reconcile timeout: %q", idB), + expectedError: nil, + }, + // artificial use case: reconcile should only be skipped if apply failed or was skipped + "apply A (A -> B) after B reconcile skipped": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idB), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileSkipped, + }) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependency apply reconcile skipped: %q", idB), + expectedError: nil, + }, + "apply A (A -> B) when B delete pending": { + strategy: actuation.ActuationStrategyApply, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingApply(idA) + taskContext.InventoryManager().AddPendingDelete(idB) + }, + id: idA, + expectedFiltered: true, + expectedReason: fmt.Sprintf("apply skipped because dependency is scheduled for delete: %q", idB), + expectedError: nil, + }, + "delete B (no deps)": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idB) + taskContext.InventoryManager().AddPendingDelete(idB) + }, + id: idB, + expectedFiltered: false, + expectedReason: "", + expectedError: nil, + }, + "delete B (A -> B) when A is invalid": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idInvalid) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idInvalid, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.AddInvalidObject(idInvalid) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent invalid: %q", idInvalid), + expectedError: nil, + }, + "delete B (A -> B) before A is deleted": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().AddPendingDelete(idA) + }, + id: idB, + expectedError: fmt.Errorf("premature delete: dependent delete actuation pending: %q", idA), + }, + "delete B (A -> B) before A is reconciled": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcilePending, + }) + }, + id: idB, + expectedError: fmt.Errorf("premature delete: dependent delete reconcile pending: %q", idA), + }, + "delete B (A -> B) after A is reconciled": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileSucceeded, + }) + }, + id: idB, + expectedFiltered: false, + expectedReason: "", + expectedError: nil, + }, + "delete B (A -> B) after A delete failed": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationFailed, + Reconcile: actuation.ReconcilePending, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent delete actuation failed: %q", idA), + expectedError: nil, + }, + "delete B (A -> B) after A delete skipped": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSkipped, + Reconcile: actuation.ReconcileSkipped, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent delete actuation skipped: %q", idA), + expectedError: nil, + }, + // artificial use case: delete reconcile can't fail, only timeout + "delete B (A -> B) after A reconcile failed": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileFailed, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent delete reconcile failed: %q", idA), + expectedError: nil, + }, + "delete B (A -> B) after A reconcile timeout": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileTimeout, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent delete reconcile timeout: %q", idA), + expectedError: nil, + }, + // artificial use case: reconcile should only be skipped if delete failed or was skipped + "delete B (A -> B) after A reconcile skipped": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileSkipped, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("dependent delete reconcile skipped: %q", idA), + expectedError: nil, + }, + "delete B (A -> B) when A apply succeeded": { + strategy: actuation.ActuationStrategyDelete, + contextSetup: func(taskContext *taskrunner.TaskContext) { + taskContext.Graph().AddVertex(idA) + taskContext.Graph().AddVertex(idB) + taskContext.Graph().AddEdge(idA, idB) + taskContext.InventoryManager().AddPendingDelete(idB) + taskContext.InventoryManager().SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: inventory.ObjectReferenceFromObjMetadata(idA), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationSucceeded, + Reconcile: actuation.ReconcileSucceeded, + }) + }, + id: idB, + expectedFiltered: true, + expectedReason: fmt.Sprintf("delete skipped because dependent is scheduled for apply: %q", idA), + expectedError: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + taskContext := taskrunner.NewTaskContext(nil, nil) + tc.contextSetup(taskContext) + + filter := DependencyFilter{ + TaskContext: taskContext, + Strategy: tc.strategy, + } + obj := defaultObj.DeepCopy() + obj.SetGroupVersionKind(tc.id.GroupKind.WithVersion("v1")) + obj.SetName(tc.id.Name) + obj.SetNamespace(tc.id.Namespace) + + filtered, reason, err := filter.Filter(obj) + if tc.expectedError != nil { + require.EqualError(t, err, tc.expectedError.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedFiltered, filtered) + assert.Equal(t, tc.expectedReason, reason) + }) + } +} diff --git a/pkg/apply/filter/relationship_string.go b/pkg/apply/filter/relationship_string.go new file mode 100644 index 00000000..0d66ff30 --- /dev/null +++ b/pkg/apply/filter/relationship_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type=Relationship -linecomment"; DO NOT EDIT. + +package filter + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[RelationshipDependent-0] + _ = x[RelationshipDependency-1] +} + +const _Relationship_name = "DependentDependency" + +var _Relationship_index = [...]uint8{0, 9, 19} + +func (i Relationship) String() string { + if i < 0 || i >= Relationship(len(_Relationship_index)-1) { + return "Relationship(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Relationship_name[_Relationship_index[i]:_Relationship_index[i+1]] +} diff --git a/pkg/apply/solver/solver.go b/pkg/apply/solver/solver.go index 41cee37b..92fc0a25 100644 --- a/pkg/apply/solver/solver.go +++ b/pkg/apply/solver/solver.go @@ -119,7 +119,7 @@ func (t *TaskQueueBuilder) WithPruneObjects(pruneObjs object.UnstructuredSet) *T } // Build returns the queue of tasks that have been created -func (t *TaskQueueBuilder) Build(o Options) *TaskQueue { +func (t *TaskQueueBuilder) Build(taskContext *taskrunner.TaskContext, o Options) *TaskQueue { var tasks []taskrunner.Task // reset counters @@ -127,11 +127,40 @@ func (t *TaskQueueBuilder) Build(o Options) *TaskQueue { t.pruneCounter = 0 t.waitCounter = 0 + // Filter objects that failed earlier validation applyObjs := t.Collector.FilterInvalidObjects(t.applyObjs) pruneObjs := t.Collector.FilterInvalidObjects(t.pruneObjs) + // Merge applyObjs & pruneObjs and graph them together. + // This detects implicit and explicit dependencies. + // Invalid dependency annotations will be treated as validation errors. + allObjs := make(object.UnstructuredSet, 0, len(applyObjs)+len(pruneObjs)) + allObjs = append(allObjs, applyObjs...) + allObjs = append(allObjs, pruneObjs...) + g, err := graph.DependencyGraph(allObjs) + if err != nil { + t.Collector.Collect(err) + } + // Store graph for use by DependencyFilter + taskContext.SetGraph(g) + // Sort objects into phases (apply order). + // Cycles will be treated as validation errors. + idSetList, err := g.Sort() + if err != nil { + t.Collector.Collect(err) + } + + // Filter objects with cycles or invalid dependency annotations + applyObjs = t.Collector.FilterInvalidObjects(applyObjs) + pruneObjs = t.Collector.FilterInvalidObjects(pruneObjs) + if len(applyObjs) > 0 { - klog.V(2).Infoln("adding inventory add task (%d objects)", len(applyObjs)) + // Register actuation plan in the inventory + for _, id := range object.UnstructuredSetToObjMetadataSet(applyObjs) { + taskContext.InventoryManager().AddPendingApply(id) + } + + klog.V(2).Infof("adding inventory add task (%d objects)", len(applyObjs)) tasks = append(tasks, &task.InvAddTask{ TaskName: "inventory-add-0", InvClient: t.InvClient, @@ -140,18 +169,10 @@ func (t *TaskQueueBuilder) Build(o Options) *TaskQueue { DryRun: o.DryRunStrategy, }) - // Create a dependency graph, sort, and flatten into phases. - applySets, err := graph.SortObjs(applyObjs) - if err != nil { - t.Collector.Collect(err) - } + // Filter idSetList down to just apply objects + applySets := graph.HydrateSetList(idSetList, applyObjs) for _, applySet := range applySets { - // filter again, because sorting may have added more invalid objects. - applySet = t.Collector.FilterInvalidObjects(applySet) - if len(applySet) == 0 { - continue - } tasks = append(tasks, t.newApplyTask(applySet, t.ApplyFilters, t.ApplyMutators, o)) // dry-run skips wait tasks @@ -164,18 +185,18 @@ func (t *TaskQueueBuilder) Build(o Options) *TaskQueue { } if o.Prune && len(pruneObjs) > 0 { - // Create a dependency graph, sort (in reverse), and flatten into phases. - pruneSets, err := graph.ReverseSortObjs(pruneObjs) - if err != nil { - t.Collector.Collect(err) + // Register actuation plan in the inventory + for _, id := range object.UnstructuredSetToObjMetadataSet(pruneObjs) { + taskContext.InventoryManager().AddPendingDelete(id) } + // Filter idSetList down to just prune objects + pruneSets := graph.HydrateSetList(idSetList, pruneObjs) + + // Reverse apply order to get prune order + graph.ReverseSetList(pruneSets) + for _, pruneSet := range pruneSets { - // filter again, because sorting may have added more invalid objects. - pruneSet = t.Collector.FilterInvalidObjects(pruneSet) - if len(pruneSet) == 0 { - continue - } tasks = append(tasks, t.newPruneTask(pruneSet, t.PruneFilters, o)) // dry-run skips wait tasks diff --git a/pkg/apply/solver/solver_test.go b/pkg/apply/solver/solver_test.go index 54a14baf..0118e6ca 100644 --- a/pkg/apply/solver/solver_test.go +++ b/pkg/apply/solver/solver_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cli-utils/pkg/apis/actuation" "sigs.k8s.io/cli-utils/pkg/apply/prune" "sigs.k8s.io/cli-utils/pkg/apply/task" "sigs.k8s.io/cli-utils/pkg/apply/taskrunner" @@ -133,10 +134,11 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { "abc-123", "default", "test")) testCases := map[string]struct { - applyObjs []*unstructured.Unstructured - options Options - expectedTasks []taskrunner.Task - expectedError error + applyObjs []*unstructured.Unstructured + options Options + expectedTasks []taskrunner.Task + expectedError error + expectedStatus []actuation.ObjectStatus }{ "no resources, no apply or wait tasks": { applyObjs: []*unstructured.Unstructured{}, @@ -184,6 +186,16 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resource with no timeout": { applyObjs: []*unstructured.Unstructured{ @@ -226,6 +238,24 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources with reconcile timeout": { applyObjs: []*unstructured.Unstructured{ @@ -272,6 +302,24 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources with reconcile timeout and dryrun": { applyObjs: []*unstructured.Unstructured{ @@ -313,6 +361,24 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { DryRun: common.DryRunClient, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources with reconcile timeout and server-dryrun": { applyObjs: []*unstructured.Unstructured{ @@ -354,6 +420,24 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { DryRun: common.DryRunServer, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["default-pod"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources including CRD": { applyObjs: []*unstructured.Unstructured{ @@ -413,6 +497,32 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab1"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crd"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab2"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "no wait with CRDs if it is a dryrun": { applyObjs: []*unstructured.Unstructured{ @@ -463,6 +573,32 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { DryRun: common.DryRunClient, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab1"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crd"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab2"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "resources in namespace creates multiple apply tasks": { applyObjs: []*unstructured.Unstructured{ @@ -522,6 +658,32 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["namespace"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "deployment depends on secret creates multiple tasks": { applyObjs: []*unstructured.Unstructured{ @@ -579,6 +741,24 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "cyclic dependency returns error": { applyObjs: []*unstructured.Unstructured{ @@ -629,9 +809,10 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { InvClient: fakeInvClient, Collector: vCollector, } + taskContext := taskrunner.NewTaskContext(nil, nil) tq := tqb.WithInventory(invInfo). WithApplyObjects(tc.applyObjs). - Build(tc.options) + Build(taskContext, tc.options) err := vCollector.ToError() if tc.expectedError != nil { assert.EqualError(t, err, tc.expectedError.Error()) @@ -639,6 +820,9 @@ func TestTaskQueueBuilder_ApplyBuild(t *testing.T) { } assert.NoError(t, err) asserter.Equal(t, tc.expectedTasks, tq.tasks) + + actualStatus := taskContext.InventoryManager().Inventory().Status.Objects + testutil.AssertEqual(t, tc.expectedStatus, actualStatus) }) } } @@ -656,10 +840,11 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { "abc-123", "default", "test")) testCases := map[string]struct { - pruneObjs []*unstructured.Unstructured - options Options - expectedTasks []taskrunner.Task - expectedError error + pruneObjs []*unstructured.Unstructured + options Options + expectedTasks []taskrunner.Task + expectedError error + expectedStatus []actuation.ObjectStatus }{ "no resources, no apply or prune tasks": { pruneObjs: []*unstructured.Unstructured{}, @@ -701,6 +886,16 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["default-pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources, one prune task, one wait task": { pruneObjs: []*unstructured.Unstructured{ @@ -734,6 +929,24 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["default-pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "dependent resources, two prune tasks, two wait tasks": { pruneObjs: []*unstructured.Unstructured{ @@ -781,6 +994,24 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "single resource with prune timeout has wait task": { pruneObjs: []*unstructured.Unstructured{ @@ -814,6 +1045,16 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources with prune timeout and server-dryrun": { pruneObjs: []*unstructured.Unstructured{ @@ -846,6 +1087,24 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { DryRun: common.DryRunServer, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["default-pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "multiple resources including CRD": { pruneObjs: []*unstructured.Unstructured{ @@ -895,6 +1154,32 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab1"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crd"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab2"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "no wait with CRDs if it is a dryrun": { pruneObjs: []*unstructured.Unstructured{ @@ -935,6 +1220,32 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { DryRun: common.DryRunClient, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab1"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crd"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["crontab2"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "resources in namespace creates multiple apply tasks": { pruneObjs: []*unstructured.Unstructured{ @@ -983,6 +1294,32 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { }, }, }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["namespace"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["pod"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, }, "cyclic dependency": { pruneObjs: []*unstructured.Unstructured{ @@ -1084,16 +1421,376 @@ func TestTaskQueueBuilder_PruneBuild(t *testing.T) { InvClient: fakeInvClient, Collector: vCollector, } + taskContext := taskrunner.NewTaskContext(nil, nil) + tq := tqb.WithInventory(invInfo). + WithPruneObjects(tc.pruneObjs). + Build(taskContext, tc.options) + err := vCollector.ToError() + if tc.expectedError != nil { + assert.EqualError(t, err, tc.expectedError.Error()) + return + } + assert.NoError(t, err) + asserter.Equal(t, tc.expectedTasks, tq.tasks) + + actualStatus := taskContext.InventoryManager().Inventory().Status.Objects + testutil.AssertEqual(t, tc.expectedStatus, actualStatus) + }) + } +} + +func TestTaskQueueBuilder_ApplyPruneBuild(t *testing.T) { + // Use a custom Asserter to customize the comparison options + asserter := testutil.NewAsserter( + cmpopts.EquateErrors(), + waitTaskComparer(), + fakeClientComparer(), + inventoryInfoComparer(), + ) + + invInfo := inventory.WrapInventoryInfoObj(newInvObject( + "abc-123", "default", "test")) + + testCases := map[string]struct { + inventoryIDs object.ObjMetadataSet + applyObjs object.UnstructuredSet + pruneObjs object.UnstructuredSet + options Options + expectedTasks []taskrunner.Task + expectedError error + expectedStatus []actuation.ObjectStatus + }{ + "two resources, one apply, one prune": { + inventoryIDs: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + applyObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + pruneObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["secret"]), + }, + options: Options{Prune: true}, + expectedTasks: []taskrunner.Task{ + &task.InvAddTask{ + TaskName: "inventory-add-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + Objects: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &task.ApplyTask{ + TaskName: "apply-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-0", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]), + }, + Condition: taskrunner.AllCurrent, + }, + &task.PruneTask{ + TaskName: "prune-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["secret"]), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-1", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + Condition: taskrunner.AllNotFound, + }, + &task.InvSetTask{ + TaskName: "inventory-set-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + PrevInventory: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, + }, + "prune disabled": { + inventoryIDs: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + applyObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + pruneObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["secret"]), + }, + options: Options{Prune: false}, + expectedTasks: []taskrunner.Task{ + &task.InvAddTask{ + TaskName: "inventory-add-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + Objects: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &task.ApplyTask{ + TaskName: "apply-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-0", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]), + }, + Condition: taskrunner.AllCurrent, + }, + &task.InvSetTask{ + TaskName: "inventory-set-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + PrevInventory: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, + }, + // This use case returns in a task plan that would cause a dependency + // to be deleted. This is remediated by the DependencyFilter at + // apply-time, by skipping both the apply and prune. + // This test does not verify the DependencyFilter tho, just that the + // dependency was discovered between apply & prune objects. + "dependency: apply -> prune": { + inventoryIDs: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + applyObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + }, + pruneObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["secret"]), + }, + options: Options{Prune: true}, + expectedTasks: []taskrunner.Task{ + &task.InvAddTask{ + TaskName: "inventory-add-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + Objects: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + }, + }, + &task.ApplyTask{ + TaskName: "apply-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-0", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]), + }, + Condition: taskrunner.AllCurrent, + }, + &task.PruneTask{ + TaskName: "prune-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["secret"]), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-1", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + Condition: taskrunner.AllNotFound, + }, + &task.InvSetTask{ + TaskName: "inventory-set-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + PrevInventory: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, + }, + // This use case returns in a task plan that would cause a dependency + // to be applied. This is fine. + // This test just verifies that the dependency was discovered between + // prune & apply objects. + "dependency: prune -> apply": { + inventoryIDs: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + applyObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + pruneObjs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["deployment"]))), + }, + options: Options{Prune: true}, + expectedTasks: []taskrunner.Task{ + &task.InvAddTask{ + TaskName: "inventory-add-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + Objects: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &task.ApplyTask{ + TaskName: "apply-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["deployment"]), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-0", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]), + }, + Condition: taskrunner.AllCurrent, + }, + &task.PruneTask{ + TaskName: "prune-0", + Objects: []*unstructured.Unstructured{ + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["deployment"]))), + }, + }, + &taskrunner.WaitTask{ + TaskName: "wait-1", + Ids: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + Condition: taskrunner.AllNotFound, + }, + &task.InvSetTask{ + TaskName: "inventory-set-0", + InvClient: &inventory.FakeClient{}, + InvInfo: invInfo, + PrevInventory: object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + expectedStatus: []actuation.ObjectStatus{ + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["deployment"]), + ), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + { + ObjectReference: inventory.ObjectReferenceFromObjMetadata( + testutil.ToIdentifier(t, resources["secret"]), + ), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + mapper := testutil.NewFakeRESTMapper() + // inject mapper & pruner for equality comparison + for _, t := range tc.expectedTasks { + switch typedTask := t.(type) { + case *task.ApplyTask: + typedTask.Mapper = mapper + case *task.PruneTask: + typedTask.Pruner = &prune.Pruner{} + case *taskrunner.WaitTask: + typedTask.Mapper = mapper + } + } + + fakeInvClient := inventory.NewFakeClient(tc.inventoryIDs) + vCollector := &validation.Collector{} + tqb := TaskQueueBuilder{ + Pruner: pruner, + Mapper: mapper, + InvClient: fakeInvClient, + Collector: vCollector, + } + taskContext := taskrunner.NewTaskContext(nil, nil) tq := tqb.WithInventory(invInfo). + WithApplyObjects(tc.applyObjs). WithPruneObjects(tc.pruneObjs). - Build(tc.options) + Build(taskContext, tc.options) + err := vCollector.ToError() if tc.expectedError != nil { assert.EqualError(t, err, tc.expectedError.Error()) return } assert.NoError(t, err) + asserter.Equal(t, tc.expectedTasks, tq.tasks) + + actualStatus := taskContext.InventoryManager().Inventory().Status.Objects + testutil.AssertEqual(t, tc.expectedStatus, actualStatus) }) } } diff --git a/pkg/apply/taskrunner/context.go b/pkg/apply/taskrunner/context.go index 33e419f8..f8763e24 100644 --- a/pkg/apply/taskrunner/context.go +++ b/pkg/apply/taskrunner/context.go @@ -9,6 +9,7 @@ import ( "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/inventory" "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/object/graph" ) // NewTaskContext returns a new TaskContext @@ -20,6 +21,7 @@ func NewTaskContext(eventChannel chan event.Event, resourceCache cache.ResourceC inventoryManager: inventory.NewManager(), abandonedObjects: make(map[object.ObjMetadata]struct{}), invalidObjects: make(map[object.ObjMetadata]struct{}), + graph: graph.New(), } } @@ -32,6 +34,7 @@ type TaskContext struct { inventoryManager *inventory.Manager abandonedObjects map[object.ObjMetadata]struct{} invalidObjects map[object.ObjMetadata]struct{} + graph *graph.Graph } func (tc *TaskContext) TaskChannel() chan TaskResult { @@ -50,6 +53,14 @@ func (tc *TaskContext) InventoryManager() *inventory.Manager { return tc.inventoryManager } +func (tc *TaskContext) Graph() *graph.Graph { + return tc.graph +} + +func (tc *TaskContext) SetGraph(g *graph.Graph) { + tc.graph = g +} + // SendEvent sends an event on the event channel func (tc *TaskContext) SendEvent(e event.Event) { klog.V(5).Infof("sending event: %s", e) diff --git a/pkg/inventory/manager.go b/pkg/inventory/manager.go index 1b44bbe5..6083b4fd 100644 --- a/pkg/inventory/manager.go +++ b/pkg/inventory/manager.go @@ -393,3 +393,55 @@ func (tc *Manager) SetPendingReconcile(id object.ObjMetadata) error { func (tc *Manager) PendingReconciles() object.ObjMetadataSet { return tc.ObjectsWithReconcileStatus(actuation.ReconcilePending) } + +// IsPendingApply returns true if the object pending apply +func (tc *Manager) IsPendingApply(id object.ObjMetadata) bool { + objStatus, found := tc.ObjectStatus(id) + if !found { + return false + } + return objStatus.Strategy == actuation.ActuationStrategyApply && + objStatus.Actuation == actuation.ActuationPending +} + +// AddPendingApply registers that the object is pending apply +func (tc *Manager) AddPendingApply(id object.ObjMetadata) { + tc.SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: ObjectReferenceFromObjMetadata(id), + Strategy: actuation.ActuationStrategyApply, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }) +} + +// PendingApplies returns all the objects that are pending apply +func (tc *Manager) PendingApplies() object.ObjMetadataSet { + return tc.ObjectsWithActuationStatus(actuation.ActuationStrategyApply, + actuation.ActuationPending) +} + +// IsPendingDelete returns true if the object pending delete +func (tc *Manager) IsPendingDelete(id object.ObjMetadata) bool { + objStatus, found := tc.ObjectStatus(id) + if !found { + return false + } + return objStatus.Strategy == actuation.ActuationStrategyDelete && + objStatus.Actuation == actuation.ActuationPending +} + +// AddPendingDelete registers that the object is pending delete +func (tc *Manager) AddPendingDelete(id object.ObjMetadata) { + tc.SetObjectStatus(actuation.ObjectStatus{ + ObjectReference: ObjectReferenceFromObjMetadata(id), + Strategy: actuation.ActuationStrategyDelete, + Actuation: actuation.ActuationPending, + Reconcile: actuation.ReconcilePending, + }) +} + +// PendingDeletes returns all the objects that are pending delete +func (tc *Manager) PendingDeletes() object.ObjMetadataSet { + return tc.ObjectsWithActuationStatus(actuation.ActuationStrategyDelete, + actuation.ActuationPending) +} diff --git a/pkg/object/graph/depends.go b/pkg/object/graph/depends.go index 1557a7fe..8e38b7b7 100644 --- a/pkg/object/graph/depends.go +++ b/pkg/object/graph/depends.go @@ -19,25 +19,19 @@ import ( "sigs.k8s.io/cli-utils/pkg/ordering" ) -// 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 object.UnstructuredSet) ([]object.UnstructuredSet, error) { - var objSets []object.UnstructuredSet +// DependencyGraph returns a new graph, populated with the supplied objects as +// vetices and edges built from their dependencies. +func DependencyGraph(objs object.UnstructuredSet) (*Graph, error) { + g := New() if len(objs) == 0 { - return objSets, nil + return g, nil } var errors []error + // Convert to IDs (same length & order as objs) + // This is simply an optimiation to avoid repeating obj -> id conversion. ids := object.UnstructuredSetToObjMetadataSet(objs) - // Create the graph, and build a map of object metadata to the object (Unstructured). - g := New() - objToUnstructured := map[object.ObjMetadata]*unstructured.Unstructured{} - for i, obj := range objs { - id := ids[i] - objToUnstructured[id] = obj - } + // Add objects as graph vertices addVertices(g, ids) // Add dependencies as graph edges @@ -49,14 +43,29 @@ func SortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, error) { if err := addApplyTimeMutationEdges(g, objs, ids); err != nil { errors = append(errors, err) } - // Run topological sort on the graph. - sortedObjSets, err := g.Sort() - if err != nil { - errors = append(errors, err) + if len(errors) > 0 { + return g, multierror.Wrap(errors...) + } + return g, nil +} + +// HydrateSetList takes a list of sets of ids and a set of objects and returns +// a list of set of objects. The output set list will be the same order as the +// input set list, but with IDs converted into Objects. Any IDs that do not +// match objects in the provided object set will be skipped (filtered) in the +// output. +func HydrateSetList(idSetList []object.ObjMetadataSet, objs object.UnstructuredSet) []object.UnstructuredSet { + var objSetList []object.UnstructuredSet + + // Build a map of id -> obj. + objToUnstructured := map[object.ObjMetadata]*unstructured.Unstructured{} + for _, obj := range objs { + objToUnstructured[object.UnstructuredToObjMetadata(obj)] = obj } + // Map the object metadata back to the sorted sets of unstructured objects. // Ignore any edges that aren't part of the input set (don't wait for them). - for _, objSet := range sortedObjSets { + for _, objSet := range idSetList { currentSet := object.UnstructuredSet{} for _, id := range objSet { var found bool @@ -65,14 +74,43 @@ func SortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, error) { currentSet = append(currentSet, obj) } } - // Sort each set in apply order - sort.Sort(ordering.SortableUnstructureds(currentSet)) - objSets = append(objSets, currentSet) + if len(currentSet) > 0 { + // Sort each set in apply order + sort.Sort(ordering.SortableUnstructureds(currentSet)) + objSetList = append(objSetList, currentSet) + } } + + return objSetList +} + +// 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 object.UnstructuredSet) ([]object.UnstructuredSet, error) { + var errors []error + if len(objs) == 0 { + return nil, nil + } + + g, err := DependencyGraph(objs) + if err != nil { + // collect and continue + errors = multierror.Unwrap(err) + } + + idSetList, err := g.Sort() + if err != nil { + errors = append(errors, err) + } + + objSetList := HydrateSetList(idSetList, objs) + if len(errors) > 0 { - return objSets, multierror.Wrap(errors...) + return objSetList, multierror.Wrap(errors...) } - return objSets, nil + return objSetList, nil } // ReverseSortObjs is the same as SortObjs but using reverse ordering. @@ -82,17 +120,22 @@ func ReverseSortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, err if err != nil { return s, err } + ReverseSetList(s) + return s, nil +} + +// ReverseSetList deep reverses of a list of object lists +func ReverseSetList(setList []object.UnstructuredSet) { // 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] + for i, j := 0, len(setList)-1; i < j; i, j = i+1, j-1 { + setList[i], setList[j] = setList[j], setList[i] } // Reverse the ordering of the objects in each set using swaps. - for _, c := range s { - for i, j := 0, len(c)-1; i < j; i, j = i+1, j-1 { - c[i], c[j] = c[j], c[i] + for _, set := range setList { + for i, j := 0, len(set)-1; i < j; i, j = i+1, j-1 { + set[i], set[j] = set[j], set[i] } } - return s, nil } // addVertices adds all the IDs in the set as graph vertices. diff --git a/pkg/object/graph/depends_test.go b/pkg/object/graph/depends_test.go index 2c5a7b3e..3cdc197a 100644 --- a/pkg/object/graph/depends_test.go +++ b/pkg/object/graph/depends_test.go @@ -7,7 +7,10 @@ import ( "errors" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/cli-utils/pkg/multierror" @@ -374,6 +377,495 @@ func TestReverseSortObjs(t *testing.T) { } } +func TestDependencyGraph(t *testing.T) { + // Use a custom Asserter to customize the graph options + asserter := testutil.NewAsserter( + cmpopts.EquateErrors(), + graphComparer(), + ) + + testCases := map[string]struct { + objs object.UnstructuredSet + graph *Graph + expectedError error + }{ + "no objects": { + objs: object.UnstructuredSet{}, + graph: New(), + }, + "one object no dependencies": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): {}, + }, + }, + }, + "two unrelated objects": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): {}, + testutil.ToIdentifier(t, resources["secret"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): {}, + testutil.ToIdentifier(t, resources["secret"]): {}, + }, + }, + }, + "two objects one dependency": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): {}, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + }, + }, + "three objects two dependencies": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["pod"]))), + testutil.Unstructured(t, resources["pod"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["pod"]), + }, + testutil.ToIdentifier(t, resources["pod"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["pod"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + testutil.ToIdentifier(t, resources["deployment"]): {}, + }, + }, + }, + "three objects two dependencies on the same object": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["pod"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["pod"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + testutil.ToIdentifier(t, resources["pod"]), + }, + testutil.ToIdentifier(t, resources["pod"]): {}, + testutil.ToIdentifier(t, resources["deployment"]): {}, + }, + }, + }, + "two objects and their namespace": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["secret"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["namespace"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["namespace"]), + }, + testutil.ToIdentifier(t, resources["namespace"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["namespace"]): { + testutil.ToIdentifier(t, resources["secret"]), + testutil.ToIdentifier(t, resources["deployment"]), + }, + testutil.ToIdentifier(t, resources["secret"]): {}, + testutil.ToIdentifier(t, resources["deployment"]): {}, + }, + }, + }, + "two custom resources and their CRD": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["crd"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["crontab1"]): { + testutil.ToIdentifier(t, resources["crd"]), + }, + testutil.ToIdentifier(t, resources["crontab2"]): { + testutil.ToIdentifier(t, resources["crd"]), + }, + testutil.ToIdentifier(t, resources["crd"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["crd"]): { + testutil.ToIdentifier(t, resources["crontab1"]), + testutil.ToIdentifier(t, resources["crontab2"]), + }, + testutil.ToIdentifier(t, resources["crontab1"]): {}, + testutil.ToIdentifier(t, resources["crontab2"]): {}, + }, + }, + }, + "two custom resources with their CRD and namespace": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["crontab1"]): { + testutil.ToIdentifier(t, resources["crd"]), + testutil.ToIdentifier(t, resources["namespace"]), + }, + testutil.ToIdentifier(t, resources["crontab2"]): { + testutil.ToIdentifier(t, resources["crd"]), + testutil.ToIdentifier(t, resources["namespace"]), + }, + testutil.ToIdentifier(t, resources["crd"]): {}, + testutil.ToIdentifier(t, resources["namespace"]): {}, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["crd"]): { + testutil.ToIdentifier(t, resources["crontab1"]), + testutil.ToIdentifier(t, resources["crontab2"]), + }, + testutil.ToIdentifier(t, resources["namespace"]): { + testutil.ToIdentifier(t, resources["crontab1"]), + testutil.ToIdentifier(t, resources["crontab2"]), + }, + testutil.ToIdentifier(t, resources["crontab1"]): {}, + testutil.ToIdentifier(t, resources["crontab2"]): {}, + }, + }, + }, + "two object cyclic dependency": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["deployment"]))), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + }, + }, + }, + "three object cyclic dependency": { + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["secret"]))), + testutil.Unstructured(t, resources["secret"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["pod"]))), + testutil.Unstructured(t, resources["pod"], + testutil.AddDependsOn(t, testutil.ToIdentifier(t, resources["deployment"]))), + }, + graph: &Graph{ + edges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["pod"]), + }, + testutil.ToIdentifier(t, resources["pod"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + reverseEdges: map[object.ObjMetadata]object.ObjMetadataSet{ + testutil.ToIdentifier(t, resources["deployment"]): { + testutil.ToIdentifier(t, resources["pod"]), + }, + testutil.ToIdentifier(t, resources["pod"]): { + testutil.ToIdentifier(t, resources["secret"]), + }, + testutil.ToIdentifier(t, resources["secret"]): { + testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g, err := DependencyGraph(tc.objs) + if tc.expectedError != nil { + require.EqualError(t, err, tc.expectedError.Error()) + return + } + assert.NoError(t, err) + asserter.Equal(t, tc.graph, g) + }) + } +} + +func TestHydrateSetList(t *testing.T) { + testCases := map[string]struct { + idSetList []object.ObjMetadataSet + objs object.UnstructuredSet + expected []object.UnstructuredSet + }{ + "no object sets": { + idSetList: []object.ObjMetadataSet{}, + expected: nil, + }, + "one object set": { + idSetList: []object.ObjMetadataSet{ + { + testutil.ToIdentifier(t, resources["deployment"]), + }, + }, + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + }, + "two out of three": { + idSetList: []object.ObjMetadataSet{ + { + testutil.ToIdentifier(t, resources["deployment"]), + }, + { + testutil.ToIdentifier(t, resources["secret"]), + }, + { + testutil.ToIdentifier(t, resources["pod"]), + }, + }, + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["pod"]), + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["pod"]), + }, + }, + }, + "two uneven sets": { + idSetList: []object.ObjMetadataSet{ + { + testutil.ToIdentifier(t, resources["secret"]), + testutil.ToIdentifier(t, resources["deployment"]), + }, + { + testutil.ToIdentifier(t, resources["namespace"]), + }, + }, + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["pod"]), + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["namespace"]), + }, + }, + }, + "one of two sets": { + idSetList: []object.ObjMetadataSet{ + { + testutil.ToIdentifier(t, resources["namespace"]), + testutil.ToIdentifier(t, resources["crd"]), + }, + { + testutil.ToIdentifier(t, resources["crontab1"]), + testutil.ToIdentifier(t, resources["crontab2"]), + }, + }, + objs: object.UnstructuredSet{ + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + objSetList := HydrateSetList(tc.idSetList, tc.objs) + assert.Equal(t, tc.expected, objSetList) + }) + } +} + +func TestReverseSetList(t *testing.T) { + testCases := map[string]struct { + setList []object.UnstructuredSet + expected []object.UnstructuredSet + }{ + "no object sets": { + setList: []object.UnstructuredSet{}, + expected: []object.UnstructuredSet{}, + }, + "one object set": { + setList: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + }, + "three object sets": { + setList: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["pod"]), + }, + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["pod"]), + }, + { + testutil.Unstructured(t, resources["secret"]), + }, + { + testutil.Unstructured(t, resources["deployment"]), + }, + }, + }, + "two uneven sets": { + setList: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["secret"]), + testutil.Unstructured(t, resources["deployment"]), + }, + { + testutil.Unstructured(t, resources["namespace"]), + }, + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["namespace"]), + }, + { + testutil.Unstructured(t, resources["deployment"]), + testutil.Unstructured(t, resources["secret"]), + }, + }, + }, + "two even sets": { + setList: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["crontab1"]), + testutil.Unstructured(t, resources["crontab2"]), + }, + { + testutil.Unstructured(t, resources["crd"]), + testutil.Unstructured(t, resources["namespace"]), + }, + }, + expected: []object.UnstructuredSet{ + { + testutil.Unstructured(t, resources["namespace"]), + testutil.Unstructured(t, resources["crd"]), + }, + { + testutil.Unstructured(t, resources["crontab2"]), + testutil.Unstructured(t, resources["crontab1"]), + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + ReverseSetList(tc.setList) + assert.Equal(t, tc.expected, tc.setList) + }) + } +} + func TestApplyTimeMutationEdges(t *testing.T) { testCases := map[string]struct { objs []*unstructured.Unstructured @@ -646,7 +1138,7 @@ func TestApplyTimeMutationEdges(t *testing.T) { } else { assert.NoError(t, err) } - actual := g.GetEdges() + actual := edgeMapToList(g.edges) verifyEdges(t, tc.expected, actual) }) } @@ -939,7 +1431,7 @@ func TestAddDependsOnEdges(t *testing.T) { } else { assert.NoError(t, err) } - actual := g.GetEdges() + actual := edgeMapToList(g.edges) verifyEdges(t, tc.expected, actual) }) } @@ -1016,7 +1508,7 @@ func TestAddNamespaceEdges(t *testing.T) { g := New() ids := object.UnstructuredSetToObjMetadataSet(tc.objs) addNamespaceEdges(g, tc.objs, ids) - actual := g.GetEdges() + actual := edgeMapToList(g.edges) verifyEdges(t, tc.expected, actual) }) } @@ -1068,7 +1560,7 @@ func TestAddCRDEdges(t *testing.T) { g := New() ids := object.UnstructuredSetToObjMetadataSet(tc.objs) addCRDEdges(g, tc.objs, ids) - actual := g.GetEdges() + actual := edgeMapToList(g.edges) verifyEdges(t, tc.expected, actual) }) } @@ -1136,3 +1628,17 @@ func containsEdge(edges []Edge, edge Edge) bool { } return false } + +// waitTaskComparer allows comparion of WaitTasks, ignoring private fields. +func graphComparer() cmp.Option { + return cmp.Comparer(func(x, y *Graph) bool { + if x == nil { + return y == nil + } + if y == nil { + return false + } + return cmp.Equal(x.edges, y.edges) && + cmp.Equal(x.reverseEdges, y.reverseEdges) + }) +} diff --git a/pkg/object/graph/graph.go b/pkg/object/graph/graph.go index fd1989a5..d3c16a4f 100644 --- a/pkg/object/graph/graph.go +++ b/pkg/object/graph/graph.go @@ -20,12 +20,15 @@ import ( type Graph struct { // map "from" vertex -> list of "to" vertices edges map[object.ObjMetadata]object.ObjMetadataSet + // map "to" vertex -> list of "from" vertices + reverseEdges map[object.ObjMetadata]object.ObjMetadataSet } // New returns a pointer to an empty Graph data structure. func New() *Graph { g := &Graph{} g.edges = make(map[object.ObjMetadata]object.ObjMetadataSet) + g.reverseEdges = make(map[object.ObjMetadata]object.ObjMetadataSet) return g } @@ -35,13 +38,16 @@ func (g *Graph) AddVertex(v object.ObjMetadata) { if _, exists := g.edges[v]; !exists { g.edges[v] = object.ObjMetadataSet{} } + if _, exists := g.reverseEdges[v]; !exists { + g.reverseEdges[v] = object.ObjMetadataSet{} + } } -// GetVertices returns a sorted set of unique vertices in the graph. -func (g *Graph) GetVertices() object.ObjMetadataSet { - keys := make(object.ObjMetadataSet, len(g.edges)) +// edgeMapKeys returns a sorted set of unique vertices in the graph. +func edgeMapKeys(edgeMap map[object.ObjMetadata]object.ObjMetadataSet) object.ObjMetadataSet { + keys := make(object.ObjMetadataSet, len(edgeMap)) i := 0 - for k := range g.edges { + for k := range edgeMap { keys[i] = k i++ } @@ -56,21 +62,28 @@ func (g *Graph) AddEdge(from object.ObjMetadata, to object.ObjMetadata) { if _, exists := g.edges[from]; !exists { g.edges[from] = object.ObjMetadataSet{} } + if _, exists := g.reverseEdges[from]; !exists { + g.reverseEdges[from] = object.ObjMetadataSet{} + } // Add "to" vertex if it doesn't already exist. if _, exists := g.edges[to]; !exists { g.edges[to] = object.ObjMetadataSet{} } + if _, exists := g.reverseEdges[to]; !exists { + g.reverseEdges[to] = object.ObjMetadataSet{} + } // Add edge "from" -> "to" if it doesn't already exist // into the adjacency list. if !g.isAdjacent(from, to) { g.edges[from] = append(g.edges[from], to) + g.reverseEdges[to] = append(g.reverseEdges[to], from) } } -// GetEdges returns a sorted slice of directed graph edges (vertex pairs). -func (g *Graph) GetEdges() []Edge { +// edgeMapToList returns a sorted slice of directed graph edges (vertex pairs). +func edgeMapToList(edgeMap map[object.ObjMetadata]object.ObjMetadataSet) []Edge { edges := []Edge{} - for from, toList := range g.edges { + for from, toList := range edgeMap { for _, to := range toList { edge := Edge{From: from, To: to} edges = append(edges, edge) @@ -101,25 +114,53 @@ func (g *Graph) Size() int { return len(g.edges) } -// removeVertex removes the passed vertex as well as any edges -// into the vertex. -func (g *Graph) removeVertex(r object.ObjMetadata) { +// removeVertex removes the passed vertex as well as any edges into the vertex. +func removeVertex(edges map[object.ObjMetadata]object.ObjMetadataSet, r object.ObjMetadata) { // First, remove the object from all adjacency lists. - for v, adj := range g.edges { - g.edges[v] = adj.Remove(r) + for v, adj := range edges { + edges[v] = adj.Remove(r) } // Finally, remove the vertex - delete(g.edges, r) + delete(edges, r) +} + +// Dependencies returns the objects that this object depends on. +func (g *Graph) Dependencies(from object.ObjMetadata) object.ObjMetadataSet { + edgesFrom, exists := g.edges[from] + if !exists { + return nil + } + c := make(object.ObjMetadataSet, len(edgesFrom)) + copy(c, edgesFrom) + return c } -// Sort returns the ordered set of vertices after -// a topological sort. +// Dependents returns the objects that depend on this object. +func (g *Graph) Dependents(to object.ObjMetadata) object.ObjMetadataSet { + edgesTo, exists := g.reverseEdges[to] + if !exists { + return nil + } + c := make(object.ObjMetadataSet, len(edgesTo)) + copy(c, edgesTo) + return c +} + +// Sort returns the ordered set of vertices after a topological sort. func (g *Graph) Sort() ([]object.ObjMetadataSet, error) { + // deep copy edge map to avoid destructive sorting + edges := make(map[object.ObjMetadata]object.ObjMetadataSet, len(g.edges)) + for vertex, deps := range g.edges { + c := make(object.ObjMetadataSet, len(deps)) + copy(c, deps) + edges[vertex] = c + } + sorted := []object.ObjMetadataSet{} - for g.Size() > 0 { + for len(edges) > 0 { // Identify all the leaf vertices. leafVertices := object.ObjMetadataSet{} - for v, adj := range g.edges { + for v, adj := range edges { if len(adj) == 0 { leafVertices = append(leafVertices, v) } @@ -129,12 +170,12 @@ func (g *Graph) Sort() ([]object.ObjMetadataSet, error) { if len(leafVertices) == 0 { // Error can be ignored, so return the full set list return sorted, validation.NewError(CyclicDependencyError{ - Edges: g.GetEdges(), - }, g.GetVertices()...) + Edges: edgeMapToList(edges), + }, edgeMapKeys(edges)...) } // Remove all edges to leaf vertices. for _, v := range leafVertices { - g.removeVertex(v) + removeVertex(edges, v) } sorted = append(sorted, leafVertices) } diff --git a/pkg/object/graph/graph_test.go b/pkg/object/graph/graph_test.go index b9d77d38..b9c4937c 100644 --- a/pkg/object/graph/graph_test.go +++ b/pkg/object/graph/graph_test.go @@ -138,6 +138,119 @@ func TestObjectGraphSort(t *testing.T) { } assert.NoError(t, err) testutil.AssertEqual(t, tc.expected, actual) + + // verify sort is repeatable & non-destructive + actual, err = g.Sort() + assert.NoError(t, err) + testutil.AssertEqual(t, tc.expected, actual) + }) + } +} + +func TestGraphDependencies(t *testing.T) { + testCases := map[string]struct { + vertices object.ObjMetadataSet + edges []Edge + from object.ObjMetadata + expected object.ObjMetadataSet + }{ + "no dependencies": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + from: o3, + expected: object.ObjMetadataSet{}, + }, + "one dependency": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + from: o2, + expected: object.ObjMetadataSet{o3}, + }, + "two dependencies": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + from: o1, + expected: object.ObjMetadataSet{o2, o3}, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g := New() + for _, vertex := range tc.vertices { + g.AddVertex(vertex) + } + for _, edge := range tc.edges { + g.AddEdge(edge.From, edge.To) + } + + testutil.AssertEqual(t, tc.expected, g.Dependencies(tc.from)) + }) + } +} + +func TestGraphDependents(t *testing.T) { + testCases := map[string]struct { + vertices object.ObjMetadataSet + edges []Edge + to object.ObjMetadata + expected object.ObjMetadataSet + }{ + "no dependents": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + to: o1, + expected: object.ObjMetadataSet{}, + }, + "one dependent": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + to: o2, + expected: object.ObjMetadataSet{o1}, + }, + "two dependents": { + vertices: object.ObjMetadataSet{o1, o2, o3}, + edges: []Edge{ + {From: o1, To: o2}, + {From: o1, To: o3}, + {From: o2, To: o3}, + }, + to: o3, + expected: object.ObjMetadataSet{o1, o2}, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + g := New() + for _, vertex := range tc.vertices { + g.AddVertex(vertex) + } + for _, edge := range tc.edges { + g.AddEdge(edge.From, edge.To) + } + + testutil.AssertEqual(t, tc.expected, g.Dependents(tc.to)) }) } } diff --git a/test/e2e/artifacts_test.go b/test/e2e/artifacts_test.go index 9339b916..cb130036 100644 --- a/test/e2e/artifacts_test.go +++ b/test/e2e/artifacts_test.go @@ -192,3 +192,10 @@ spec: - name: tcp containerPort: 80 ` + +var namespaceTemplate = ` +apiVersion: v1 +kind: Namespace +metadata: + name: {{.Namespace}} +` diff --git a/test/e2e/dependency_filter_test.go b/test/e2e/dependency_filter_test.go new file mode 100644 index 00000000..ec8258ef --- /dev/null +++ b/test/e2e/dependency_filter_test.go @@ -0,0 +1,411 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cli-utils/pkg/apply" + "sigs.k8s.io/cli-utils/pkg/apply/event" + "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/object/validation" + "sigs.k8s.io/cli-utils/pkg/testutil" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +//nolint:dupl // expEvents similar to other tests +func dependencyFilterTest(ctx context.Context, c client.Client, invConfig InventoryConfig, inventoryName, namespaceName string) { + By("apply resources in order based on depends-on annotation") + applier := invConfig.ApplierFactoryFunc() + + inv := invConfig.InvWrapperFunc(invConfig.FactoryFunc(inventoryName, namespaceName, "test")) + + pod1Obj := withDependsOn(withNamespace(manifestToUnstructured(pod1), namespaceName), fmt.Sprintf("/namespaces/%s/Pod/pod2", namespaceName)) + pod2Obj := withNamespace(manifestToUnstructured(pod2), namespaceName) + + // Dependency order: pod1 -> pod2 + // Apply order: pod2, pod1 + resources := []*unstructured.Unstructured{ + pod1Obj, + pod2Obj, + } + + // Cleanup + defer func(ctx context.Context, c client.Client) { + deleteUnstructuredIfExists(ctx, c, pod1Obj) + deleteUnstructuredIfExists(ctx, c, pod2Obj) + }(ctx, c) + + applierEvents := runCollect(applier.Run(ctx, inv, resources, apply.ApplierOptions{ + EmitStatusEvents: false, + })) + + expEvents := []testutil.ExpEvent{ + { + // InitTask + EventType: event.InitType, + InitEvent: &testutil.ExpInitEvent{}, + }, + { + // InvAddTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Started, + }, + }, + { + // InvAddTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Started, + }, + }, + { + // Apply pod2 first + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-0", + Operation: event.Created, + Identifier: object.UnstructuredToObjMetadata(pod2Obj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Started, + }, + }, + { + // pod2 reconcile Pending. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.ReconcilePending, + Identifier: object.UnstructuredToObjMetadata(pod2Obj), + }, + }, + { + // pod2 confirmed Current. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.Reconciled, + Identifier: object.UnstructuredToObjMetadata(pod2Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-1", + Type: event.Started, + }, + }, + { + // Apply pod1 second + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-1", + Operation: event.Created, + Identifier: object.UnstructuredToObjMetadata(pod1Obj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-1", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Started, + }, + }, + { + // pod1 reconcile Pending. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.ReconcilePending, + Identifier: object.UnstructuredToObjMetadata(pod1Obj), + }, + }, + { + // pod1 confirmed Current. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.Reconciled, + Identifier: object.UnstructuredToObjMetadata(pod1Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Finished, + }, + }, + { + // InvSetTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Started, + }, + }, + { + // InvSetTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Finished, + }, + }, + } + Expect(testutil.EventsToExpEvents(applierEvents)).To(testutil.Equal(expEvents)) + + By("verify pod1 created and ready") + result := assertUnstructuredExists(ctx, c, pod1Obj) + podIP, found, err := object.NestedField(result.Object, "status", "podIP") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(podIP).NotTo(BeEmpty()) // use podIP as proxy for readiness + + By("verify pod2 created and ready") + result = assertUnstructuredExists(ctx, c, pod2Obj) + podIP, found, err = object.NestedField(result.Object, "status", "podIP") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(podIP).NotTo(BeEmpty()) // use podIP as proxy for readiness + + // Attempt to Prune pod2 + resources = []*unstructured.Unstructured{ + pod1Obj, + } + + applierEvents = runCollect(applier.Run(ctx, inv, resources, apply.ApplierOptions{ + EmitStatusEvents: false, + ValidationPolicy: validation.SkipInvalid, + })) + + expEvents = []testutil.ExpEvent{ + { + // InitTask + EventType: event.InitType, + InitEvent: &testutil.ExpInitEvent{}, + }, + { + // InvAddTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Started, + }, + }, + { + // InvAddTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Started, + }, + }, + { + // Apply pod1 Skipped (dependency actuation strategy mismatch) + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-0", + Operation: event.Unchanged, + Identifier: object.UnstructuredToObjMetadata(pod1Obj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Started, + }, + }, + { + // pod1 reconcile Skipped (because apply skipped) + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.ReconcileSkipped, + Identifier: object.UnstructuredToObjMetadata(pod1Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Finished, + }, + }, + { + // PruneTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.PruneAction, + GroupName: "prune-0", + Type: event.Started, + }, + }, + { + // Prune pod2 Skipped (dependency actuation strategy mismatch) + EventType: event.PruneType, + PruneEvent: &testutil.ExpPruneEvent{ + GroupName: "prune-0", + Operation: event.PruneSkipped, + Identifier: object.UnstructuredToObjMetadata(pod2Obj), + Error: nil, + }, + }, + { + // PruneTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.PruneAction, + GroupName: "prune-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Started, + }, + }, + { + // pod2 reconcile Skipped (because prune skipped) + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.ReconcileSkipped, + Identifier: object.UnstructuredToObjMetadata(pod2Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Finished, + }, + }, + { + // InvSetTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Started, + }, + }, + { + // InvSetTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Finished, + }, + }, + } + Expect(testutil.EventsToExpEvents(applierEvents)).To(testutil.Equal(expEvents)) + + By("verify pod1 not deleted") + result = assertUnstructuredExists(ctx, c, pod1Obj) + ts, found, err := object.NestedField(result.Object, "metadata", "deletionTimestamp") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "deletionTimestamp found: ", ts) + + By("verify pod2 not deleted") + result = assertUnstructuredExists(ctx, c, pod2Obj) + ts, found, err = object.NestedField(result.Object, "metadata", "deletionTimestamp") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "deletionTimestamp found: ", ts) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index eb29d8d7..f0027ef1 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -187,6 +187,14 @@ var _ = Describe("Applier", func() { mutationTest(ctx, c, invConfig, inventoryName, namespace.GetName()) }) + It("DependencyFilter", func() { + dependencyFilterTest(ctx, c, invConfig, inventoryName, namespace.GetName()) + }) + + It("LocalNamespacesFilter", func() { + namespaceFilterTest(ctx, c, invConfig, inventoryName, namespace.GetName()) + }) + It("Prune retrieval error correctly handled", func() { pruneRetrieveErrorTest(ctx, c, invConfig, inventoryName, namespace.GetName()) }) diff --git a/test/e2e/namespace_filter_test.go b/test/e2e/namespace_filter_test.go new file mode 100644 index 00000000..598f302a --- /dev/null +++ b/test/e2e/namespace_filter_test.go @@ -0,0 +1,408 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cli-utils/pkg/apply" + "sigs.k8s.io/cli-utils/pkg/apply/event" + "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/testutil" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +//nolint:dupl // expEvents similar to other tests +func namespaceFilterTest(ctx context.Context, c client.Client, invConfig InventoryConfig, inventoryName, namespaceName string) { + By("apply resources in order based on depends-on annotation") + applier := invConfig.ApplierFactoryFunc() + + inv := invConfig.InvWrapperFunc(invConfig.FactoryFunc(inventoryName, namespaceName, "test")) + + namespace1Name := fmt.Sprintf("%s-ns1", namespaceName) + + fields := struct{ Namespace string }{Namespace: namespace1Name} + namespace1Obj := templateToUnstructured(namespaceTemplate, fields) + podBObj := templateToUnstructured(podBTemplate, fields) + + // Dependency order: podB -> namespace1 + // Apply order: namespace1, podB + resources := []*unstructured.Unstructured{ + namespace1Obj, + podBObj, + } + + // Cleanup + defer func(ctx context.Context, c client.Client) { + deleteUnstructuredIfExists(ctx, c, podBObj) + deleteUnstructuredIfExists(ctx, c, namespace1Obj) + }(ctx, c) + + applierEvents := runCollect(applier.Run(ctx, inv, resources, apply.ApplierOptions{ + EmitStatusEvents: false, + })) + + expEvents := []testutil.ExpEvent{ + { + // InitTask + EventType: event.InitType, + InitEvent: &testutil.ExpInitEvent{}, + }, + { + // InvAddTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Started, + }, + }, + { + // InvAddTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Started, + }, + }, + { + // Apply namespace1 first + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-0", + Operation: event.Created, + Identifier: object.UnstructuredToObjMetadata(namespace1Obj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Started, + }, + }, + { + // namespace1 reconcile Pending. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.ReconcilePending, + Identifier: object.UnstructuredToObjMetadata(namespace1Obj), + }, + }, + { + // namespace1 confirmed Current. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.Reconciled, + Identifier: object.UnstructuredToObjMetadata(namespace1Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-1", + Type: event.Started, + }, + }, + { + // Apply podB second + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-1", + Operation: event.Created, + Identifier: object.UnstructuredToObjMetadata(podBObj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-1", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Started, + }, + }, + { + // podB reconcile Pending. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.ReconcilePending, + Identifier: object.UnstructuredToObjMetadata(podBObj), + }, + }, + { + // podB confirmed Current. + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.Reconciled, + Identifier: object.UnstructuredToObjMetadata(podBObj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Finished, + }, + }, + { + // InvSetTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Started, + }, + }, + { + // InvSetTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Finished, + }, + }, + } + Expect(testutil.EventsToExpEvents(applierEvents)).To(testutil.Equal(expEvents)) + + By("verify namespace1 created") + assertUnstructuredExists(ctx, c, namespace1Obj) + + By("verify podB created and ready") + result := assertUnstructuredExists(ctx, c, podBObj) + podIP, found, err := object.NestedField(result.Object, "status", "podIP") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(podIP).NotTo(BeEmpty()) // use podIP as proxy for readiness + + // Attempt to Prune namespace + resources = []*unstructured.Unstructured{ + podBObj, + } + + applierEvents = runCollect(applier.Run(ctx, inv, resources, apply.ApplierOptions{ + EmitStatusEvents: false, + })) + + expEvents = []testutil.ExpEvent{ + { + // InitTask + EventType: event.InitType, + InitEvent: &testutil.ExpInitEvent{}, + }, + { + // InvAddTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Started, + }, + }, + { + // InvAddTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-add-0", + Type: event.Finished, + }, + }, + { + // ApplyTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Started, + }, + }, + { + // Apply podB Skipped (because depends on namespace being deleted) + EventType: event.ApplyType, + ApplyEvent: &testutil.ExpApplyEvent{ + GroupName: "apply-0", + Operation: event.Unchanged, + Identifier: object.UnstructuredToObjMetadata(podBObj), + Error: nil, + }, + }, + { + // ApplyTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.ApplyAction, + GroupName: "apply-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Started, + }, + }, + { + // podB Reconcile Skipped (because apply skipped) + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-0", + Operation: event.ReconcileSkipped, + Identifier: object.UnstructuredToObjMetadata(podBObj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-0", + Type: event.Finished, + }, + }, + { + // PruneTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.PruneAction, + GroupName: "prune-0", + Type: event.Started, + }, + }, + { + // Prune namespace1 Skipped (because namespace still in use) + EventType: event.PruneType, + PruneEvent: &testutil.ExpPruneEvent{ + GroupName: "prune-0", + Operation: event.PruneSkipped, + Identifier: object.UnstructuredToObjMetadata(namespace1Obj), + Error: nil, + }, + }, + { + // PruneTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.PruneAction, + GroupName: "prune-0", + Type: event.Finished, + }, + }, + { + // WaitTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Started, + }, + }, + { + // namespace1 reconcile Skipped (because prune skipped). + EventType: event.WaitType, + WaitEvent: &testutil.ExpWaitEvent{ + GroupName: "wait-1", + Operation: event.ReconcileSkipped, + Identifier: object.UnstructuredToObjMetadata(namespace1Obj), + }, + }, + { + // WaitTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.WaitAction, + GroupName: "wait-1", + Type: event.Finished, + }, + }, + { + // InvSetTask start + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Started, + }, + }, + { + // InvSetTask finished + EventType: event.ActionGroupType, + ActionGroupEvent: &testutil.ExpActionGroupEvent{ + Action: event.InventoryAction, + GroupName: "inventory-set-0", + Type: event.Finished, + }, + }, + } + Expect(testutil.EventsToExpEvents(applierEvents)).To(testutil.Equal(expEvents)) + + By("verify namespace1 not deleted") + result = assertUnstructuredExists(ctx, c, namespace1Obj) + ts, found, err := object.NestedField(result.Object, "metadata", "deletionTimestamp") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "deletionTimestamp found: ", ts) + + By("verify podB not deleted") + result = assertUnstructuredExists(ctx, c, podBObj) + ts, found, err = object.NestedField(result.Object, "metadata", "deletionTimestamp") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "deletionTimestamp found: ", ts) +}