From 5edcf5b394d21364d0892a47b693dad7268b0772 Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 12:57:08 +0300
Subject: [PATCH 1/5] api: Add the `readyExpr` field to `dependsOn` Extend the
readiness evaluation of dependencies with CEL expressions
Signed-off-by: Stefan Prodan
---
api/v1/kustomization_types.go | 18 ++++-
api/v1/reference_types.go | 26 ++++++-
api/v1/zz_generated.deepcopy.go | 17 ++++-
...mize.toolkit.fluxcd.io_kustomizations.yaml | 20 +++--
docs/api/v1/kustomize.md | 73 +++++++++++++++++--
5 files changed, 136 insertions(+), 18 deletions(-)
diff --git a/api/v1/kustomization_types.go b/api/v1/kustomization_types.go
index 4854aaa92..0852d7deb 100644
--- a/api/v1/kustomization_types.go
+++ b/api/v1/kustomization_types.go
@@ -49,11 +49,11 @@ type KustomizationSpec struct {
// +optional
CommonMetadata *CommonMetadata `json:"commonMetadata,omitempty"`
- // DependsOn may contain a meta.NamespacedObjectReference slice
+ // DependsOn may contain a DependencyReference slice
// with references to Kustomization resources that must be ready before this
// Kustomization can be reconciled.
// +optional
- DependsOn []meta.NamespacedObjectReference `json:"dependsOn,omitempty"`
+ DependsOn []DependencyReference `json:"dependsOn,omitempty"`
// Decrypt Kubernetes secrets before applying them on the cluster.
// +optional
@@ -333,9 +333,19 @@ func (in Kustomization) GetDeletionPolicy() string {
return in.Spec.DeletionPolicy
}
-// GetDependsOn returns the list of dependencies across-namespaces.
+// GetDependsOn returns the dependencies as a list of meta.NamespacedObjectReference.
+//
+// This function makes the Kustomization type conformant with the meta.ObjectWithDependencies interface
+// and allows the controller-runtime to index Kustomizations by their dependencies.
func (in Kustomization) GetDependsOn() []meta.NamespacedObjectReference {
- return in.Spec.DependsOn
+ deps := make([]meta.NamespacedObjectReference, len(in.Spec.DependsOn))
+ for i := range in.Spec.DependsOn {
+ deps[i] = meta.NamespacedObjectReference{
+ Name: in.Spec.DependsOn[i].Name,
+ Namespace: in.Spec.DependsOn[i].Namespace,
+ }
+ }
+ return deps
}
// GetConditions returns the status conditions of the object.
diff --git a/api/v1/reference_types.go b/api/v1/reference_types.go
index cf0d9abd2..3a2412d71 100644
--- a/api/v1/reference_types.go
+++ b/api/v1/reference_types.go
@@ -16,7 +16,9 @@ limitations under the License.
package v1
-import "fmt"
+import (
+ "fmt"
+)
// CrossNamespaceSourceReference contains enough information to let you locate the
// typed Kubernetes resource object at cluster level.
@@ -40,9 +42,31 @@ type CrossNamespaceSourceReference struct {
Namespace string `json:"namespace,omitempty"`
}
+// String returns a string representation of the CrossNamespaceSourceReference
+// in the format "Kind/Name" or "Kind/Namespace/Name" if Namespace is set.
func (s *CrossNamespaceSourceReference) String() string {
if s.Namespace != "" {
return fmt.Sprintf("%s/%s/%s", s.Kind, s.Namespace, s.Name)
}
return fmt.Sprintf("%s/%s", s.Kind, s.Name)
}
+
+// DependencyReference defines a Kustomization dependency on another Kustomization resource.
+type DependencyReference struct {
+ // Name of the referent.
+ // +required
+ Name string `json:"name"`
+
+ // Namespace of the referent, defaults to the namespace of the Kustomization
+ // resource object that contains the reference.
+ // +optional
+ Namespace string `json:"namespace,omitempty"`
+
+ // ReadyExpr is a CEL expression that can be used to assess the readiness
+ // of a dependency. When specified, the built-in readiness check
+ // is replaced by the logic defined in the CEL expression.
+ // To make the CEL expression additive to the built-in readiness check,
+ // the feature gate `AdditiveCELDependencyCheck` must be set to `true`.
+ // +optional
+ ReadyExpr string `json:"readyExpr,omitempty"`
+}
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 8164afba3..fe4bbf878 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -91,6 +91,21 @@ func (in *Decryption) DeepCopy() *Decryption {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DependencyReference) DeepCopyInto(out *DependencyReference) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependencyReference.
+func (in *DependencyReference) DeepCopy() *DependencyReference {
+ if in == nil {
+ return nil
+ }
+ out := new(DependencyReference)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Kustomization) DeepCopyInto(out *Kustomization) {
*out = *in
@@ -160,7 +175,7 @@ func (in *KustomizationSpec) DeepCopyInto(out *KustomizationSpec) {
}
if in.DependsOn != nil {
in, out := &in.DependsOn, &out.DependsOn
- *out = make([]meta.NamespacedObjectReference, len(*in))
+ *out = make([]DependencyReference, len(*in))
copy(*out, *in)
}
if in.Decryption != nil {
diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
index e38c8f5c1..850627fdb 100644
--- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
+++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
@@ -123,20 +123,28 @@ spec:
type: string
dependsOn:
description: |-
- DependsOn may contain a meta.NamespacedObjectReference slice
+ DependsOn may contain a DependencyReference slice
with references to Kustomization resources that must be ready before this
Kustomization can be reconciled.
items:
- description: |-
- NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
- namespace.
+ description: DependencyReference defines a Kustomization dependency
+ on another Kustomization resource.
properties:
name:
description: Name of the referent.
type: string
namespace:
- description: Namespace of the referent, when not specified it
- acts as LocalObjectReference.
+ description: |-
+ Namespace of the referent, defaults to the namespace of the Kustomization
+ resource object that contains the reference.
+ type: string
+ readyExpr:
+ description: |-
+ ReadyExpr is a CEL expression that can be used to assess the readiness
+ of a dependency. When specified, the built-in readiness check
+ is replaced by the logic defined in the CEL expression.
+ To make the CEL expression additive to the built-in readiness check,
+ the feature gate `AdditiveCELDependencyCheck` must be set to `true`.
type: string
required:
- name
diff --git a/docs/api/v1/kustomize.md b/docs/api/v1/kustomize.md
index a109c61e5..70ae86119 100644
--- a/docs/api/v1/kustomize.md
+++ b/docs/api/v1/kustomize.md
@@ -89,14 +89,14 @@ overridden if its key matches a common one.
dependsOn
-
-[]github.com/fluxcd/pkg/apis/meta.NamespacedObjectReference
+
+[]DependencyReference
|
(Optional)
- DependsOn may contain a meta.NamespacedObjectReference slice
+ DependsOn may contain a DependencyReference slice
with references to Kustomization resources that must be ready before this
Kustomization can be reconciled.
|
@@ -609,6 +609,67 @@ field.
+
+
+(Appears on:
+KustomizationSpec)
+
+DependencyReference defines a Kustomization dependency on another Kustomization resource.
+
@@ -647,14 +708,14 @@ overridden if its key matches a common one.
dependsOn
-
-[]github.com/fluxcd/pkg/apis/meta.NamespacedObjectReference
+
+[]DependencyReference
|
(Optional)
- DependsOn may contain a meta.NamespacedObjectReference slice
+ DependsOn may contain a DependencyReference slice
with references to Kustomization resources that must be ready before this
Kustomization can be reconciled.
|
From c2754dd5dedae6470befd44bfd99158746ce65ba Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 12:57:52 +0300
Subject: [PATCH 2/5] controller: Add `AdditiveCELDependencyCheck` feature gate
Signed-off-by: Stefan Prodan
---
internal/features/features.go | 12 ++++++++--
main.go | 43 ++++++++++++++++++++---------------
2 files changed, 35 insertions(+), 20 deletions(-)
diff --git a/internal/features/features.go b/internal/features/features.go
index b70ce1ccf..c8adfcd19 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -48,9 +48,14 @@ const (
// but is missing from the input vars.
StrictPostBuildSubstitutions = "StrictPostBuildSubstitutions"
- // GroupChangelog controls groups kubernetes objects names on log output
- // reduces cardinality of logs when logging to elasticsearch
+ // GroupChangeLog controls whether to group Kubernetes objects names in log output
+ // 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"
)
var features = map[string]bool{
@@ -69,6 +74,9 @@ var features = map[string]bool{
// GroupChangeLog
// opt-in from v1.5
GroupChangeLog: false,
+ // AdditiveCELDependencyCheck
+ // opt-in from v1.7
+ AdditiveCELDependencyCheck: false,
}
func init() {
diff --git a/main.go b/main.go
index e5b55fea5..0c56d9e9f 100644
--- a/main.go
+++ b/main.go
@@ -264,6 +264,12 @@ func main() {
os.Exit(1)
}
+ additiveCELDependencyCheck, err := features.Enabled(features.AdditiveCELDependencyCheck)
+ if err != nil {
+ setupLog.Error(err, "unable to check feature gate "+features.AdditiveCELDependencyCheck)
+ os.Exit(1)
+ }
+
var tokenCache *pkgcache.TokenCache
if tokenCacheOptions.MaxSize > 0 {
var err error
@@ -278,24 +284,25 @@ func main() {
}
if err = (&controller.KustomizationReconciler{
- ControllerName: controllerName,
- DefaultServiceAccount: defaultServiceAccount,
- SOPSAgeSecret: sopsAgeSecret,
- Client: mgr.GetClient(),
- Mapper: restMapper,
- APIReader: mgr.GetAPIReader(),
- Metrics: metricsH,
- EventRecorder: eventRecorder,
- NoCrossNamespaceRefs: aclOptions.NoCrossNamespaceRefs,
- NoRemoteBases: noRemoteBases,
- FailFast: failFast,
- ConcurrentSSA: concurrentSSA,
- KubeConfigOpts: kubeConfigOpts,
- ClusterReader: clusterReader,
- DisallowedFieldManagers: disallowedFieldManagers,
- StrictSubstitutions: strictSubstitutions,
- GroupChangeLog: groupChangeLog,
- TokenCache: tokenCache,
+ ControllerName: controllerName,
+ DefaultServiceAccount: defaultServiceAccount,
+ SOPSAgeSecret: sopsAgeSecret,
+ Client: mgr.GetClient(),
+ Mapper: restMapper,
+ APIReader: mgr.GetAPIReader(),
+ Metrics: metricsH,
+ EventRecorder: eventRecorder,
+ NoCrossNamespaceRefs: aclOptions.NoCrossNamespaceRefs,
+ NoRemoteBases: noRemoteBases,
+ FailFast: failFast,
+ ConcurrentSSA: concurrentSSA,
+ KubeConfigOpts: kubeConfigOpts,
+ ClusterReader: clusterReader,
+ DisallowedFieldManagers: disallowedFieldManagers,
+ StrictSubstitutions: strictSubstitutions,
+ GroupChangeLog: groupChangeLog,
+ AdditiveCELDependencyCheck: additiveCELDependencyCheck,
+ TokenCache: tokenCache,
}).SetupWithManager(ctx, mgr, controller.KustomizationReconcilerOptions{
DependencyRequeueInterval: requeueDependency,
HTTPRetry: httpRetry,
From e0e6e22272b6859ff00285324e01007fb6671d2e Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 12:59:16 +0300
Subject: [PATCH 3/5] controller: Implement CEL evaluation for dependency
checks
Signed-off-by: Stefan Prodan
---
go.mod | 4 +-
go.sum | 4 +-
internal/controller/constants.go | 5 +-
.../controller/kustomization_controller.go | 168 ++++++++++----
.../kustomization_dependson_test.go | 215 +++++++++++++++++-
5 files changed, 337 insertions(+), 59 deletions(-)
diff --git a/go.mod b/go.mod
index ebd0e776a..e706705ae 100644
--- a/go.mod
+++ b/go.mod
@@ -34,6 +34,7 @@ require (
github.com/fluxcd/pkg/testserver v0.11.0
github.com/fluxcd/source-controller/api v1.6.0
github.com/getsops/sops/v3 v3.10.2
+ github.com/google/cel-go v0.23.2
github.com/hashicorp/vault/api v1.20.0
github.com/onsi/gomega v1.37.0
github.com/opencontainers/go-digest v1.0.0
@@ -147,11 +148,10 @@ require (
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/btree v1.1.3 // indirect
- github.com/google/cel-go v0.23.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
diff --git a/go.sum b/go.sum
index 4b3f4d5c3..250939bd0 100644
--- a/go.sum
+++ b/go.sum
@@ -258,8 +258,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
-github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
diff --git a/internal/controller/constants.go b/internal/controller/constants.go
index ed9a9866f..16041c983 100644
--- a/internal/controller/constants.go
+++ b/internal/controller/constants.go
@@ -16,4 +16,7 @@ limitations under the License.
package controller
-const OCIArtifactOriginRevisionAnnotation = "org.opencontainers.image.revision"
+const (
+ OCIArtifactOriginRevisionAnnotation = "org.opencontainers.image.revision"
+ TerminalErrorMessage = "Reconciliation failed terminally due to configuration error"
+)
diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go
index cdba07e45..5f56f048c 100644
--- a/internal/controller/kustomization_controller.go
+++ b/internal/controller/kustomization_controller.go
@@ -26,6 +26,7 @@ import (
"time"
securejoin "github.com/cyphar/filepath-securejoin"
+ celtypes "github.com/google/cel-go/common/types"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
@@ -96,22 +97,23 @@ type KustomizationReconciler struct {
artifactFetchRetries int
requeueDependency time.Duration
- Mapper apimeta.RESTMapper
- APIReader client.Reader
- ClusterReader engine.ClusterReaderFactory
- ControllerName string
- statusManager string
- NoCrossNamespaceRefs bool
- NoRemoteBases bool
- FailFast bool
- DefaultServiceAccount string
- SOPSAgeSecret string
- KubeConfigOpts runtimeClient.KubeConfigOptions
- ConcurrentSSA int
- DisallowedFieldManagers []string
- StrictSubstitutions bool
- GroupChangeLog bool
- TokenCache *cache.TokenCache
+ Mapper apimeta.RESTMapper
+ APIReader client.Reader
+ ClusterReader engine.ClusterReaderFactory
+ ControllerName string
+ statusManager string
+ NoCrossNamespaceRefs bool
+ NoRemoteBases bool
+ FailFast bool
+ DefaultServiceAccount string
+ SOPSAgeSecret string
+ KubeConfigOpts runtimeClient.KubeConfigOptions
+ ConcurrentSSA int
+ DisallowedFieldManagers []string
+ StrictSubstitutions bool
+ GroupChangeLog bool
+ AdditiveCELDependencyCheck bool
+ TokenCache *cache.TokenCache
}
// KustomizationReconcilerOptions contains options for the KustomizationReconciler.
@@ -298,14 +300,12 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
// Configure custom health checks.
statusReaders, err := cel.PollerWithCustomHealthChecks(ctx, obj.Spec.HealthCheckExprs)
if err != nil {
- const msg = "Reconciliation failed terminally due to configuration error"
- errMsg := fmt.Sprintf("%s: %v", msg, err)
+ errMsg := fmt.Sprintf("%s: %v", TerminalErrorMessage, err)
conditions.MarkFalse(obj, meta.ReadyCondition, meta.InvalidCELExpressionReason, "%s", errMsg)
conditions.MarkStalled(obj, meta.InvalidCELExpressionReason, "%s", errMsg)
obj.Status.ObservedGeneration = obj.Generation
- log.Error(err, msg)
r.event(obj, "", "", eventv1.EventSeverityError, errMsg, nil)
- return ctrl.Result{}, nil
+ return ctrl.Result{}, reconcile.TerminalError(err)
}
// Check object-level workload identity feature gate and decryption with service account.
@@ -355,6 +355,17 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques
// Check dependencies and requeue the reconciliation if the check fails.
if len(obj.Spec.DependsOn) > 0 {
if err := r.checkDependencies(ctx, obj, artifactSource); err != nil {
+ // Check if this is a terminal error that should not trigger retries
+ if errors.Is(err, reconcile.TerminalError(nil)) {
+ errMsg := fmt.Sprintf("%s: %v", TerminalErrorMessage, err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, meta.InvalidCELExpressionReason, "%s", errMsg)
+ conditions.MarkStalled(obj, meta.InvalidCELExpressionReason, "%s", errMsg)
+ obj.Status.ObservedGeneration = obj.Generation
+ r.event(obj, revision, originRevision, eventv1.EventSeverityError, errMsg, nil)
+ return ctrl.Result{}, err
+ }
+
+ // Retry on transient errors.
conditions.MarkFalse(obj, meta.ReadyCondition, meta.DependencyNotReadyReason, "%s", err)
msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.requeueDependency.String())
log.Info(msg)
@@ -595,51 +606,120 @@ func (r *KustomizationReconciler) reconcile(
return nil
}
+// checkDependencies checks if the dependencies of the current Kustomization are ready.
+// To be considered ready, a dependencies must meet the following criteria:
+// - The dependency exists in the API server.
+// - The CEL expression (if provided) must evaluate to true.
+// - The dependency observed generation must match the current generation.
+// - The dependency Ready condition must be true.
+// - The dependency last applied revision must match the current source artifact revision.
func (r *KustomizationReconciler) checkDependencies(ctx context.Context,
obj *kustomizev1.Kustomization,
source sourcev1.Source) error {
- for _, d := range obj.Spec.DependsOn {
- if d.Namespace == "" {
- d.Namespace = obj.GetNamespace()
+
+ // Convert the Kustomization object to Unstructured for CEL evaluation.
+ objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+ if err != nil {
+ return fmt.Errorf("failed to convert Kustomization to unstructured: %w", err)
+ }
+
+ for _, depRef := range obj.Spec.DependsOn {
+ // Check if the dependency exists by querying
+ // the API server bypassing the cache.
+ if depRef.Namespace == "" {
+ depRef.Namespace = obj.GetNamespace()
}
- dName := types.NamespacedName{
- Namespace: d.Namespace,
- Name: d.Name,
+ depName := types.NamespacedName{
+ Namespace: depRef.Namespace,
+ Name: depRef.Name,
}
- var k kustomizev1.Kustomization
- err := r.APIReader.Get(ctx, dName, &k)
+ var dep kustomizev1.Kustomization
+ err := r.APIReader.Get(ctx, depName, &dep)
if err != nil {
- return fmt.Errorf("dependency '%s' not found: %w", dName, err)
+ return fmt.Errorf("dependency '%s' not found: %w", depName, err)
}
- if len(k.Status.Conditions) == 0 || k.Generation != k.Status.ObservedGeneration {
- return fmt.Errorf("dependency '%s' is not ready", dName)
+ // Evaluate the CEL expression (if specified) to determine if the dependency is ready.
+ if depRef.ReadyExpr != "" {
+ ready, err := r.evalReadyExpr(ctx, depRef.ReadyExpr, objMap, &dep)
+ if err != nil {
+ return err
+ }
+ if !ready {
+ return fmt.Errorf("dependency '%s' is not ready according to readyExpr eval", depName)
+ }
}
- if !apimeta.IsStatusConditionTrue(k.Status.Conditions, meta.ReadyCondition) {
- return fmt.Errorf("dependency '%s' is not ready", dName)
+ // Skip the built-in readiness check if the CEL expression is provided
+ // and the AdditiveCELDependencyCheck feature gate is not enabled.
+ if depRef.ReadyExpr != "" && !r.AdditiveCELDependencyCheck {
+ continue
}
- srcNamespace := k.Spec.SourceRef.Namespace
- if srcNamespace == "" {
- srcNamespace = k.GetNamespace()
+ // Check if the dependency observed generation is up to date
+ // and if the dependency is in a ready state.
+ if len(dep.Status.Conditions) == 0 || dep.Generation != dep.Status.ObservedGeneration {
+ return fmt.Errorf("dependency '%s' is not ready", depName)
}
- dSrcNamespace := obj.Spec.SourceRef.Namespace
- if dSrcNamespace == "" {
- dSrcNamespace = obj.GetNamespace()
+ if !apimeta.IsStatusConditionTrue(dep.Status.Conditions, meta.ReadyCondition) {
+ return fmt.Errorf("dependency '%s' is not ready", depName)
}
- if k.Spec.SourceRef.Name == obj.Spec.SourceRef.Name &&
- srcNamespace == dSrcNamespace &&
- k.Spec.SourceRef.Kind == obj.Spec.SourceRef.Kind &&
- !source.GetArtifact().HasRevision(k.Status.LastAppliedRevision) {
- return fmt.Errorf("dependency '%s' revision is not up to date", dName)
+ // Check if the dependency source matches the current source
+ // and if so, verify that the last applied revision of the dependency
+ // matches the current source artifact revision.
+ srcNamespace := dep.Spec.SourceRef.Namespace
+ if srcNamespace == "" {
+ srcNamespace = dep.GetNamespace()
+ }
+ depSrcNamespace := obj.Spec.SourceRef.Namespace
+ if depSrcNamespace == "" {
+ depSrcNamespace = obj.GetNamespace()
+ }
+ if dep.Spec.SourceRef.Name == obj.Spec.SourceRef.Name &&
+ srcNamespace == depSrcNamespace &&
+ dep.Spec.SourceRef.Kind == obj.Spec.SourceRef.Kind &&
+ !source.GetArtifact().HasRevision(dep.Status.LastAppliedRevision) {
+ return fmt.Errorf("dependency '%s' revision is not up to date", depName)
}
}
return nil
}
+// evalReadyExpr evaluates the CEL expression for the dependency readiness check.
+func (r *KustomizationReconciler) evalReadyExpr(
+ ctx context.Context,
+ expr string,
+ selfMap map[string]any,
+ dep *kustomizev1.Kustomization,
+) (bool, error) {
+ const (
+ selfName = "self"
+ depName = "dep"
+ )
+
+ celExpr, err := cel.NewExpression(expr,
+ cel.WithCompile(),
+ cel.WithOutputType(celtypes.BoolType),
+ cel.WithStructVariables(selfName, depName))
+ if err != nil {
+ return false, reconcile.TerminalError(fmt.Errorf("failed to evaluate dependency %s: %w", dep.Name, err))
+ }
+
+ depMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dep)
+ if err != nil {
+ return false, fmt.Errorf("failed to convert %s object to map: %w", depName, err)
+ }
+
+ vars := map[string]any{
+ selfName: selfMap,
+ depName: depMap,
+ }
+
+ return celExpr.EvaluateBoolean(ctx, vars)
+}
+
func (r *KustomizationReconciler) getSource(ctx context.Context,
obj *kustomizev1.Kustomization) (sourcev1.Source, error) {
var src sourcev1.Source
diff --git a/internal/controller/kustomization_dependson_test.go b/internal/controller/kustomization_dependson_test.go
index aceb8a576..14eac8032 100644
--- a/internal/controller/kustomization_dependson_test.go
+++ b/internal/controller/kustomization_dependson_test.go
@@ -19,14 +19,16 @@ package controller
import (
"context"
"fmt"
+ "strings"
"testing"
"time"
"github.com/fluxcd/pkg/apis/meta"
+ "github.com/fluxcd/pkg/runtime/conditions"
"github.com/fluxcd/pkg/testserver"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
. "github.com/onsi/gomega"
- apimeta "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -117,7 +119,7 @@ spec:
Namespace: kustomizationKey.Namespace,
},
Spec: kustomizev1.KustomizationSpec{
- Interval: metav1.Duration{Duration: reconciliationInterval},
+ Interval: metav1.Duration{Duration: time.Hour},
Path: "./",
KubeConfig: &meta.KubeConfigReference{
SecretRef: &meta.SecretKeyReference{
@@ -140,15 +142,14 @@ spec:
g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
- return apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition) != nil
+ return conditions.Has(resultK, meta.ReadyCondition)
}, timeout, time.Second).Should(BeTrue())
t.Run("fails due to source not found", func(t *testing.T) {
g := NewWithT(t)
g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
- ready := apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
- return ready.Reason == meta.ArtifactFailedReason
+ return conditions.HasAnyReason(resultK, meta.ReadyCondition, meta.ArtifactFailedReason)
}, timeout, time.Second).Should(BeTrue())
})
@@ -159,8 +160,7 @@ spec:
g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
- ready := apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
- return ready.Reason == meta.ReconciliationSucceededReason
+ return conditions.IsReady(resultK)
}, timeout, time.Second).Should(BeTrue())
})
@@ -168,7 +168,7 @@ spec:
g := NewWithT(t)
g.Eventually(func() error {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
- resultK.Spec.DependsOn = []meta.NamespacedObjectReference{
+ resultK.Spec.DependsOn = []kustomizev1.DependencyReference{
{
Namespace: id,
Name: "root",
@@ -179,8 +179,203 @@ spec:
g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
- ready := apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
- return ready.Reason == meta.DependencyNotReadyReason
+ return conditions.HasAnyReason(resultK, meta.ReadyCondition, meta.DependencyNotReadyReason)
+ }, timeout, time.Second).Should(BeTrue())
+ })
+}
+
+func TestKustomizationReconciler_DependsOn_CEL(t *testing.T) {
+ g := NewWithT(t)
+ id := "dep-cel" + randStringRunes(5)
+ depID := "test-dep-" + 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: "config.yaml",
+ Body: fmt.Sprintf(`---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: %[1]s
+data:
+ key: "%[2]s"
+`, name, data),
+ },
+ }
+ }
+
+ artifact, err := testServer.ArtifactFromFiles(manifests(id, id))
+ g.Expect(err).NotTo(HaveOccurred())
+
+ repositoryName := types.NamespacedName{
+ Name: fmt.Sprintf("dep-%s", randStringRunes(5)),
+ Namespace: id,
+ }
+ err = applyGitRepository(repositoryName, artifact, revision)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ kustomizationKey := types.NamespacedName{
+ Name: fmt.Sprintf("dep-%s", randStringRunes(5)),
+ Namespace: id,
+ }
+ kustomization := &kustomizev1.Kustomization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: kustomizationKey.Name,
+ Namespace: kustomizationKey.Namespace,
+ },
+ Spec: kustomizev1.KustomizationSpec{
+ Interval: metav1.Duration{Duration: time.Hour},
+ Path: "./",
+ KubeConfig: &meta.KubeConfigReference{
+ SecretRef: &meta.SecretKeyReference{
+ Name: "kubeconfig",
+ },
+ },
+ SourceRef: kustomizev1.CrossNamespaceSourceReference{
+ Name: repositoryName.Name,
+ Namespace: repositoryName.Namespace,
+ Kind: sourcev1.GitRepositoryKind,
+ },
+ TargetNamespace: id,
+ Prune: true,
+ },
+ }
+
+ g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
+
+ resultK := &kustomizev1.Kustomization{}
+
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return conditions.Has(resultK, meta.ReadyCondition)
+ }, timeout, time.Second).Should(BeTrue())
+
+ t.Run("succeeds with readyExpr dependency check", func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Create a dependency Kustomization with matching annotations
+ dependency := &kustomizev1.Kustomization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: depID,
+ Namespace: id,
+ Annotations: map[string]string{
+ "app/version": "v1.2.3",
+ },
+ },
+ Spec: kustomizev1.KustomizationSpec{
+ Suspend: true, // Suspended dependency should work with readyExpr and AdditiveCELDependencyCheck disabled
+ 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,
+ },
+ Prune: true,
+ },
+ }
+
+ g.Expect(k8sClient.Create(context.Background(), dependency)).To(Succeed())
+
+ // Update the main Kustomization with matching annotations and readyExpr
+ g.Eventually(func() error {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ resultK.ObjectMeta.Annotations = map[string]string{
+ "app/version": "v1.2.3",
+ }
+ resultK.Spec.DependsOn = []kustomizev1.DependencyReference{
+ {
+ Name: dependency.Name,
+ ReadyExpr: `self.metadata.annotations['app/version'] == dep.metadata.annotations['app/version']`,
+ },
+ }
+ return k8sClient.Update(context.Background(), resultK)
+ }, timeout, time.Second).Should(BeNil())
+
+ // Should succeed because CEL expression evaluates to true
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return conditions.IsReady(resultK)
+ }, timeout, time.Second).Should(BeTrue())
+ })
+
+ t.Run("fails with readyExpr when condition not met", func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Update the main kustomization with mismatched annotations
+ g.Eventually(func() error {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ resultK.ObjectMeta.Annotations = map[string]string{
+ "app/version": "v1.2.4",
+ }
+ resultK.Spec.DependsOn = []kustomizev1.DependencyReference{
+ {
+ Namespace: id,
+ Name: depID,
+ ReadyExpr: `self.metadata.annotations['app/version'] == dep.metadata.annotations['app/version']`,
+ },
+ }
+ return k8sClient.Update(context.Background(), resultK)
+ }, timeout, time.Second).Should(BeNil())
+
+ // Should fail because CEL expression evaluates to false
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ ready := conditions.Get(resultK, meta.ReadyCondition)
+ return ready.Reason == meta.DependencyNotReadyReason &&
+ strings.Contains(ready.Message, "not ready according to readyExpr")
+ }, timeout, time.Second).Should(BeTrue())
+
+ g.Expect(conditions.IsStalled(resultK)).Should(BeFalse())
+ })
+
+ t.Run("fails terminally with invalid readyExpr", func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Update the main kustomization with invalid CEL expression
+ g.Eventually(func() error {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ resultK.Spec.DependsOn = []kustomizev1.DependencyReference{
+ {
+ Name: depID,
+ ReadyExpr: `self.generation == deps.generation`, // Invalid vars
+ },
+ }
+ return k8sClient.Update(context.Background(), resultK)
+ }, timeout, time.Second).Should(BeNil())
+
+ // Should be marked as stalled because CEL expression is invalid
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return conditions.IsStalled(resultK)
+ }, timeout, time.Second).Should(BeTrue())
+
+ g.Expect(conditions.IsReady(resultK)).Should(BeFalse())
+ g.Expect(conditions.GetReason(resultK, meta.ReadyCondition)).Should(BeIdenticalTo(meta.InvalidCELExpressionReason))
+ g.Expect(conditions.GetMessage(resultK, meta.ReadyCondition)).Should(ContainSubstring("failed to parse"))
+ })
+
+ t.Run("GC works with failing dependency", func(t *testing.T) {
+ g := NewWithT(t)
+
+ g.Expect(k8sClient.Delete(context.Background(), kustomization)).To(Succeed())
+
+ g.Eventually(func() bool {
+ err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return errors.IsNotFound(err)
}, timeout, time.Second).Should(BeTrue())
})
}
From d17e5d25144d16caf59d4c7b74f5174e3bbaa9ca Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 13:00:53 +0300
Subject: [PATCH 4/5] docs: Add dependency ready expression to API docs
Signed-off-by: Stefan Prodan
---
docs/spec/v1/kustomizations.md | 45 ++++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/docs/spec/v1/kustomizations.md b/docs/spec/v1/kustomizations.md
index af3da7826..28eee557c 100644
--- a/docs/spec/v1/kustomizations.md
+++ b/docs/spec/v1/kustomizations.md
@@ -487,6 +487,51 @@ is running before deploying applications inside the mesh.
**Note:** Circular dependencies between Kustomizations must be avoided,
otherwise the interdependent Kustomizations will never be applied on the cluster.
+#### Dependency Ready Expression
+
+`.spec.dependsOn[].readyExpr` is an optional field that can be used to define a CEL expression
+to determine the readiness of a Kustomization dependency.
+
+This is helpful for when custom logic is needed to determine if a dependency is ready.
+For example, when performing a lockstep upgrade, the `readyExpr` can be used to
+verify that a dependency has a matching version label before proceeding with the
+reconciliation of the dependent Kustomization.
+
+```yaml
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: app-backend
+ namespace: apps
+ labels:
+ app/version: v1.2.3
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: app-frontend
+ namespace: apps
+ labels:
+ app/version: v1.2.3
+spec:
+ dependsOn:
+ - name: app-backend
+ readyExpr: >
+ dep.metadata.labels['app/version'] == self.metadata.labels['app/version'] &&
+ dep.status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True') &&
+ dep.metadata.generation == dep.status.observedGeneration
+```
+
+The CEL expression contains the following variables:
+
+- `dep`: The dependency Kustomization object being evaluated.
+- `self`: The Kustomization object being reconciled.
+
+**Note:** When `readyExpr` is specified, the built-in readiness check is replaced by the logic
+defined in the CEL expression. You can configure the controller to run both the CEL expression
+evaluation and the built-in readiness check, with the `AdditiveCELDependencyCheck`
+[feature gate](https://fluxcd.io/flux/components/kustomize/options/#feature-gates).
+
### Service Account reference
`.spec.serviceAccountName` is an optional field used to specify the
From fd63b520d584a07651aa2ce8e827a3d361860a03 Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 20:43:14 +0300
Subject: [PATCH 5/5] controller: Move manager to a dedicated file
Signed-off-by: Stefan Prodan
---
.../controller/kustomization_controller.go | 127 --------------
internal/controller/kustomization_manager.go | 161 ++++++++++++++++++
2 files changed, 161 insertions(+), 127 deletions(-)
create mode 100644 internal/controller/kustomization_manager.go
diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go
index 5f56f048c..1c95c4b25 100644
--- a/internal/controller/kustomization_controller.go
+++ b/internal/controller/kustomization_controller.go
@@ -36,14 +36,9 @@ import (
"k8s.io/apimachinery/pkg/types"
kerrors "k8s.io/apimachinery/pkg/util/errors"
kuberecorder "k8s.io/client-go/tools/record"
- "k8s.io/client-go/util/workqueue"
ctrl "sigs.k8s.io/controller-runtime"
- "sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
- "sigs.k8s.io/controller-runtime/pkg/handler"
- "sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling"
@@ -64,7 +59,6 @@ import (
runtimeCtrl "github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/jitter"
"github.com/fluxcd/pkg/runtime/patch"
- "github.com/fluxcd/pkg/runtime/predicates"
"github.com/fluxcd/pkg/runtime/statusreaders"
"github.com/fluxcd/pkg/ssa"
"github.com/fluxcd/pkg/ssa/normalize"
@@ -116,127 +110,6 @@ type KustomizationReconciler struct {
TokenCache *cache.TokenCache
}
-// KustomizationReconcilerOptions contains options for the KustomizationReconciler.
-type KustomizationReconcilerOptions struct {
- HTTPRetry int
- DependencyRequeueInterval time.Duration
- RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
- WatchConfigsPredicate predicate.Predicate
-}
-
-func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts KustomizationReconcilerOptions) error {
- const (
- indexOCIRepository = ".metadata.ociRepository"
- indexGitRepository = ".metadata.gitRepository"
- indexBucket = ".metadata.bucket"
- indexConfigMap = ".metadata.configMap"
- indexSecret = ".metadata.secret"
- )
-
- // Index the Kustomizations by the OCIRepository references they (may) point at.
- if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexOCIRepository,
- r.indexBy(sourcev1.OCIRepositoryKind)); err != nil {
- return fmt.Errorf("failed creating index %s: %w", indexOCIRepository, err)
- }
-
- // Index the Kustomizations by the GitRepository references they (may) point at.
- if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexGitRepository,
- r.indexBy(sourcev1.GitRepositoryKind)); err != nil {
- return fmt.Errorf("failed creating index %s: %w", indexGitRepository, err)
- }
-
- // Index the Kustomizations by the Bucket references they (may) point at.
- if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexBucket,
- r.indexBy(sourcev1.BucketKind)); err != nil {
- return fmt.Errorf("failed creating index %s: %w", indexBucket, err)
- }
-
- // Index the Kustomization by the ConfigMap references they point to.
- if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexConfigMap,
- func(o client.Object) []string {
- obj := o.(*kustomizev1.Kustomization)
- namespace := obj.GetNamespace()
- var keys []string
- if kc := obj.Spec.KubeConfig; kc != nil && kc.ConfigMapRef != nil {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.ConfigMapRef.Name))
- }
- if pb := obj.Spec.PostBuild; pb != nil {
- for _, ref := range pb.SubstituteFrom {
- if ref.Kind == "ConfigMap" {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
- }
- }
- }
- return keys
- },
- ); err != nil {
- return fmt.Errorf("failed creating index %s: %w", indexConfigMap, err)
- }
-
- // Index the Kustomization by the Secret references they point to.
- if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexSecret,
- func(o client.Object) []string {
- obj := o.(*kustomizev1.Kustomization)
- namespace := obj.GetNamespace()
- var keys []string
- if dec := obj.Spec.Decryption; dec != nil && dec.SecretRef != nil {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, dec.SecretRef.Name))
- }
- if kc := obj.Spec.KubeConfig; kc != nil && kc.SecretRef != nil {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.SecretRef.Name))
- }
- if pb := obj.Spec.PostBuild; pb != nil {
- for _, ref := range pb.SubstituteFrom {
- if ref.Kind == "Secret" {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
- }
- }
- }
- return keys
- },
- ); err != nil {
- return fmt.Errorf("failed creating index %s: %w", indexSecret, err)
- }
-
- r.requeueDependency = opts.DependencyRequeueInterval
- r.statusManager = fmt.Sprintf("gotk-%s", r.ControllerName)
- r.artifactFetchRetries = opts.HTTPRetry
-
- return ctrl.NewControllerManagedBy(mgr).
- For(&kustomizev1.Kustomization{}, builder.WithPredicates(
- predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
- )).
- Watches(
- &sourcev1.OCIRepository{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexOCIRepository)),
- builder.WithPredicates(SourceRevisionChangePredicate{}),
- ).
- Watches(
- &sourcev1.GitRepository{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexGitRepository)),
- builder.WithPredicates(SourceRevisionChangePredicate{}),
- ).
- Watches(
- &sourcev1.Bucket{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexBucket)),
- builder.WithPredicates(SourceRevisionChangePredicate{}),
- ).
- WatchesMetadata(
- &corev1.ConfigMap{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexConfigMap)),
- builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate),
- ).
- WatchesMetadata(
- &corev1.Secret{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexSecret)),
- builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate),
- ).
- WithOptions(controller.Options{
- RateLimiter: opts.RateLimiter,
- }).
- Complete(r)
-}
-
func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
log := ctrl.LoggerFrom(ctx)
reconcileStart := time.Now()
diff --git a/internal/controller/kustomization_manager.go b/internal/controller/kustomization_manager.go
new file mode 100644
index 000000000..278f11dd9
--- /dev/null
+++ b/internal/controller/kustomization_manager.go
@@ -0,0 +1,161 @@
+/*
+Copyright 2025 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"
+ "time"
+
+ "github.com/fluxcd/pkg/runtime/predicates"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/client-go/util/workqueue"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
+)
+
+// KustomizationReconcilerOptions contains options for the KustomizationReconciler.
+type KustomizationReconcilerOptions struct {
+ HTTPRetry int
+ DependencyRequeueInterval time.Duration
+ RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
+ WatchConfigsPredicate predicate.Predicate
+}
+
+// SetupWithManager sets up the controller with the Manager.
+// It indexes the Kustomizations by the source references, and sets up watches for
+// changes in those sources, as well as for ConfigMaps and Secrets that the Kustomizations depend on.
+func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts KustomizationReconcilerOptions) error {
+ const (
+ indexOCIRepository = ".metadata.ociRepository"
+ indexGitRepository = ".metadata.gitRepository"
+ indexBucket = ".metadata.bucket"
+ indexConfigMap = ".metadata.configMap"
+ indexSecret = ".metadata.secret"
+ )
+
+ // Index the Kustomizations by the OCIRepository references they (may) point at.
+ if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexOCIRepository,
+ r.indexBy(sourcev1.OCIRepositoryKind)); err != nil {
+ return fmt.Errorf("failed creating index %s: %w", indexOCIRepository, err)
+ }
+
+ // Index the Kustomizations by the GitRepository references they (may) point at.
+ if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexGitRepository,
+ r.indexBy(sourcev1.GitRepositoryKind)); err != nil {
+ return fmt.Errorf("failed creating index %s: %w", indexGitRepository, err)
+ }
+
+ // Index the Kustomizations by the Bucket references they (may) point at.
+ if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexBucket,
+ r.indexBy(sourcev1.BucketKind)); err != nil {
+ return fmt.Errorf("failed creating index %s: %w", indexBucket, err)
+ }
+
+ // Index the Kustomization by the ConfigMap references they point to.
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexConfigMap,
+ func(o client.Object) []string {
+ obj := o.(*kustomizev1.Kustomization)
+ namespace := obj.GetNamespace()
+ var keys []string
+ if kc := obj.Spec.KubeConfig; kc != nil && kc.ConfigMapRef != nil {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.ConfigMapRef.Name))
+ }
+ if pb := obj.Spec.PostBuild; pb != nil {
+ for _, ref := range pb.SubstituteFrom {
+ if ref.Kind == "ConfigMap" {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
+ }
+ }
+ }
+ return keys
+ },
+ ); err != nil {
+ return fmt.Errorf("failed creating index %s: %w", indexConfigMap, err)
+ }
+
+ // Index the Kustomization by the Secret references they point to.
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexSecret,
+ func(o client.Object) []string {
+ obj := o.(*kustomizev1.Kustomization)
+ namespace := obj.GetNamespace()
+ var keys []string
+ if dec := obj.Spec.Decryption; dec != nil && dec.SecretRef != nil {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, dec.SecretRef.Name))
+ }
+ if kc := obj.Spec.KubeConfig; kc != nil && kc.SecretRef != nil {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.SecretRef.Name))
+ }
+ if pb := obj.Spec.PostBuild; pb != nil {
+ for _, ref := range pb.SubstituteFrom {
+ if ref.Kind == "Secret" {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
+ }
+ }
+ }
+ return keys
+ },
+ ); err != nil {
+ return fmt.Errorf("failed creating index %s: %w", indexSecret, err)
+ }
+
+ r.requeueDependency = opts.DependencyRequeueInterval
+ r.statusManager = fmt.Sprintf("gotk-%s", r.ControllerName)
+ r.artifactFetchRetries = opts.HTTPRetry
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&kustomizev1.Kustomization{}, builder.WithPredicates(
+ predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
+ )).
+ Watches(
+ &sourcev1.OCIRepository{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexOCIRepository)),
+ builder.WithPredicates(SourceRevisionChangePredicate{}),
+ ).
+ Watches(
+ &sourcev1.GitRepository{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexGitRepository)),
+ builder.WithPredicates(SourceRevisionChangePredicate{}),
+ ).
+ Watches(
+ &sourcev1.Bucket{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexBucket)),
+ builder.WithPredicates(SourceRevisionChangePredicate{}),
+ ).
+ WatchesMetadata(
+ &corev1.ConfigMap{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexConfigMap)),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate),
+ ).
+ WatchesMetadata(
+ &corev1.Secret{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexSecret)),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate),
+ ).
+ WithOptions(controller.Options{
+ RateLimiter: opts.RateLimiter,
+ }).
+ Complete(r)
+}