From d3cdbe6d392c8735260a56f99dd4ea3811e1326b Mon Sep 17 00:00:00 2001
From: Aurel Canciu
Date: Sat, 13 Feb 2021 23:09:10 +0200
Subject: [PATCH] Support recreating objects on immutable field updates
Allow passing --force to kubectl apply. Useful when dealing with
immutable field changes in resources.
Signed-off-by: Aurel Canciu
---
.github/workflows/e2e.yaml | 2 +
api/v1beta1/kustomization_types.go | 9 +-
...mize.toolkit.fluxcd.io_kustomizations.yaml | 5 +
controllers/kustomization_controller.go | 15 +-
controllers/kustomization_controller_test.go | 135 ++++++++++++++++++
docs/api/kustomize.md | 26 ++++
6 files changed, 187 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 3cfade8c..993b5767 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -31,6 +31,8 @@ jobs:
uses: fluxcd/pkg//actions/kubebuilder@main
- name: Setup Kubectl
uses: fluxcd/pkg/actions/kubectl@main
+ with:
+ version: 1.20.2
- name: Run tests
run: make test
env:
diff --git a/api/v1beta1/kustomization_types.go b/api/v1beta1/kustomization_types.go
index ab443f7c..d0b3185b 100644
--- a/api/v1beta1/kustomization_types.go
+++ b/api/v1beta1/kustomization_types.go
@@ -17,9 +17,10 @@ limitations under the License.
package v1beta1
import (
+ "time"
+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
- "time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -127,6 +128,12 @@ type KustomizationSpec struct {
// +kubebuilder:validation:Enum=none;client;server
// +optional
Validation string `json:"validation,omitempty"`
+
+ // Force instructs the controller to recreate resources in the situation
+ // when dealing with immutable field changes.
+ // +kubebuilder:default:=false
+ // +optional
+ Force bool `json:"force,omitempty"`
}
// Decryption defines how decryption is handled for Kubernetes manifests.
diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
index 1918ac12..89bcaf50 100644
--- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
+++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
@@ -88,6 +88,11 @@ spec:
- name
type: object
type: array
+ force:
+ default: false
+ description: Force instructs the controller to recreate resources
+ in the situation when dealing with immutable field changes.
+ type: boolean
healthChecks:
description: A list of resources to be included in the health assessment.
items:
diff --git a/controllers/kustomization_controller.go b/controllers/kustomization_controller.go
index 94538368..8d65e62a 100644
--- a/controllers/kustomization_controller.go
+++ b/controllers/kustomization_controller.go
@@ -550,8 +550,15 @@ func (r *KustomizationReconciler) validate(ctx context.Context, kustomization ku
applyCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
- cmd := fmt.Sprintf("cd %s && kubectl apply -f %s.yaml --timeout=%s --dry-run=%s --cache-dir=/tmp",
- dirPath, kustomization.GetUID(), kustomization.GetTimeout().String(), kustomization.Spec.Validation)
+ validation := kustomization.Spec.Validation
+ if validation == "server" && kustomization.Spec.Force {
+ // Use client-side validation with force
+ validation = "client"
+ (logr.FromContext(ctx)).Info(fmt.Sprintf("Server-side validation is configured, falling-back to client-side validation since 'force' is enabled"))
+ }
+
+ cmd := fmt.Sprintf("cd %s && kubectl apply -f %s.yaml --timeout=%s --dry-run=%s --cache-dir=/tmp --force=%t",
+ dirPath, kustomization.GetUID(), kustomization.GetTimeout().String(), validation, kustomization.Spec.Force)
if kustomization.Spec.KubeConfig != nil {
kubeConfig, err := imp.WriteKubeConfig(ctx)
@@ -589,8 +596,8 @@ func (r *KustomizationReconciler) apply(ctx context.Context, kustomization kusto
defer cancel()
fieldManager := "kustomize-controller"
- cmd := fmt.Sprintf("cd %s && kubectl apply --field-manager=%s -f %s.yaml --timeout=%s --cache-dir=/tmp",
- dirPath, fieldManager, kustomization.GetUID(), kustomization.Spec.Interval.Duration.String())
+ cmd := fmt.Sprintf("cd %s && kubectl apply --field-manager=%s -f %s.yaml --timeout=%s --cache-dir=/tmp --force=%t",
+ dirPath, fieldManager, kustomization.GetUID(), kustomization.Spec.Interval.Duration.String(), kustomization.Spec.Force)
if kustomization.Spec.KubeConfig != nil {
kubeConfig, err := imp.WriteKubeConfig(ctx)
diff --git a/controllers/kustomization_controller_test.go b/controllers/kustomization_controller_test.go
index 561a698c..4ec37ac4 100644
--- a/controllers/kustomization_controller_test.go
+++ b/controllers/kustomization_controller_test.go
@@ -171,6 +171,7 @@ var _ = Describe("KustomizationReconciler", func() {
Suspend: false,
Timeout: nil,
Validation: "client",
+ Force: false,
PostBuild: &kustomizev1.PostBuild{
Substitute: map[string]string{"region": "eu-central-1"},
},
@@ -244,5 +245,139 @@ metadata:
expectRevision: "branch/commit1",
}),
)
+
+ Describe("Kustomization resource replacement", func() {
+ cmManifest := func(namespace, value string) string {
+ return fmt.Sprintf(`---
+kind: ConfigMap
+apiVersion: v1
+metadata:
+ name: test
+ namespace: %s
+data:
+ example: %q
+immutable: true
+`,
+ namespace, value)
+ }
+
+ It("should replace immutable field resource using force", func() {
+ manifests := []testserver.File{
+ {
+ Name: "cm.yaml",
+ Body: cmManifest(namespace.Name, "v1"),
+ },
+ }
+ artifact, err := httpServer.ArtifactFromFiles(manifests)
+ Expect(err).NotTo(HaveOccurred())
+
+ url := fmt.Sprintf("%s/%s", httpServer.URL(), artifact)
+
+ repositoryName := types.NamespacedName{
+ Name: fmt.Sprintf("%s", randStringRunes(5)),
+ Namespace: namespace.Name,
+ }
+ repository := &sourcev1.GitRepository{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: repositoryName.Name,
+ Namespace: repositoryName.Namespace,
+ },
+ Spec: sourcev1.GitRepositorySpec{
+ URL: "https://github.com/test/repository",
+ Interval: metav1.Duration{Duration: reconciliationInterval},
+ },
+ Status: sourcev1.GitRepositoryStatus{
+ Conditions: []metav1.Condition{
+ {
+ Type: meta.ReadyCondition,
+ Status: metav1.ConditionTrue,
+ LastTransitionTime: metav1.Now(),
+ Reason: sourcev1.GitOperationSucceedReason,
+ },
+ },
+ URL: url,
+ Artifact: &sourcev1.Artifact{
+ Path: url,
+ URL: url,
+ Revision: "v1",
+ LastUpdateTime: metav1.Now(),
+ },
+ },
+ }
+ Expect(k8sClient.Create(context.Background(), repository)).Should(Succeed())
+ Expect(k8sClient.Status().Update(context.Background(), repository)).Should(Succeed())
+ defer k8sClient.Delete(context.Background(), repository)
+
+ kName := types.NamespacedName{
+ Name: fmt.Sprintf("%s", randStringRunes(5)),
+ Namespace: namespace.Name,
+ }
+ k := &kustomizev1.Kustomization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: kName.Name,
+ Namespace: kName.Namespace,
+ },
+ Spec: kustomizev1.KustomizationSpec{
+ KubeConfig: kubeconfig,
+ Interval: metav1.Duration{Duration: reconciliationInterval},
+ Path: "./",
+ Prune: true,
+ SourceRef: kustomizev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: repository.Name,
+ },
+ Suspend: false,
+ Timeout: &metav1.Duration{Duration: 60 * time.Second},
+ Validation: "client",
+ Force: true,
+ },
+ }
+ Expect(k8sClient.Create(context.Background(), k)).Should(Succeed())
+ defer k8sClient.Delete(context.Background(), k)
+
+ got := &kustomizev1.Kustomization{}
+ Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), kName, got)
+ c := apimeta.FindStatusCondition(got.Status.Conditions, meta.ReadyCondition)
+ return c != nil && c.Reason == meta.ReconciliationSucceededReason
+ }, timeout, interval).Should(BeTrue())
+ Expect(got.Status.LastAppliedRevision).To(Equal("v1"))
+
+ cm := &corev1.ConfigMap{}
+ Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: namespace.Name}, cm)).Should(Succeed())
+ Expect(cm.Data["example"]).To(Equal("v1"))
+
+ manifests = []testserver.File{
+ {
+ Name: "cm.yaml",
+ Body: cmManifest(namespace.Name, "v2"),
+ },
+ }
+
+ artifact, err = httpServer.ArtifactFromFiles(manifests)
+ Expect(err).NotTo(HaveOccurred())
+
+ url = fmt.Sprintf("%s/%s", httpServer.URL(), artifact)
+
+ repository.Status.URL = url
+ repository.Status.Artifact = &sourcev1.Artifact{
+ Path: url,
+ URL: url,
+ Revision: "v2",
+ LastUpdateTime: metav1.Now(),
+ }
+ Expect(k8sClient.Status().Update(context.Background(), repository)).Should(Succeed())
+
+ lastAppliedRev := got.Status.LastAppliedRevision
+ Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), kName, got)
+ return apimeta.IsStatusConditionTrue(got.Status.Conditions, meta.ReadyCondition) && got.Status.LastAppliedRevision != lastAppliedRev
+ }, timeout, interval).Should(BeTrue())
+ Expect(got.Status.LastAppliedRevision).To(Equal("v2"))
+
+ Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: namespace.Name}, cm)).Should(Succeed())
+ Expect(cm.Data["example"]).To(Equal("v2"))
+ })
+ })
})
})
diff --git a/docs/api/kustomize.md b/docs/api/kustomize.md
index 83eb1617..a09b5f5a 100644
--- a/docs/api/kustomize.md
+++ b/docs/api/kustomize.md
@@ -320,6 +320,19 @@ string
The validation strategy can be ‘client’ (local dry-run), ‘server’ (APIServer dry-run) or ‘none’.
+
+
+force
+
+bool
+
+ |
+
+(Optional)
+ Force instructs the controller to recreate resources in the situation
+when dealing with immutable field changes.
+ |
+
@@ -763,6 +776,19 @@ string
The validation strategy can be ‘client’ (local dry-run), ‘server’ (APIServer dry-run) or ‘none’.
+
+
+force
+
+bool
+
+ |
+
+(Optional)
+ Force instructs the controller to recreate resources in the situation
+when dealing with immutable field changes.
+ |
+