Skip to content

Commit 5d60143

Browse files
committed
Add ExternalArtifact feature gate
Signed-off-by: Stefan Prodan <[email protected]>
1 parent 049a805 commit 5d60143

File tree

9 files changed

+147
-85
lines changed

9 files changed

+147
-85
lines changed

docs/spec/v1/kustomizations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Artifact containing the YAML manifests. It has two required fields:
117117
+ [GitRepository](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/gitrepositories.md)
118118
+ [OCIRepository](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/ocirepositories.md)
119119
+ [Bucket](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/buckets.md)
120+
+ [ExternalArtifact](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/externalartifacts.md) (requires `--feature-gates=ExternalArtifact=true` flag)
120121
- `name`: The Name of the referred Source object.
121122

122123
#### Cross-namespace references

internal/controller/kustomization_acl_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ stringData:
120120

121121
t.Run("fails to reconcile from cross-namespace source", func(t *testing.T) {
122122
reconciler.NoCrossNamespaceRefs = true
123+
defer func() { reconciler.NoCrossNamespaceRefs = false }()
123124

124125
revision = "v2.0.0"
125126
err = applyGitRepository(repositoryName, artifact, revision)
@@ -132,5 +133,6 @@ stringData:
132133
}, timeout, time.Second).Should(BeTrue())
133134

134135
g.Expect(readyCondition.Reason).To(Equal(apiacl.AccessDeniedReason))
136+
g.Expect(apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.StalledCondition)).Should(BeTrue())
135137
})
136138
}

internal/controller/kustomization_controller.go

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -89,26 +89,37 @@ type KustomizationReconciler struct {
8989
kuberecorder.EventRecorder
9090
runtimeCtrl.Metrics
9191

92-
artifactFetchRetries int
93-
requeueDependency time.Duration
94-
95-
Mapper apimeta.RESTMapper
96-
APIReader client.Reader
97-
ClusterReader engine.ClusterReaderFactory
98-
ControllerName string
99-
statusManager string
100-
NoCrossNamespaceRefs bool
101-
NoRemoteBases bool
92+
// Kubernetes options
93+
94+
APIReader client.Reader
95+
ClusterReader engine.ClusterReaderFactory
96+
ConcurrentSSA int
97+
ControllerName string
98+
KubeConfigOpts runtimeClient.KubeConfigOptions
99+
Mapper apimeta.RESTMapper
100+
StatusManager string
101+
102+
// Multi-tenancy and security options
103+
104+
DefaultServiceAccount string
105+
DisallowedFieldManagers []string
106+
NoCrossNamespaceRefs bool
107+
NoRemoteBases bool
108+
SOPSAgeSecret string
109+
TokenCache *cache.TokenCache
110+
111+
// Retry and requeue options
112+
113+
ArtifactFetchRetries int
114+
DependencyRequeueInterval time.Duration
115+
116+
// Feature gates
117+
118+
AdditiveCELDependencyCheck bool
119+
AllowExternalArtifact bool
102120
FailFast bool
103-
DefaultServiceAccount string
104-
SOPSAgeSecret string
105-
KubeConfigOpts runtimeClient.KubeConfigOptions
106-
ConcurrentSSA int
107-
DisallowedFieldManagers []string
108-
StrictSubstitutions bool
109121
GroupChangeLog bool
110-
AdditiveCELDependencyCheck bool
111-
TokenCache *cache.TokenCache
122+
StrictSubstitutions bool
112123
}
113124

114125
func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
@@ -207,9 +218,9 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
207218

208219
if acl.IsAccessDenied(err) {
209220
conditions.MarkFalse(obj, meta.ReadyCondition, apiacl.AccessDeniedReason, "%s", err)
210-
log.Error(err, "Access denied to cross-namespace source")
221+
conditions.MarkStalled(obj, apiacl.AccessDeniedReason, "%s", err)
211222
r.event(obj, "", "", eventv1.EventSeverityError, err.Error(), nil)
212-
return ctrl.Result{RequeueAfter: obj.GetRetryInterval()}, nil
223+
return ctrl.Result{}, reconcile.TerminalError(err)
213224
}
214225

215226
// Retry with backoff on transient errors.
@@ -218,10 +229,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
218229

219230
// Requeue the reconciliation if the source artifact is not found.
220231
if artifactSource.GetArtifact() == nil {
221-
msg := fmt.Sprintf("Source artifact not found, retrying in %s", r.requeueDependency.String())
232+
msg := fmt.Sprintf("Source artifact not found, retrying in %s", r.DependencyRequeueInterval.String())
222233
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ArtifactFailedReason, "%s", msg)
223234
log.Info(msg)
224-
return ctrl.Result{RequeueAfter: r.requeueDependency}, nil
235+
return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil
225236
}
226237
revision := artifactSource.GetArtifact().Revision
227238
originRevision := getOriginRevision(artifactSource)
@@ -241,10 +252,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
241252

242253
// Retry on transient errors.
243254
conditions.MarkFalse(obj, meta.ReadyCondition, meta.DependencyNotReadyReason, "%s", err)
244-
msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.requeueDependency.String())
255+
msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.DependencyRequeueInterval.String())
245256
log.Info(msg)
246257
r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, msg, nil)
247-
return ctrl.Result{RequeueAfter: r.requeueDependency}, nil
258+
return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil
248259
}
249260
log.Info("All dependencies are ready, proceeding with reconciliation")
250261
}
@@ -254,10 +265,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
254265

255266
// Requeue at the specified retry interval if the artifact tarball is not found.
256267
if errors.Is(reconcileErr, fetch.ErrFileNotFound) {
257-
msg := fmt.Sprintf("Source is not ready, artifact not found, retrying in %s", r.requeueDependency.String())
268+
msg := fmt.Sprintf("Source is not ready, artifact not found, retrying in %s", r.DependencyRequeueInterval.String())
258269
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ArtifactFailedReason, "%s", msg)
259270
log.Info(msg)
260-
return ctrl.Result{RequeueAfter: r.requeueDependency}, nil
271+
return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil
261272
}
262273

263274
// Broadcast the reconciliation failure and requeue at the specified retry interval.
@@ -318,7 +329,7 @@ func (r *KustomizationReconciler) reconcile(
318329
// Download artifact and extract files to the tmp dir.
319330
fetcher := fetch.New(
320331
fetch.WithLogger(ctrl.LoggerFrom(ctx)),
321-
fetch.WithRetries(r.artifactFetchRetries),
332+
fetch.WithRetries(r.ArtifactFetchRetries),
322333
fetch.WithMaxDownloadSize(tar.UnlimitedUntarSize),
323334
fetch.WithUntar(tar.WithMaxUntarSize(tar.UnlimitedUntarSize)),
324335
fetch.WithHostnameOverwrite(os.Getenv("SOURCE_CONTROLLER_LOCALHOST")),
@@ -625,12 +636,20 @@ func (r *KustomizationReconciler) getSource(ctx context.Context,
625636
Name: obj.Spec.SourceRef.Name,
626637
}
627638

639+
// Check if cross-namespace references are allowed.
628640
if r.NoCrossNamespaceRefs && sourceNamespace != obj.GetNamespace() {
629641
return src, acl.AccessDeniedError(
630642
fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked",
631643
obj.Spec.SourceRef.Kind, namespacedName))
632644
}
633645

646+
// Check if ExternalArtifact kind is allowed.
647+
if obj.Spec.SourceRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact {
648+
return src, acl.AccessDeniedError(
649+
fmt.Sprintf("can't access '%s/%s', %s feature gate is disabled",
650+
obj.Spec.SourceRef.Kind, namespacedName, sourcev1.ExternalArtifactKind))
651+
}
652+
634653
switch obj.Spec.SourceRef.Kind {
635654
case sourcev1.OCIRepositoryKind:
636655
var repository sourcev1.OCIRepository
@@ -1204,7 +1223,7 @@ func (r *KustomizationReconciler) patch(ctx context.Context,
12041223
patchOpts = append(patchOpts,
12051224
patch.WithOwnedConditions{Conditions: ownedConditions},
12061225
patch.WithForceOverwriteConditions{},
1207-
patch.WithFieldOwner(r.statusManager),
1226+
patch.WithFieldOwner(r.StatusManager),
12081227
)
12091228

12101229
// Patch the object status, conditions and finalizers.

internal/controller/kustomization_externalartifact_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"testing"
2525
"time"
2626

27+
apiacl "github.com/fluxcd/pkg/apis/acl"
2728
"github.com/fluxcd/pkg/apis/meta"
2829
"github.com/fluxcd/pkg/testserver"
2930
sourcev1 "github.com/fluxcd/source-controller/api/v1"
@@ -41,6 +42,7 @@ func TestKustomizationReconciler_ExternalArtifact(t *testing.T) {
4142
g := NewWithT(t)
4243
id := "ea-" + randStringRunes(5)
4344
revision := "v1.0.0"
45+
reconciler.AllowExternalArtifact = true
4446

4547
err := createNamespace(id)
4648
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
@@ -129,6 +131,7 @@ stringData:
129131

130132
t.Run("watches for external artifact revision change", func(t *testing.T) {
131133
newRev := "v2.0.0"
134+
132135
err = applyExternalArtifact(eaName, artifact, newRev)
133136
g.Expect(err).NotTo(HaveOccurred())
134137

@@ -142,6 +145,27 @@ stringData:
142145
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
143146
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(newRev))
144147
})
148+
149+
t.Run("fails when external artifact feature gate is disable", func(t *testing.T) {
150+
newRev := "v3.0.0"
151+
reconciler.AllowExternalArtifact = false
152+
153+
err = applyExternalArtifact(eaName, artifact, newRev)
154+
g.Expect(err).NotTo(HaveOccurred())
155+
156+
g.Eventually(func() bool {
157+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
158+
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
159+
return apimeta.IsStatusConditionFalse(resultK.Status.Conditions, meta.ReadyCondition)
160+
}, timeout, time.Second).Should(BeTrue())
161+
162+
g.Expect(readyCondition.Reason).To(Equal(apiacl.AccessDeniedReason))
163+
g.Expect(apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.StalledCondition)).Should(BeTrue())
164+
165+
events := getEvents(resultK.GetName(), nil)
166+
g.Expect(events[len(events)-1].Reason).To(BeIdenticalTo(apiacl.AccessDeniedReason))
167+
g.Expect(events[len(events)-1].Message).To(ContainSubstring("feature gate is disabled"))
168+
})
145169
}
146170

147171
func applyExternalArtifact(objKey client.ObjectKey, artifactName string, revision string) error {

internal/controller/kustomization_indexers.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ import (
2020
"context"
2121
"fmt"
2222

23-
"github.com/fluxcd/pkg/apis/meta"
24-
"github.com/fluxcd/pkg/runtime/conditions"
25-
"github.com/fluxcd/pkg/runtime/dependency"
2623
ctrl "sigs.k8s.io/controller-runtime"
2724
"sigs.k8s.io/controller-runtime/pkg/client"
2825
"sigs.k8s.io/controller-runtime/pkg/handler"
2926
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3027

28+
"github.com/fluxcd/pkg/apis/meta"
29+
"github.com/fluxcd/pkg/runtime/conditions"
30+
"github.com/fluxcd/pkg/runtime/dependency"
31+
3132
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
3233
)
3334

internal/controller/kustomization_manager.go

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22-
"time"
2322

24-
"github.com/fluxcd/pkg/runtime/predicates"
25-
sourcev1 "github.com/fluxcd/source-controller/api/v1"
2623
corev1 "k8s.io/api/core/v1"
2724
"k8s.io/client-go/util/workqueue"
2825
ctrl "sigs.k8s.io/controller-runtime"
@@ -33,15 +30,17 @@ import (
3330
"sigs.k8s.io/controller-runtime/pkg/predicate"
3431
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3532

33+
"github.com/fluxcd/pkg/runtime/predicates"
34+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
35+
3636
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
3737
)
3838

3939
// KustomizationReconcilerOptions contains options for the KustomizationReconciler.
4040
type KustomizationReconcilerOptions struct {
41-
HTTPRetry int
42-
DependencyRequeueInterval time.Duration
43-
RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
44-
WatchConfigsPredicate predicate.Predicate
41+
RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
42+
WatchConfigsPredicate predicate.Predicate
43+
WatchExternalArtifacts bool
4544
}
4645

4746
// SetupWithManager sets up the controller with the Manager.
@@ -57,12 +56,6 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl
5756
indexSecret = ".metadata.secret"
5857
)
5958

60-
// Index the Kustomizations by the ExternalArtifact references they (may) point at.
61-
if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexExternalArtifact,
62-
r.indexBy(sourcev1.ExternalArtifactKind)); err != nil {
63-
return fmt.Errorf("failed creating index %s: %w", indexExternalArtifact, err)
64-
}
65-
6659
// Index the Kustomizations by the OCIRepository references they (may) point at.
6760
if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexOCIRepository,
6861
r.indexBy(sourcev1.OCIRepositoryKind)); err != nil {
@@ -81,6 +74,14 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl
8174
return fmt.Errorf("failed creating index %s: %w", indexBucket, err)
8275
}
8376

77+
// Index the Kustomizations by the ExternalArtifact references they (may) point at (if enabled).
78+
if opts.WatchExternalArtifacts {
79+
if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexExternalArtifact,
80+
r.indexBy(sourcev1.ExternalArtifactKind)); err != nil {
81+
return fmt.Errorf("failed creating index %s: %w", indexExternalArtifact, err)
82+
}
83+
}
84+
8485
// Index the Kustomization by the ConfigMap references they point to.
8586
if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexConfigMap,
8687
func(o client.Object) []string {
@@ -128,19 +129,10 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl
128129
return fmt.Errorf("failed creating index %s: %w", indexSecret, err)
129130
}
130131

131-
r.requeueDependency = opts.DependencyRequeueInterval
132-
r.statusManager = fmt.Sprintf("gotk-%s", r.ControllerName)
133-
r.artifactFetchRetries = opts.HTTPRetry
134-
135-
return ctrl.NewControllerManagedBy(mgr).
132+
ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
136133
For(&kustomizev1.Kustomization{}, builder.WithPredicates(
137134
predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
138135
)).
139-
Watches(
140-
&sourcev1.ExternalArtifact{},
141-
handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexExternalArtifact)),
142-
builder.WithPredicates(SourceRevisionChangePredicate{}),
143-
).
144136
Watches(
145137
&sourcev1.OCIRepository{},
146138
handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexOCIRepository)),
@@ -165,9 +157,15 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl
165157
&corev1.Secret{},
166158
handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexSecret)),
167159
builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate),
168-
).
169-
WithOptions(controller.Options{
170-
RateLimiter: opts.RateLimiter,
171-
}).
172-
Complete(r)
160+
)
161+
162+
if opts.WatchExternalArtifacts {
163+
ctrlBuilder = ctrlBuilder.Watches(
164+
&sourcev1.ExternalArtifact{},
165+
handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexExternalArtifact)),
166+
builder.WithPredicates(SourceRevisionChangePredicate{}),
167+
)
168+
}
169+
170+
return ctrlBuilder.WithOptions(controller.Options{RateLimiter: opts.RateLimiter}).Complete(r)
173171
}

internal/controller/suite_test.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,21 @@ func TestMain(m *testing.M) {
174174
kstatusInProgressCheck = kcheck.NewInProgressChecker(testEnv.Client)
175175
kstatusInProgressCheck.DisableFetch = true
176176
reconciler = &KustomizationReconciler{
177-
ControllerName: controllerName,
178-
Client: testEnv,
179-
Mapper: testEnv.GetRESTMapper(),
180-
APIReader: testEnv,
181-
EventRecorder: testEnv.GetEventRecorderFor(controllerName),
182-
Metrics: testMetricsH,
183-
ConcurrentSSA: 4,
184-
DisallowedFieldManagers: []string{overrideManagerName},
185-
SOPSAgeSecret: sopsAgeSecret,
177+
ControllerName: controllerName,
178+
StatusManager: fmt.Sprintf("gotk-%s", controllerName),
179+
Client: testEnv,
180+
Mapper: testEnv.GetRESTMapper(),
181+
APIReader: testEnv,
182+
EventRecorder: testEnv.GetEventRecorderFor(controllerName),
183+
Metrics: testMetricsH,
184+
DependencyRequeueInterval: 2 * time.Second,
185+
ConcurrentSSA: 4,
186+
DisallowedFieldManagers: []string{overrideManagerName},
187+
SOPSAgeSecret: sopsAgeSecret,
186188
}
187189
if err := (reconciler).SetupWithManager(ctx, testEnv, KustomizationReconcilerOptions{
188-
DependencyRequeueInterval: 2 * time.Second,
189-
WatchConfigsPredicate: predicate.Not(predicate.Funcs{}),
190+
WatchConfigsPredicate: predicate.Not(predicate.Funcs{}),
191+
WatchExternalArtifacts: true,
190192
}); err != nil {
191193
panic(fmt.Sprintf("Failed to start KustomizationReconciler: %v", err))
192194
}

internal/features/features.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ const (
5656
// should be additive, meaning that the built-in readiness check will
5757
// be added to the user-defined CEL expressions.
5858
AdditiveCELDependencyCheck = "AdditiveCELDependencyCheck"
59+
60+
// ExternalArtifact controls whether the ExternalArtifact source type is enabled.
61+
ExternalArtifact = "ExternalArtifact"
5962
)
6063

6164
var features = map[string]bool{
@@ -77,6 +80,9 @@ var features = map[string]bool{
7780
// AdditiveCELDependencyCheck
7881
// opt-in from v1.7
7982
AdditiveCELDependencyCheck: false,
83+
// ExternalArtifact
84+
// opt-in from v1.7
85+
ExternalArtifact: false,
8086
}
8187

8288
func init() {

0 commit comments

Comments
 (0)