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