diff --git a/go.mod b/go.mod index b8b3a951..39694cb9 100644 --- a/go.mod +++ b/go.mod @@ -28,8 +28,8 @@ require ( github.com/fluxcd/pkg/cache v0.13.0 github.com/fluxcd/pkg/http/fetch v0.22.0 github.com/fluxcd/pkg/kustomize v1.27.0 - github.com/fluxcd/pkg/runtime v0.100.0 - github.com/fluxcd/pkg/ssa v0.68.0 + github.com/fluxcd/pkg/runtime v0.100.1 + github.com/fluxcd/pkg/ssa v0.67.1 github.com/fluxcd/pkg/tar v0.17.0 github.com/fluxcd/pkg/testserver v0.13.0 github.com/fluxcd/source-controller/api v1.7.2 diff --git a/go.sum b/go.sum index 1fca7eef..21114589 100644 --- a/go.sum +++ b/go.sum @@ -210,12 +210,12 @@ github.com/fluxcd/pkg/http/fetch v0.22.0 h1:FT8CfstPE/e7+KRxNrx8ZJ1Uj5rkR5wXOtvQ github.com/fluxcd/pkg/http/fetch v0.22.0/go.mod h1:X+8wF3peP79TyyDSgCJiavz+fAcYaf7CRXSeu7ccsPA= github.com/fluxcd/pkg/kustomize v1.27.0 h1:bWoWVaHV1ZRo3Ei1JXpY58hJK25WWna+az5jj6zseE0= github.com/fluxcd/pkg/kustomize v1.27.0/go.mod h1:KKb26vz5EApyOrgencDlvixJnuI6cnkWGks95sK9AFs= -github.com/fluxcd/pkg/runtime v0.100.0 h1:7k2T/zlOLZ+knVr5fGB6cqq3Dr9D1k2jEe6AJo91JlI= -github.com/fluxcd/pkg/runtime v0.100.0/go.mod h1:SctSsHvFwUfiOVP1zirP6mo7I8wQtXeWVl2lNQWal88= +github.com/fluxcd/pkg/runtime v0.100.1 h1:UiPmgY8Yv7UF06MT5T8AG9uDGNszm75/DQtK6JEhnrM= +github.com/fluxcd/pkg/runtime v0.100.1/go.mod h1:SctSsHvFwUfiOVP1zirP6mo7I8wQtXeWVl2lNQWal88= github.com/fluxcd/pkg/sourceignore v0.17.0 h1:Z72nruRMhC15zIEpWoDrAcJcJ1El6QDnP/aRDfE4WOA= github.com/fluxcd/pkg/sourceignore v0.17.0/go.mod h1:3e/VmYLId0pI/H5sK7W9Ibif+j0Ahns9RxNjDMtTTfY= -github.com/fluxcd/pkg/ssa v0.68.0 h1:hdRFrBJO9dh04200tNJljpi4TOArHC0nq+LUFZxMgKc= -github.com/fluxcd/pkg/ssa v0.68.0/go.mod h1:PFXVjChubQOiWDxalpwh6PzRsEswGqnKwZB4ScoxDx4= +github.com/fluxcd/pkg/ssa v0.67.1 h1:wmwrznP+USRUtppKRjAiBx3ayriygRx0IeMdX7z/HaM= +github.com/fluxcd/pkg/ssa v0.67.1/go.mod h1:PFXVjChubQOiWDxalpwh6PzRsEswGqnKwZB4ScoxDx4= github.com/fluxcd/pkg/tar v0.17.0 h1:uNxbFXy8ly8C7fJ8D7w3rjTNJFrb4Hp1aY/30XkfvxY= github.com/fluxcd/pkg/tar v0.17.0/go.mod h1:b1xyIRYDD0ket4SV5u0UXYv+ZdN/O/HmIO5jZQdHQls= github.com/fluxcd/pkg/testserver v0.13.0 h1:xEpBcEYtD7bwvZ+i0ZmChxKkDo/wfQEV3xmnzVybSSg= diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go index 35bad701..93fbcfbf 100644 --- a/internal/controller/kustomization_controller.go +++ b/internal/controller/kustomization_controller.go @@ -70,7 +70,6 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" "github.com/fluxcd/kustomize-controller/internal/decryptor" - "github.com/fluxcd/kustomize-controller/internal/features" "github.com/fluxcd/kustomize-controller/internal/inventory" ) @@ -118,6 +117,7 @@ type KustomizationReconciler struct { AdditiveCELDependencyCheck bool AllowExternalArtifact bool + DirectSourceFetch bool FailFast bool GroupChangeLog bool StrictSubstitutions bool @@ -686,13 +686,19 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, if obj.Spec.SourceRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact { return src, acl.AccessDeniedError( fmt.Sprintf("can't access '%s/%s', %s feature gate is disabled", - obj.Spec.SourceRef.Kind, namespacedName, features.ExternalArtifact)) + obj.Spec.SourceRef.Kind, namespacedName, runtimeCtrl.FeatureGateExternalArtifact)) + } + + // Use APIReader to bypass the cache when DirectSourceFetch is enabled. + var reader client.Reader = r.Client + if r.DirectSourceFetch { + reader = r.APIReader } switch obj.Spec.SourceRef.Kind { case sourcev1.OCIRepositoryKind: var repository sourcev1.OCIRepository - err := r.Client.Get(ctx, namespacedName, &repository) + err := reader.Get(ctx, namespacedName, &repository) if err != nil { if apierrors.IsNotFound(err) { return src, err @@ -702,7 +708,7 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, src = &repository case sourcev1.GitRepositoryKind: var repository sourcev1.GitRepository - err := r.Client.Get(ctx, namespacedName, &repository) + err := reader.Get(ctx, namespacedName, &repository) if err != nil { if apierrors.IsNotFound(err) { return src, err @@ -712,7 +718,7 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, src = &repository case sourcev1.BucketKind: var bucket sourcev1.Bucket - err := r.Client.Get(ctx, namespacedName, &bucket) + err := reader.Get(ctx, namespacedName, &bucket) if err != nil { if apierrors.IsNotFound(err) { return src, err @@ -722,7 +728,7 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, src = &bucket case sourcev1.ExternalArtifactKind: var ea sourcev1.ExternalArtifact - err := r.Client.Get(ctx, namespacedName, &ea) + err := reader.Get(ctx, namespacedName, &ea) if err != nil { if apierrors.IsNotFound(err) { return src, err diff --git a/internal/controller/kustomization_direct_source_fetch_test.go b/internal/controller/kustomization_direct_source_fetch_test.go new file mode 100644 index 00000000..d2fee18b --- /dev/null +++ b/internal/controller/kustomization_direct_source_fetch_test.go @@ -0,0 +1,319 @@ +/* +Copyright 2026 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" + apimeta "k8s.io/apimachinery/pkg/api/meta" + 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_DirectSourceFetch(t *testing.T) { + g := NewWithT(t) + id := "direct-fetch-" + 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("direct-fetch-repo-%s", randStringRunes(5)), + Namespace: id, + } + + err = applyGitRepository(repositoryName, artifact, revision) + g.Expect(err).NotTo(HaveOccurred()) + + kustomizationKey := types.NamespacedName{ + Name: fmt.Sprintf("direct-fetch-kust-%s", randStringRunes(5)), + Namespace: id, + } + + t.Run("reconciles with DirectSourceFetch enabled (uses APIReader)", func(t *testing.T) { + g := NewWithT(t) + + // Enable DirectSourceFetch to use APIReader + reconciler.DirectSourceFetch = true + defer func() { reconciler.DirectSourceFetch = false }() // Reset after test + + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: kustomizationKey.Name + "-direct", + 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, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed()) + }) + + resultK := &kustomizev1.Kustomization{} + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + return apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.ReadyCondition) && + resultK.Status.LastAppliedRevision == revision + }, timeout, time.Second).Should(BeTrue()) + }) + + t.Run("source not found with DirectSourceFetch enabled", func(t *testing.T) { + g := NewWithT(t) + + // Enable DirectSourceFetch to use APIReader + reconciler.DirectSourceFetch = true + defer func() { reconciler.DirectSourceFetch = false }() // Reset after test + + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: kustomizationKey.Name + "-notfound", + Namespace: kustomizationKey.Namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: "non-existent-repo", + Namespace: id, + Kind: sourcev1.GitRepositoryKind, + }, + TargetNamespace: id, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed()) + }) + + resultK := &kustomizev1.Kustomization{} + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + ready := apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition) + if ready == nil { + return false + } + return ready.Status == metav1.ConditionFalse && + ready.Reason == meta.ArtifactFailedReason + }, timeout, time.Second).Should(BeTrue()) + }) +} + +func TestKustomizationReconciler_DirectSourceFetch_OCIRepository(t *testing.T) { + g := NewWithT(t) + id := "direct-fetch-oci-" + randStringRunes(5) + + 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") + + t.Run("handles OCIRepository with DirectSourceFetch enabled", func(t *testing.T) { + g := NewWithT(t) + + // Enable DirectSourceFetch to use APIReader + reconciler.DirectSourceFetch = true + defer func() { reconciler.DirectSourceFetch = false }() // Reset after test + + // Create an OCIRepository (without artifact - just to test source fetching) + ociRepoName := types.NamespacedName{ + Name: fmt.Sprintf("oci-repo-%s", randStringRunes(5)), + Namespace: id, + } + + ociRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: ociRepoName.Name, + Namespace: ociRepoName.Namespace, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/test/repo", + Interval: metav1.Duration{Duration: time.Minute}, + }, + } + g.Expect(k8sClient.Create(context.Background(), ociRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), ociRepo)).To(Succeed()) + }) + + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("kust-oci-%s", randStringRunes(5)), + Namespace: id, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: ociRepoName.Name, + Namespace: ociRepoName.Namespace, + Kind: sourcev1.OCIRepositoryKind, + }, + TargetNamespace: id, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed()) + }) + + // The kustomization should be able to find the source (even though it has no artifact) + // and eventually report that the artifact is not ready + resultK := &kustomizev1.Kustomization{} + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + // Source should be found but artifact should not be ready + return len(resultK.Status.Conditions) > 0 + }, timeout, time.Second).Should(BeTrue()) + }) +} + +func TestKustomizationReconciler_DirectSourceFetch_Bucket(t *testing.T) { + g := NewWithT(t) + id := "direct-fetch-bucket-" + randStringRunes(5) + + 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") + + t.Run("handles Bucket with DirectSourceFetch enabled", func(t *testing.T) { + g := NewWithT(t) + + // Enable DirectSourceFetch to use APIReader + reconciler.DirectSourceFetch = true + defer func() { reconciler.DirectSourceFetch = false }() // Reset after test + + // Create a Bucket source (without artifact - just to test source fetching) + bucketName := types.NamespacedName{ + Name: fmt.Sprintf("bucket-%s", randStringRunes(5)), + Namespace: id, + } + + bucket := &sourcev1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: bucketName.Name, + Namespace: bucketName.Namespace, + }, + Spec: sourcev1.BucketSpec{ + BucketName: "test-bucket", + Endpoint: "s3.amazonaws.com", + Interval: metav1.Duration{Duration: time.Minute}, + }, + } + g.Expect(k8sClient.Create(context.Background(), bucket)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), bucket)).To(Succeed()) + }) + + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("kust-bucket-%s", randStringRunes(5)), + Namespace: id, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: bucketName.Name, + Namespace: bucketName.Namespace, + Kind: sourcev1.BucketKind, + }, + TargetNamespace: id, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed()) + }) + + // The kustomization should be able to find the source (even though it has no artifact) + resultK := &kustomizev1.Kustomization{} + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + // Source should be found but artifact should not be ready + return len(resultK.Status.Conditions) > 0 + }, timeout, time.Second).Should(BeTrue()) + }) +} diff --git a/internal/features/features.go b/internal/features/features.go index 15feee47..56dee2e7 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -25,12 +25,6 @@ import ( ) const ( - // CacheSecretsAndConfigMaps controls whether Secrets and ConfigMaps should - // be cached. - // - // When enabled, it will cache both object types, resulting in increased - // memory usage and cluster-wide RBAC permissions (list and watch). - CacheSecretsAndConfigMaps = "CacheSecretsAndConfigMaps" // DisableStatusPollerCache controls whether the status polling cache // should be disabled. @@ -53,14 +47,6 @@ const ( // to reduce cardinality of logs. GroupChangeLog = "GroupChangeLog" - // AdditiveCELDependencyCheck controls whether the CEL dependency check - // should be additive, meaning that the built-in readiness check will - // be added to the user-defined CEL expressions. - AdditiveCELDependencyCheck = "AdditiveCELDependencyCheck" - - // ExternalArtifact controls whether the ExternalArtifact source type is enabled. - ExternalArtifact = "ExternalArtifact" - // CancelHealthCheckOnNewRevision controls whether ongoing health checks // should be cancelled when a new source revision becomes available. // @@ -74,7 +60,7 @@ const ( var features = map[string]bool{ // CacheSecretsAndConfigMaps // opt-in from v0.33 - CacheSecretsAndConfigMaps: false, + controller.FeatureGateCacheSecretsAndConfigMaps: false, // DisableStatusPollerCache // opt-out from v1.2 DisableStatusPollerCache: true, @@ -89,16 +75,19 @@ var features = map[string]bool{ GroupChangeLog: false, // AdditiveCELDependencyCheck // opt-in from v1.7 - AdditiveCELDependencyCheck: false, + controller.FeatureGateAdditiveCELDependencyCheck: false, // ExternalArtifact // opt-in from v1.7 - ExternalArtifact: false, + controller.FeatureGateExternalArtifact: false, // CancelHealthCheckOnNewRevision // opt-in from v1.7 CancelHealthCheckOnNewRevision: false, // DisableConfigWatchers // opt-in from v1.7.3 controller.FeatureGateDisableConfigWatchers: false, + // DirectSourceFetch + // opt-in from v1.8 + controller.FeatureGateDirectSourceFetch: false, } func init() { diff --git a/main.go b/main.go index 0008bcf1..2ea7f5ac 100644 --- a/main.go +++ b/main.go @@ -190,9 +190,9 @@ func main() { } var disableCacheFor []ctrlclient.Object - shouldCache, err := features.Enabled(features.CacheSecretsAndConfigMaps) + shouldCache, err := features.Enabled(runtimeCtrl.FeatureGateCacheSecretsAndConfigMaps) if err != nil { - setupLog.Error(err, "unable to check feature gate "+features.CacheSecretsAndConfigMaps) + setupLog.Error(err, "unable to check feature gate "+runtimeCtrl.FeatureGateCacheSecretsAndConfigMaps) os.Exit(1) } if !shouldCache { @@ -285,15 +285,15 @@ func main() { os.Exit(1) } - additiveCELDependencyCheck, err := features.Enabled(features.AdditiveCELDependencyCheck) + additiveCELDependencyCheck, err := features.Enabled(runtimeCtrl.FeatureGateAdditiveCELDependencyCheck) if err != nil { - setupLog.Error(err, "unable to check feature gate "+features.AdditiveCELDependencyCheck) + setupLog.Error(err, "unable to check feature gate "+runtimeCtrl.FeatureGateAdditiveCELDependencyCheck) os.Exit(1) } - allowExternalArtifact, err := features.Enabled(features.ExternalArtifact) + allowExternalArtifact, err := features.Enabled(runtimeCtrl.FeatureGateExternalArtifact) if err != nil { - setupLog.Error(err, "unable to check feature gate "+features.ExternalArtifact) + setupLog.Error(err, "unable to check feature gate "+runtimeCtrl.FeatureGateExternalArtifact) os.Exit(1) } @@ -323,6 +323,15 @@ func main() { } watchConfigs := !disableConfigWatchers + directSourceFetch, err := features.Enabled(runtimeCtrl.FeatureGateDirectSourceFetch) + if err != nil { + setupLog.Error(err, "unable to check feature gate "+runtimeCtrl.FeatureGateDirectSourceFetch) + os.Exit(1) + } + if directSourceFetch { + setupLog.Info("DirectSourceFetch feature gate is enabled, sources will be fetched directly from the API server bypassing the cache") + } + customStageKinds, err := ssautils.ParseGroupKindSet(customApplyStageKinds) if err != nil { setupLog.Error(err, "unable to parse --custom-apply-stage-kinds") @@ -340,6 +349,7 @@ func main() { ControllerName: controllerName, DefaultServiceAccount: defaultServiceAccount, DependencyRequeueInterval: requeueDependency, + DirectSourceFetch: directSourceFetch, DisallowedFieldManagers: disallowedFieldManagers, EventRecorder: eventRecorder, FailFast: failFast,