diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go index b9a85e3d..607bbd97 100644 --- a/internal/controller/kustomization_controller.go +++ b/internal/controller/kustomization_controller.go @@ -83,18 +83,19 @@ type KustomizationReconciler struct { kuberecorder.EventRecorder runtimeCtrl.Metrics - artifactFetcher *fetch.ArchiveFetcher - requeueDependency time.Duration - StatusPoller *polling.StatusPoller - PollingOpts polling.Options - ControllerName string - statusManager string - NoCrossNamespaceRefs bool - NoRemoteBases bool - FailFast bool - DefaultServiceAccount string - KubeConfigOpts runtimeClient.KubeConfigOptions - ConcurrentSSA int + artifactFetcher *fetch.ArchiveFetcher + requeueDependency time.Duration + StatusPoller *polling.StatusPoller + PollingOpts polling.Options + ControllerName string + statusManager string + NoCrossNamespaceRefs bool + NoRemoteBases bool + FailFast bool + DefaultServiceAccount string + KubeConfigOpts runtimeClient.KubeConfigOptions + ConcurrentSSA int + DisallowedFieldManagers []string } // KustomizationReconcilerOptions contains options for the KustomizationReconciler. @@ -669,6 +670,41 @@ func (r *KustomizationReconciler) apply(ctx context.Context, fmt.Sprintf("%s/force", kustomizev1.GroupVersion.Group): kustomizev1.EnabledValue, } + fieldManagers := []ssa.FieldManager{ + { + // to undo changes made with 'kubectl apply --server-side --force-conflicts' + Name: "kubectl", + OperationType: metav1.ManagedFieldsOperationApply, + }, + { + // to undo changes made with 'kubectl apply' + Name: "kubectl", + OperationType: metav1.ManagedFieldsOperationUpdate, + }, + { + // to undo changes made with 'kubectl apply' + Name: "before-first-apply", + OperationType: metav1.ManagedFieldsOperationUpdate, + }, + { + // to undo changes made by the controller before SSA + Name: r.ControllerName, + OperationType: metav1.ManagedFieldsOperationUpdate, + }, + } + + for _, fieldManager := range r.DisallowedFieldManagers { + fieldManagers = append(fieldManagers, ssa.FieldManager{ + Name: fieldManager, + OperationType: metav1.ManagedFieldsOperationApply, + }) + // to undo changes made by the controller before SSA + fieldManagers = append(fieldManagers, ssa.FieldManager{ + Name: fieldManager, + OperationType: metav1.ManagedFieldsOperationUpdate, + }) + } + applyOpts.Cleanup = ssa.ApplyCleanupOptions{ Annotations: []string{ // remove the kubectl annotation @@ -681,28 +717,7 @@ func (r *KustomizationReconciler) apply(ctx context.Context, // remove deprecated fluxcd.io labels "fluxcd.io/sync-gc-mark", }, - FieldManagers: []ssa.FieldManager{ - { - // to undo changes made with 'kubectl apply --server-side --force-conflicts' - Name: "kubectl", - OperationType: metav1.ManagedFieldsOperationApply, - }, - { - // to undo changes made with 'kubectl apply' - Name: "kubectl", - OperationType: metav1.ManagedFieldsOperationUpdate, - }, - { - // to undo changes made with 'kubectl apply' - Name: "before-first-apply", - OperationType: metav1.ManagedFieldsOperationUpdate, - }, - { - // to undo changes made by the controller before SSA - Name: r.ControllerName, - OperationType: metav1.ManagedFieldsOperationUpdate, - }, - }, + FieldManagers: fieldManagers, Exclusions: map[string]string{ fmt.Sprintf("%s/ssa", kustomizev1.GroupVersion.Group): kustomizev1.MergeValue, }, diff --git a/internal/controller/kustomization_disallowed_managers_test.go b/internal/controller/kustomization_disallowed_managers_test.go new file mode 100644 index 00000000..dffb2e5a --- /dev/null +++ b/internal/controller/kustomization_disallowed_managers_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2023 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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" +) + +func TestKustomizationReconciler_DisallowedManagers(t *testing.T) { + g := NewWithT(t) + id := "disallowed-managers-" + 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: "configmap.yaml", + Body: fmt.Sprintf(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: %[1]s +data: + key: %[2]s +`, name, data), + }, + } + } + + artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5))) + g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files") + + repositoryName := types.NamespacedName{ + Name: fmt.Sprintf("disallowed-managers-%s", randStringRunes(5)), + Namespace: id, + } + + err = applyGitRepository(repositoryName, artifact, revision) + g.Expect(err).NotTo(HaveOccurred()) + + kustomizationKey := types.NamespacedName{ + Name: fmt.Sprintf("disallowed-managers-%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, + }, + HealthChecks: []meta.NamespacedObjectKindReference{ + { + APIVersion: "v1", + Kind: "ConfigMap", + Name: id, + Namespace: id, + }, + }, + TargetNamespace: id, + Force: false, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + + resultK := &kustomizev1.Kustomization{} + initialConfigMap := &corev1.ConfigMap{} + badConfigMap := &corev1.ConfigMap{} + fixedConfigMap := &corev1.ConfigMap{} + + t.Run("creates configmap", func(t *testing.T) { + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + return resultK.Status.LastAppliedRevision == revision + }, timeout, time.Second).Should(BeTrue()) + logStatus(t, resultK) + + kstatusCheck.CheckErr(ctx, resultK) + g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, initialConfigMap)).Should(Succeed()) + g.Expect(initialConfigMap.Data).Should(HaveKey("key")) + }) + + t.Run("update configmap with new data", func(t *testing.T) { + configMap := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: id, + Namespace: id, + }, + } + err = k8sClient.Patch(context.Background(), &configMap, client.RawPatch(types.MergePatchType, []byte(`{"data":{"bad-key":"overridden field manager"}}`)), &client.PatchOptions{FieldManager: overrideManagerName}) + g.Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Patch(context.Background(), &configMap, client.RawPatch(types.MergePatchType, []byte(`{"data":{"key2":"not overridden field manager"}}`)), &client.PatchOptions{FieldManager: "good-name"}) + g.Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(initialConfigMap), badConfigMap) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(badConfigMap.Data).Should(HaveKey("bad-key")) + g.Expect(badConfigMap.Data).Should(HaveKey("key2")) + }) + + t.Run("bad-key should be removed from the configmap", func(t *testing.T) { + reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: kustomizationKey, + }) + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(initialConfigMap), fixedConfigMap) + return g.Expect(fixedConfigMap.Data).ShouldNot(HaveKey("bad-key")) && g.Expect(fixedConfigMap.Data).Should(HaveKey("key2")) + }, timeout, time.Second).Should(BeTrue()) + }) +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 55fc89d1..f5f8e0f1 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -57,6 +57,7 @@ const ( interval = time.Second * 1 reconciliationInterval = time.Second * 5 vaultVersion = "1.13.2" + overrideManagerName = "node-fetch" ) var ( @@ -173,11 +174,12 @@ func TestMain(m *testing.M) { kstatusInProgressCheck = kcheck.NewInProgressChecker(testEnv.Client) kstatusInProgressCheck.DisableFetch = true reconciler = &KustomizationReconciler{ - ControllerName: controllerName, - Client: testEnv, - EventRecorder: testEnv.GetEventRecorderFor(controllerName), - Metrics: testMetricsH, - ConcurrentSSA: 4, + ControllerName: controllerName, + Client: testEnv, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + Metrics: testMetricsH, + ConcurrentSSA: 4, + DisallowedFieldManagers: []string{overrideManagerName}, } if err := (reconciler).SetupWithManager(ctx, testEnv, KustomizationReconcilerOptions{ DependencyRequeueInterval: 2 * time.Second, diff --git a/main.go b/main.go index 0e3a3792..4ec23590 100644 --- a/main.go +++ b/main.go @@ -76,24 +76,25 @@ func init() { func main() { var ( - metricsAddr string - eventsAddr string - healthAddr string - concurrent int - concurrentSSA int - requeueDependency time.Duration - clientOptions runtimeClient.Options - kubeConfigOpts runtimeClient.KubeConfigOptions - logOptions logger.Options - leaderElectionOptions leaderelection.Options - rateLimiterOptions runtimeCtrl.RateLimiterOptions - watchOptions runtimeCtrl.WatchOptions - intervalJitterOptions jitter.IntervalOptions - aclOptions acl.Options - noRemoteBases bool - httpRetry int - defaultServiceAccount string - featureGates feathelper.FeatureGates + metricsAddr string + eventsAddr string + healthAddr string + concurrent int + concurrentSSA int + requeueDependency time.Duration + clientOptions runtimeClient.Options + kubeConfigOpts runtimeClient.KubeConfigOptions + logOptions logger.Options + leaderElectionOptions leaderelection.Options + rateLimiterOptions runtimeCtrl.RateLimiterOptions + watchOptions runtimeCtrl.WatchOptions + intervalJitterOptions jitter.IntervalOptions + aclOptions acl.Options + noRemoteBases bool + httpRetry int + defaultServiceAccount string + featureGates feathelper.FeatureGates + disallowedFieldManagers []string ) flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") @@ -106,6 +107,7 @@ func main() { "Disallow remote bases usage in Kustomize overlays. When this flag is enabled, all resources must refer to local files included in the source artifact.") flag.IntVar(&httpRetry, "http-retry", 9, "The maximum number of retries when failing to fetch artifacts over HTTP.") flag.StringVar(&defaultServiceAccount, "default-service-account", "", "Default service account used for impersonation.") + flag.StringArrayVar(&disallowedFieldManagers, "override-manager", []string{}, "Field manager disallowed to perform changes on managed resources.") clientOptions.BindFlags(flag.CommandLine) logOptions.BindFlags(flag.CommandLine) @@ -227,18 +229,19 @@ func main() { } if err = (&controller.KustomizationReconciler{ - ControllerName: controllerName, - DefaultServiceAccount: defaultServiceAccount, - Client: mgr.GetClient(), - Metrics: metricsH, - EventRecorder: eventRecorder, - NoCrossNamespaceRefs: aclOptions.NoCrossNamespaceRefs, - NoRemoteBases: noRemoteBases, - FailFast: failFast, - ConcurrentSSA: concurrentSSA, - KubeConfigOpts: kubeConfigOpts, - PollingOpts: pollingOpts, - StatusPoller: polling.NewStatusPoller(mgr.GetClient(), mgr.GetRESTMapper(), pollingOpts), + ControllerName: controllerName, + DefaultServiceAccount: defaultServiceAccount, + Client: mgr.GetClient(), + Metrics: metricsH, + EventRecorder: eventRecorder, + NoCrossNamespaceRefs: aclOptions.NoCrossNamespaceRefs, + NoRemoteBases: noRemoteBases, + FailFast: failFast, + ConcurrentSSA: concurrentSSA, + KubeConfigOpts: kubeConfigOpts, + PollingOpts: pollingOpts, + StatusPoller: polling.NewStatusPoller(mgr.GetClient(), mgr.GetRESTMapper(), pollingOpts), + DisallowedFieldManagers: disallowedFieldManagers, }).SetupWithManager(ctx, mgr, controller.KustomizationReconcilerOptions{ DependencyRequeueInterval: requeueDependency, HTTPRetry: httpRetry,