Skip to content

Commit a695718

Browse files
committed
Add DirectSourceFetch feature gate to bypass cache for source objects
This feature gate enables fetching source objects (GitRepository, OCIRepository, Bucket) directly from the API server using APIReader, bypassing the controller's cache. This can be useful when immediate consistency is required for source object reads. When enabled via --feature-gates=DirectSourceFetch=true: - Source objects are fetched using r.APIReader instead of r.Client - A log message is emitted at startup indicating the feature is active Changes: - Add DirectSourceFetch field to KustomizationReconciler struct - Update getSource() to use APIReader when feature is enabled - Register feature gate with default value false (opt-in) - Add unit tests for GitRepository, OCIRepository, and Bucket sources - Update pkg/runtime dependency to v0.100.1 Signed-off-by: Dipti Pai <diptipai89@outlook.com>
1 parent d527a7b commit a695718

6 files changed

Lines changed: 355 additions & 31 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ require (
2828
github.com/fluxcd/pkg/cache v0.13.0
2929
github.com/fluxcd/pkg/http/fetch v0.22.0
3030
github.com/fluxcd/pkg/kustomize v1.27.0
31-
github.com/fluxcd/pkg/runtime v0.100.0
31+
github.com/fluxcd/pkg/runtime v0.100.1
3232
github.com/fluxcd/pkg/ssa v0.68.0
3333
github.com/fluxcd/pkg/tar v0.17.0
3434
github.com/fluxcd/pkg/testserver v0.13.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ github.com/fluxcd/pkg/http/fetch v0.22.0 h1:FT8CfstPE/e7+KRxNrx8ZJ1Uj5rkR5wXOtvQ
210210
github.com/fluxcd/pkg/http/fetch v0.22.0/go.mod h1:X+8wF3peP79TyyDSgCJiavz+fAcYaf7CRXSeu7ccsPA=
211211
github.com/fluxcd/pkg/kustomize v1.27.0 h1:bWoWVaHV1ZRo3Ei1JXpY58hJK25WWna+az5jj6zseE0=
212212
github.com/fluxcd/pkg/kustomize v1.27.0/go.mod h1:KKb26vz5EApyOrgencDlvixJnuI6cnkWGks95sK9AFs=
213-
github.com/fluxcd/pkg/runtime v0.100.0 h1:7k2T/zlOLZ+knVr5fGB6cqq3Dr9D1k2jEe6AJo91JlI=
214-
github.com/fluxcd/pkg/runtime v0.100.0/go.mod h1:SctSsHvFwUfiOVP1zirP6mo7I8wQtXeWVl2lNQWal88=
213+
github.com/fluxcd/pkg/runtime v0.100.1 h1:UiPmgY8Yv7UF06MT5T8AG9uDGNszm75/DQtK6JEhnrM=
214+
github.com/fluxcd/pkg/runtime v0.100.1/go.mod h1:SctSsHvFwUfiOVP1zirP6mo7I8wQtXeWVl2lNQWal88=
215215
github.com/fluxcd/pkg/sourceignore v0.17.0 h1:Z72nruRMhC15zIEpWoDrAcJcJ1El6QDnP/aRDfE4WOA=
216216
github.com/fluxcd/pkg/sourceignore v0.17.0/go.mod h1:3e/VmYLId0pI/H5sK7W9Ibif+j0Ahns9RxNjDMtTTfY=
217217
github.com/fluxcd/pkg/ssa v0.68.0 h1:hdRFrBJO9dh04200tNJljpi4TOArHC0nq+LUFZxMgKc=

internal/controller/kustomization_controller.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ import (
7070

7171
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
7272
"github.com/fluxcd/kustomize-controller/internal/decryptor"
73-
"github.com/fluxcd/kustomize-controller/internal/features"
7473
"github.com/fluxcd/kustomize-controller/internal/inventory"
7574
)
7675

@@ -118,6 +117,7 @@ type KustomizationReconciler struct {
118117

119118
AdditiveCELDependencyCheck bool
120119
AllowExternalArtifact bool
120+
DirectSourceFetch bool
121121
FailFast bool
122122
GroupChangeLog bool
123123
StrictSubstitutions bool
@@ -686,13 +686,19 @@ func (r *KustomizationReconciler) getSource(ctx context.Context,
686686
if obj.Spec.SourceRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact {
687687
return src, acl.AccessDeniedError(
688688
fmt.Sprintf("can't access '%s/%s', %s feature gate is disabled",
689-
obj.Spec.SourceRef.Kind, namespacedName, features.ExternalArtifact))
689+
obj.Spec.SourceRef.Kind, namespacedName, runtimeCtrl.FeatureGateExternalArtifact))
690+
}
691+
692+
// Use APIReader to bypass the cache when DirectSourceFetch is enabled.
693+
var reader client.Reader = r.Client
694+
if r.DirectSourceFetch {
695+
reader = r.APIReader
690696
}
691697

692698
switch obj.Spec.SourceRef.Kind {
693699
case sourcev1.OCIRepositoryKind:
694700
var repository sourcev1.OCIRepository
695-
err := r.Client.Get(ctx, namespacedName, &repository)
701+
err := reader.Get(ctx, namespacedName, &repository)
696702
if err != nil {
697703
if apierrors.IsNotFound(err) {
698704
return src, err
@@ -702,7 +708,7 @@ func (r *KustomizationReconciler) getSource(ctx context.Context,
702708
src = &repository
703709
case sourcev1.GitRepositoryKind:
704710
var repository sourcev1.GitRepository
705-
err := r.Client.Get(ctx, namespacedName, &repository)
711+
err := reader.Get(ctx, namespacedName, &repository)
706712
if err != nil {
707713
if apierrors.IsNotFound(err) {
708714
return src, err
@@ -712,7 +718,7 @@ func (r *KustomizationReconciler) getSource(ctx context.Context,
712718
src = &repository
713719
case sourcev1.BucketKind:
714720
var bucket sourcev1.Bucket
715-
err := r.Client.Get(ctx, namespacedName, &bucket)
721+
err := reader.Get(ctx, namespacedName, &bucket)
716722
if err != nil {
717723
if apierrors.IsNotFound(err) {
718724
return src, err
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"testing"
23+
"time"
24+
25+
"github.com/fluxcd/pkg/apis/meta"
26+
"github.com/fluxcd/pkg/testserver"
27+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
28+
. "github.com/onsi/gomega"
29+
apimeta "k8s.io/apimachinery/pkg/api/meta"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/types"
32+
"sigs.k8s.io/controller-runtime/pkg/client"
33+
34+
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
35+
)
36+
37+
func TestKustomizationReconciler_DirectSourceFetch(t *testing.T) {
38+
g := NewWithT(t)
39+
id := "direct-fetch-" + randStringRunes(5)
40+
revision := "v1.0.0"
41+
42+
err := createNamespace(id)
43+
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
44+
45+
err = createKubeConfigSecret(id)
46+
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
47+
48+
manifests := func(name string, data string) []testserver.File {
49+
return []testserver.File{
50+
{
51+
Name: "configmap.yaml",
52+
Body: fmt.Sprintf(`---
53+
apiVersion: v1
54+
kind: ConfigMap
55+
metadata:
56+
name: %[1]s
57+
data:
58+
key: "%[2]s"
59+
`, name, data),
60+
},
61+
}
62+
}
63+
64+
artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5)))
65+
g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files")
66+
67+
repositoryName := types.NamespacedName{
68+
Name: fmt.Sprintf("direct-fetch-repo-%s", randStringRunes(5)),
69+
Namespace: id,
70+
}
71+
72+
err = applyGitRepository(repositoryName, artifact, revision)
73+
g.Expect(err).NotTo(HaveOccurred())
74+
75+
kustomizationKey := types.NamespacedName{
76+
Name: fmt.Sprintf("direct-fetch-kust-%s", randStringRunes(5)),
77+
Namespace: id,
78+
}
79+
80+
t.Run("reconciles with DirectSourceFetch enabled (uses APIReader)", func(t *testing.T) {
81+
g := NewWithT(t)
82+
83+
// Enable DirectSourceFetch to use APIReader
84+
reconciler.DirectSourceFetch = true
85+
defer func() { reconciler.DirectSourceFetch = false }() // Reset after test
86+
87+
kustomization := &kustomizev1.Kustomization{
88+
ObjectMeta: metav1.ObjectMeta{
89+
Name: kustomizationKey.Name + "-direct",
90+
Namespace: kustomizationKey.Namespace,
91+
},
92+
Spec: kustomizev1.KustomizationSpec{
93+
Interval: metav1.Duration{Duration: reconciliationInterval},
94+
Path: "./",
95+
KubeConfig: &meta.KubeConfigReference{
96+
SecretRef: &meta.SecretKeyReference{
97+
Name: "kubeconfig",
98+
},
99+
},
100+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
101+
Name: repositoryName.Name,
102+
Namespace: repositoryName.Namespace,
103+
Kind: sourcev1.GitRepositoryKind,
104+
},
105+
TargetNamespace: id,
106+
},
107+
}
108+
109+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
110+
t.Cleanup(func() {
111+
g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed())
112+
})
113+
114+
resultK := &kustomizev1.Kustomization{}
115+
g.Eventually(func() bool {
116+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
117+
return apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.ReadyCondition) &&
118+
resultK.Status.LastAppliedRevision == revision
119+
}, timeout, time.Second).Should(BeTrue())
120+
})
121+
122+
t.Run("source not found with DirectSourceFetch enabled", func(t *testing.T) {
123+
g := NewWithT(t)
124+
125+
// Enable DirectSourceFetch to use APIReader
126+
reconciler.DirectSourceFetch = true
127+
defer func() { reconciler.DirectSourceFetch = false }() // Reset after test
128+
129+
kustomization := &kustomizev1.Kustomization{
130+
ObjectMeta: metav1.ObjectMeta{
131+
Name: kustomizationKey.Name + "-notfound",
132+
Namespace: kustomizationKey.Namespace,
133+
},
134+
Spec: kustomizev1.KustomizationSpec{
135+
Interval: metav1.Duration{Duration: reconciliationInterval},
136+
Path: "./",
137+
KubeConfig: &meta.KubeConfigReference{
138+
SecretRef: &meta.SecretKeyReference{
139+
Name: "kubeconfig",
140+
},
141+
},
142+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
143+
Name: "non-existent-repo",
144+
Namespace: id,
145+
Kind: sourcev1.GitRepositoryKind,
146+
},
147+
TargetNamespace: id,
148+
},
149+
}
150+
151+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
152+
t.Cleanup(func() {
153+
g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed())
154+
})
155+
156+
resultK := &kustomizev1.Kustomization{}
157+
g.Eventually(func() bool {
158+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
159+
ready := apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
160+
if ready == nil {
161+
return false
162+
}
163+
return ready.Status == metav1.ConditionFalse &&
164+
ready.Reason == meta.ArtifactFailedReason
165+
}, timeout, time.Second).Should(BeTrue())
166+
})
167+
}
168+
169+
func TestKustomizationReconciler_DirectSourceFetch_OCIRepository(t *testing.T) {
170+
g := NewWithT(t)
171+
id := "direct-fetch-oci-" + randStringRunes(5)
172+
173+
err := createNamespace(id)
174+
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
175+
176+
err = createKubeConfigSecret(id)
177+
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
178+
179+
t.Run("handles OCIRepository with DirectSourceFetch enabled", func(t *testing.T) {
180+
g := NewWithT(t)
181+
182+
// Enable DirectSourceFetch to use APIReader
183+
reconciler.DirectSourceFetch = true
184+
defer func() { reconciler.DirectSourceFetch = false }() // Reset after test
185+
186+
// Create an OCIRepository (without artifact - just to test source fetching)
187+
ociRepoName := types.NamespacedName{
188+
Name: fmt.Sprintf("oci-repo-%s", randStringRunes(5)),
189+
Namespace: id,
190+
}
191+
192+
ociRepo := &sourcev1.OCIRepository{
193+
ObjectMeta: metav1.ObjectMeta{
194+
Name: ociRepoName.Name,
195+
Namespace: ociRepoName.Namespace,
196+
},
197+
Spec: sourcev1.OCIRepositorySpec{
198+
URL: "oci://ghcr.io/test/repo",
199+
Interval: metav1.Duration{Duration: time.Minute},
200+
},
201+
}
202+
g.Expect(k8sClient.Create(context.Background(), ociRepo)).To(Succeed())
203+
t.Cleanup(func() {
204+
g.Expect(k8sClient.Delete(context.Background(), ociRepo)).To(Succeed())
205+
})
206+
207+
kustomization := &kustomizev1.Kustomization{
208+
ObjectMeta: metav1.ObjectMeta{
209+
Name: fmt.Sprintf("kust-oci-%s", randStringRunes(5)),
210+
Namespace: id,
211+
},
212+
Spec: kustomizev1.KustomizationSpec{
213+
Interval: metav1.Duration{Duration: reconciliationInterval},
214+
Path: "./",
215+
KubeConfig: &meta.KubeConfigReference{
216+
SecretRef: &meta.SecretKeyReference{
217+
Name: "kubeconfig",
218+
},
219+
},
220+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
221+
Name: ociRepoName.Name,
222+
Namespace: ociRepoName.Namespace,
223+
Kind: sourcev1.OCIRepositoryKind,
224+
},
225+
TargetNamespace: id,
226+
},
227+
}
228+
229+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
230+
t.Cleanup(func() {
231+
g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed())
232+
})
233+
234+
// The kustomization should be able to find the source (even though it has no artifact)
235+
// and eventually report that the artifact is not ready
236+
resultK := &kustomizev1.Kustomization{}
237+
g.Eventually(func() bool {
238+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
239+
// Source should be found but artifact should not be ready
240+
return len(resultK.Status.Conditions) > 0
241+
}, timeout, time.Second).Should(BeTrue())
242+
})
243+
}
244+
245+
func TestKustomizationReconciler_DirectSourceFetch_Bucket(t *testing.T) {
246+
g := NewWithT(t)
247+
id := "direct-fetch-bucket-" + randStringRunes(5)
248+
249+
err := createNamespace(id)
250+
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
251+
252+
err = createKubeConfigSecret(id)
253+
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
254+
255+
t.Run("handles Bucket with DirectSourceFetch enabled", func(t *testing.T) {
256+
g := NewWithT(t)
257+
258+
// Enable DirectSourceFetch to use APIReader
259+
reconciler.DirectSourceFetch = true
260+
defer func() { reconciler.DirectSourceFetch = false }() // Reset after test
261+
262+
// Create a Bucket source (without artifact - just to test source fetching)
263+
bucketName := types.NamespacedName{
264+
Name: fmt.Sprintf("bucket-%s", randStringRunes(5)),
265+
Namespace: id,
266+
}
267+
268+
bucket := &sourcev1.Bucket{
269+
ObjectMeta: metav1.ObjectMeta{
270+
Name: bucketName.Name,
271+
Namespace: bucketName.Namespace,
272+
},
273+
Spec: sourcev1.BucketSpec{
274+
BucketName: "test-bucket",
275+
Endpoint: "s3.amazonaws.com",
276+
Interval: metav1.Duration{Duration: time.Minute},
277+
},
278+
}
279+
g.Expect(k8sClient.Create(context.Background(), bucket)).To(Succeed())
280+
t.Cleanup(func() {
281+
g.Expect(k8sClient.Delete(context.Background(), bucket)).To(Succeed())
282+
})
283+
284+
kustomization := &kustomizev1.Kustomization{
285+
ObjectMeta: metav1.ObjectMeta{
286+
Name: fmt.Sprintf("kust-bucket-%s", randStringRunes(5)),
287+
Namespace: id,
288+
},
289+
Spec: kustomizev1.KustomizationSpec{
290+
Interval: metav1.Duration{Duration: reconciliationInterval},
291+
Path: "./",
292+
KubeConfig: &meta.KubeConfigReference{
293+
SecretRef: &meta.SecretKeyReference{
294+
Name: "kubeconfig",
295+
},
296+
},
297+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
298+
Name: bucketName.Name,
299+
Namespace: bucketName.Namespace,
300+
Kind: sourcev1.BucketKind,
301+
},
302+
TargetNamespace: id,
303+
},
304+
}
305+
306+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
307+
t.Cleanup(func() {
308+
g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed())
309+
})
310+
311+
// The kustomization should be able to find the source (even though it has no artifact)
312+
resultK := &kustomizev1.Kustomization{}
313+
g.Eventually(func() bool {
314+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
315+
// Source should be found but artifact should not be ready
316+
return len(resultK.Status.Conditions) > 0
317+
}, timeout, time.Second).Should(BeTrue())
318+
})
319+
}

0 commit comments

Comments
 (0)