diff --git a/api/v1/kustomization_types.go b/api/v1/kustomization_types.go index ea6c92ae..22b6b330 100644 --- a/api/v1/kustomization_types.go +++ b/api/v1/kustomization_types.go @@ -33,6 +33,10 @@ const ( MergeValue = "Merge" IfNotPresentValue = "IfNotPresent" IgnoreValue = "Ignore" + + DeletionPolicyMirrorPrune = "MirrorPrune" + DeletionPolicyDelete = "Delete" + DeletionPolicyOrphan = "Orphan" ) // KustomizationSpec defines the configuration to calculate the desired state @@ -95,6 +99,14 @@ type KustomizationSpec struct { // +required Prune bool `json:"prune"` + // DeletionPolicy can be used to control garbage collection when this + // Kustomization is deleted. Valid values are ('MirrorPrune', 'Delete', + // 'Orphan'). 'MirrorPrune' mirrors the Prune field (orphan if false, + // delete if true). Defaults to 'MirrorPrune'. + // +kubebuilder:validation:Enum=MirrorPrune;Delete;Orphan + // +optional + DeletionPolicy string `json:"deletionPolicy,omitempty"` + // A list of resources to be included in the health assessment. // +optional HealthChecks []meta.NamespacedObjectKindReference `json:"healthChecks,omitempty"` @@ -287,6 +299,14 @@ func (in Kustomization) GetRequeueAfter() time.Duration { return in.Spec.Interval.Duration } +// GetDeletionPolicy returns the deletion policy and default value if not specified. +func (in Kustomization) GetDeletionPolicy() string { + if in.Spec.DeletionPolicy == "" { + return DeletionPolicyMirrorPrune + } + return in.Spec.DeletionPolicy +} + // GetDependsOn returns the list of dependencies across-namespaces. func (in Kustomization) GetDependsOn() []meta.NamespacedObjectReference { return in.Spec.DependsOn diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml index bbeb11ba..9fa9f991 100644 --- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml +++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml @@ -98,6 +98,17 @@ spec: required: - provider type: object + deletionPolicy: + description: |- + DeletionPolicy can be used to control garbage collection when this + Kustomization is deleted. Valid values are ('MirrorPrune', 'Delete', + 'Orphan'). 'MirrorPrune' mirrors the Prune field (orphan if false, + delete if true). Defaults to 'MirrorPrune'. + enum: + - MirrorPrune + - Delete + - Orphan + type: string dependsOn: description: |- DependsOn may contain a meta.NamespacedObjectReference slice diff --git a/docs/api/v1/kustomize.md b/docs/api/v1/kustomize.md index 7a2281ad..9973006f 100644 --- a/docs/api/v1/kustomize.md +++ b/docs/api/v1/kustomize.md @@ -208,6 +208,21 @@ bool +deletionPolicy
+ +string + + + +(Optional) +

DeletionPolicy can be used to control garbage collection when this +Kustomization is deleted. Valid values are (‘MirrorPrune’, ‘Delete’, +‘Orphan’). ‘MirrorPrune’ mirrors the Prune field (orphan if false, +delete if true). Defaults to ‘MirrorPrune’.

+ + + + healthChecks
@@ -716,6 +731,21 @@ bool +deletionPolicy
+ +string + + + +(Optional) +

DeletionPolicy can be used to control garbage collection when this +Kustomization is deleted. Valid values are (‘MirrorPrune’, ‘Delete’, +‘Orphan’). ‘MirrorPrune’ mirrors the Prune field (orphan if false, +delete if true). Defaults to ‘MirrorPrune’.

+ + + + healthChecks
diff --git a/docs/spec/v1/kustomizations.md b/docs/spec/v1/kustomizations.md index 77c1e01e..a9cfa72d 100644 --- a/docs/spec/v1/kustomizations.md +++ b/docs/spec/v1/kustomizations.md @@ -169,6 +169,39 @@ kustomize.toolkit.fluxcd.io/prune: disabled For details on how the controller tracks Kubernetes objects and determines what to garbage collect, see [`.status.inventory`](#inventory). +### Deletion policy + +`.spec.deletionPolicy` is an optional field that allows control over +garbage collection when a Kustomization object is deleted. The default behavior +is to mirror the configuration of [`.spec.prune`](#prune). + +Valid values: + +- `MirrorPrune` (default) - The managed resources will be deleted if `prune` is + `true` and orphaned if `false`. +- `Delete` - Ensure the managed resources are deleted before the Kustomization + is deleted. +- `Orphan` - Leave the managed resources when the Kustomization is deleted. + +For special cases when the managed resources are removed by other means (e.g. +the deletion of the namespace specified with +[`.spec.targetNamespace`](#target-namespace)), you can set the deletion policy +to `Orphan`: + +```yaml +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: app + namespace: default +spec: + # ...omitted for brevity + targetNamespace: app-namespace + prune: true + deletionPolicy: Orphan +``` + ### Interval `.spec.interval` is a required field that specifies the interval at which the diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go index 9df5966f..bd2ef9a1 100644 --- a/internal/controller/kustomization_controller.go +++ b/internal/controller/kustomization_controller.go @@ -956,10 +956,17 @@ func (r *KustomizationReconciler) prune(ctx context.Context, return false, nil } +func finalizerShouldDeleteResources(obj *kustomizev1.Kustomization) bool { + if obj.GetDeletionPolicy() == kustomizev1.DeletionPolicyMirrorPrune { + return obj.Spec.Prune + } + return obj.Spec.DeletionPolicy == kustomizev1.DeletionPolicyDelete +} + func (r *KustomizationReconciler) finalize(ctx context.Context, obj *kustomizev1.Kustomization) (ctrl.Result, error) { log := ctrl.LoggerFrom(ctx) - if obj.Spec.Prune && + if finalizerShouldDeleteResources(obj) && !obj.Spec.Suspend && obj.Status.Inventory != nil && obj.Status.Inventory.Entries != nil { diff --git a/internal/controller/kustomization_deletion_policy_test.go b/internal/controller/kustomization_deletion_policy_test.go new file mode 100644 index 00000000..f5493602 --- /dev/null +++ b/internal/controller/kustomization_deletion_policy_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/testserver" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" +) + +func TestKustomizationReconciler_DeletionPolicyDelete(t *testing.T) { + tests := []struct { + name string + prune bool + deletionPolicy string + wantDelete bool + }{ + { + name: "should delete when deletionPolicy overrides pruning disabled", + prune: false, + deletionPolicy: kustomizev1.DeletionPolicyDelete, + wantDelete: true, + }, + { + name: "should delete when deletionPolicy mirrors prune and pruning enabled", + prune: true, + deletionPolicy: kustomizev1.DeletionPolicyMirrorPrune, + wantDelete: true, + }, + { + name: "should orphan when deletionPolicy overrides pruning enabled", + prune: true, + deletionPolicy: kustomizev1.DeletionPolicyOrphan, + wantDelete: false, + }, + { + name: "should orphan when deletionPolicy mirrors prune and pruning disabled", + prune: false, + deletionPolicy: kustomizev1.DeletionPolicyMirrorPrune, + wantDelete: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + id := "gc-" + randStringRunes(5) + revision := "v1.0.0" + + err := createNamespace(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace") + + err = createKubeConfigSecret(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret") + + manifests := func(name string, data string) []testserver.File { + return []testserver.File{ + { + Name: "config.yaml", + Body: fmt.Sprintf(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: %[1]s +data: + key: "%[2]s" +`, name, data), + }, + } + } + + artifact, err := testServer.ArtifactFromFiles(manifests(id, id)) + g.Expect(err).NotTo(HaveOccurred()) + + repositoryName := types.NamespacedName{ + Name: fmt.Sprintf("gc-%s", randStringRunes(5)), + Namespace: id, + } + + err = applyGitRepository(repositoryName, artifact, revision) + g.Expect(err).NotTo(HaveOccurred()) + + kustomizationKey := types.NamespacedName{ + Name: fmt.Sprintf("gc-%s", randStringRunes(5)), + Namespace: id, + } + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: kustomizationKey.Name, + Namespace: kustomizationKey.Namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: repositoryName.Name, + Namespace: repositoryName.Namespace, + Kind: sourcev1.GitRepositoryKind, + }, + TargetNamespace: id, + Prune: tt.prune, + DeletionPolicy: tt.deletionPolicy, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + + resultK := &kustomizev1.Kustomization{} + resultConfig := &corev1.ConfigMap{} + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + return resultK.Status.LastAppliedRevision == revision + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, resultConfig)).Should(Succeed()) + + g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed()) + g.Eventually(func() bool { + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), kustomization) + return apierrors.IsNotFound(err) + }, timeout, time.Second).Should(BeTrue()) + + if tt.wantDelete { + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(resultConfig), resultConfig) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + } else { + g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(resultConfig), resultConfig)).Should(Succeed()) + } + + }) + } +}