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.

+ +