From 4ffe621c6bde7d4284b37811710ec13b4b8e7e45 Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 21:09:30 +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/v2/helmrelease_types.go | 18 ++++-
api/v2/reference_types.go | 20 +++++
api/v2/zz_generated.deepcopy.go | 17 ++++-
.../helm.toolkit.fluxcd.io_helmreleases.yaml | 20 +++--
docs/api/v2/helm.md | 73 +++++++++++++++++--
.../controller/helmrelease_controller_test.go | 12 +--
6 files changed, 137 insertions(+), 23 deletions(-)
diff --git a/api/v2/helmrelease_types.go b/api/v2/helmrelease_types.go
index d53042bcb..92457f325 100644
--- a/api/v2/helmrelease_types.go
+++ b/api/v2/helmrelease_types.go
@@ -100,11 +100,11 @@ type HelmReleaseSpec struct {
// +optional
StorageNamespace string `json:"storageNamespace,omitempty"`
- // DependsOn may contain a meta.NamespacedObjectReference slice with
+ // DependsOn may contain a DependencyReference slice with
// references to HelmRelease resources that must be ready before this HelmRelease
// can be reconciled.
// +optional
- DependsOn []meta.NamespacedObjectReference `json:"dependsOn,omitempty"`
+ DependsOn []DependencyReference `json:"dependsOn,omitempty"`
// Timeout is the time to wait for any individual Kubernetes operation (like Jobs
// for hooks) during the performance of a Helm action. Defaults to '5m0s'.
@@ -1265,9 +1265,19 @@ func (in HelmRelease) UsePersistentClient() bool {
return *in.Spec.PersistentClient
}
-// GetDependsOn returns the list of dependencies across-namespaces.
+// GetDependsOn returns the dependencies as a list of meta.NamespacedObjectReference.
+//
+// This function makes the HelmRelease type conformant with the meta.ObjectWithDependencies interface
+// and allows the controller-runtime to index HelmReleases by their dependencies.
func (in HelmRelease) 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/v2/reference_types.go b/api/v2/reference_types.go
index 419913b12..7e19b2269 100644
--- a/api/v2/reference_types.go
+++ b/api/v2/reference_types.go
@@ -68,3 +68,23 @@ type CrossNamespaceSourceReference struct {
// +optional
Namespace string `json:"namespace,omitempty"`
}
+
+// DependencyReference defines a HelmRelease dependency on another HelmRelease resource.
+type DependencyReference struct {
+ // Name of the referent.
+ // +required
+ Name string `json:"name"`
+
+ // Namespace of the referent, defaults to the namespace of the HelmRelease
+ // 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/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go
index 903efbaad..cfcc81c4f 100644
--- a/api/v2/zz_generated.deepcopy.go
+++ b/api/v2/zz_generated.deepcopy.go
@@ -87,6 +87,21 @@ func (in *CrossNamespaceSourceReference) DeepCopy() *CrossNamespaceSourceReferen
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 *DriftDetection) DeepCopyInto(out *DriftDetection) {
*out = *in
@@ -305,7 +320,7 @@ func (in *HelmReleaseSpec) DeepCopyInto(out *HelmReleaseSpec) {
}
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.Timeout != nil {
diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
index a1156cf3c..8ad103da8 100644
--- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
+++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
@@ -239,20 +239,28 @@ spec:
type: object
dependsOn:
description: |-
- DependsOn may contain a meta.NamespacedObjectReference slice with
+ DependsOn may contain a DependencyReference slice with
references to HelmRelease resources that must be ready before this HelmRelease
can be reconciled.
items:
- description: |-
- NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
- namespace.
+ description: DependencyReference defines a HelmRelease dependency
+ on another HelmRelease 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 HelmRelease
+ 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/v2/helm.md b/docs/api/v2/helm.md
index 1e4f5bef8..b4af0cfe2 100644
--- a/docs/api/v2/helm.md
+++ b/docs/api/v2/helm.md
@@ -187,14 +187,14 @@ Defaults to the namespace of the HelmRelease.
dependsOn
-
-[]github.com/fluxcd/pkg/apis/meta.NamespacedObjectReference
+
+[]DependencyReference
|
(Optional)
- DependsOn may contain a meta.NamespacedObjectReference slice with
+ DependsOn may contain a DependencyReference slice with
references to HelmRelease resources that must be ready before this HelmRelease
can be reconciled.
|
@@ -615,6 +615,67 @@ resource object that contains the reference.
+
+
+(Appears on:
+HelmReleaseSpec)
+
+DependencyReference defines a HelmRelease dependency on another HelmRelease resource.
+
@@ -1258,14 +1319,14 @@ Defaults to the namespace of the HelmRelease.
dependsOn
-
-[]github.com/fluxcd/pkg/apis/meta.NamespacedObjectReference
+
+[]DependencyReference
|
(Optional)
- DependsOn may contain a meta.NamespacedObjectReference slice with
+ DependsOn may contain a DependencyReference slice with
references to HelmRelease resources that must be ready before this HelmRelease
can be reconciled.
|
diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go
index be52e1ddd..d559a909c 100644
--- a/internal/controller/helmrelease_controller_test.go
+++ b/internal/controller/helmrelease_controller_test.go
@@ -93,7 +93,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) {
Namespace: "mock",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency",
},
@@ -2818,7 +2818,7 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
Namespace: "some-namespace",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency-1",
},
@@ -2869,7 +2869,7 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
Namespace: "some-namespace",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency-1",
},
@@ -2904,7 +2904,7 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
Namespace: "some-namespace",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency-1",
},
@@ -2939,7 +2939,7 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
Namespace: "some-namespace",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency-1",
},
@@ -2971,7 +2971,7 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
Namespace: "some-namespace",
},
Spec: v2.HelmReleaseSpec{
- DependsOn: []meta.NamespacedObjectReference{
+ DependsOn: []v2.DependencyReference{
{
Name: "dependency-1",
},
From 6d5856c00eb83b8d17e61d079f815ac6273fc003 Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 21:18:55 +0300
Subject: [PATCH 2/5] controller: Move manager and indexers to dedicated files
Signed-off-by: Stefan Prodan
---
internal/controller/helmrelease_controller.go | 221 ------------------
internal/controller/helmrelease_indexers.go | 136 +++++++++++
internal/controller/helmrelease_manager.go | 150 ++++++++++++
3 files changed, 286 insertions(+), 221 deletions(-)
create mode 100644 internal/controller/helmrelease_indexers.go
create mode 100644 internal/controller/helmrelease_manager.go
diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go
index e9a8efba0..5371c07f5 100644
--- a/internal/controller/helmrelease_controller.go
+++ b/internal/controller/helmrelease_controller.go
@@ -33,14 +33,9 @@ import (
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
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/Masterminds/semver/v3"
@@ -57,7 +52,6 @@ import (
"github.com/fluxcd/pkg/runtime/logger"
"github.com/fluxcd/pkg/runtime/object"
"github.com/fluxcd/pkg/runtime/patch"
- "github.com/fluxcd/pkg/runtime/predicates"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
"github.com/fluxcd/pkg/chartutil"
@@ -71,7 +65,6 @@ import (
"github.com/fluxcd/helm-controller/internal/kube"
"github.com/fluxcd/helm-controller/internal/loader"
"github.com/fluxcd/helm-controller/internal/postrender"
- intpredicates "github.com/fluxcd/helm-controller/internal/predicates"
intreconcile "github.com/fluxcd/helm-controller/internal/reconcile"
"github.com/fluxcd/helm-controller/internal/release"
)
@@ -105,123 +98,11 @@ type HelmReleaseReconciler struct {
artifactFetchRetries int
}
-type HelmReleaseReconcilerOptions struct {
- HTTPRetry int
- DependencyRequeueInterval time.Duration
- RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
- WatchConfigsPredicate predicate.Predicate
-}
-
var (
errWaitForDependency = errors.New("must wait for dependency")
errWaitForChart = errors.New("must wait for chart")
)
-func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error {
- const (
- indexConfigMap = ".metadata.configMap"
- indexSecret = ".metadata.secret"
- )
-
- // Index the HelmRelease by the Source reference they point to.
- if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, v2.SourceIndexKey,
- func(o client.Object) []string {
- obj := o.(*v2.HelmRelease)
- var kind, name, namespace string
- switch {
- case obj.HasChartRef() && !obj.HasChartTemplate():
- kind = obj.Spec.ChartRef.Kind
- name = obj.Spec.ChartRef.Name
- namespace = obj.Spec.ChartRef.Namespace
- if namespace == "" {
- namespace = obj.GetNamespace()
- }
- case !obj.HasChartRef() && obj.HasChartTemplate():
- kind = sourcev1.HelmChartKind
- name = obj.GetHelmChartName()
- namespace = obj.Spec.Chart.GetNamespace(obj.GetNamespace())
- default:
- return nil
- }
- return []string{fmt.Sprintf("%s/%s/%s", kind, namespace, name)}
- },
- ); err != nil {
- return err
- }
-
- // Index the HelmRelease by the ConfigMap references they point to.
- if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, indexConfigMap,
- func(o client.Object) []string {
- obj := o.(*v2.HelmRelease)
- 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))
- }
- for _, ref := range obj.Spec.ValuesFrom {
- if ref.Kind == "ConfigMap" {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
- }
- }
- return keys
- },
- ); err != nil {
- return err
- }
-
- // Index the HelmRelease by the Secret references they point to.
- if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, indexSecret,
- func(o client.Object) []string {
- obj := o.(*v2.HelmRelease)
- namespace := obj.GetNamespace()
- var keys []string
- if kc := obj.Spec.KubeConfig; kc != nil && kc.SecretRef != nil {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.SecretRef.Name))
- }
- for _, ref := range obj.Spec.ValuesFrom {
- if ref.Kind == "Secret" {
- keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
- }
- }
- return keys
- },
- ); err != nil {
- return err
- }
-
- r.requeueDependency = opts.DependencyRequeueInterval
- r.artifactFetchRetries = opts.HTTPRetry
-
- return ctrl.NewControllerManagedBy(mgr).
- For(&v2.HelmRelease{}, builder.WithPredicates(
- predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
- )).
- Watches(
- &sourcev1.HelmChart{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForHelmChartChange),
- builder.WithPredicates(intpredicates.SourceRevisionChangePredicate{}),
- ).
- Watches(
- &sourcev1.OCIRepository{},
- handler.EnqueueRequestsFromMapFunc(r.requestsForOCIRepositoryChange),
- builder.WithPredicates(intpredicates.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 *HelmReleaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
start := time.Now()
log := ctrl.LoggerFrom(ctx)
@@ -871,108 +752,6 @@ func (r *HelmReleaseReconciler) waitForHistoryCacheSync(obj *v2.HelmRelease) wai
}
}
-func (r *HelmReleaseReconciler) requestsForHelmChartChange(ctx context.Context, o client.Object) []reconcile.Request {
- hc, ok := o.(*sourcev1.HelmChart)
- if !ok {
- err := fmt.Errorf("expected a HelmChart, got %T", o)
- ctrl.LoggerFrom(ctx).Error(err, "failed to get requests for HelmChart change")
- return nil
- }
- // If we do not have an artifact, we have no requests to make
- if hc.GetArtifact() == nil {
- return nil
- }
-
- var list v2.HelmReleaseList
- if err := r.List(ctx, &list, client.MatchingFields{
- v2.SourceIndexKey: sourcev1.HelmChartKind + "/" + client.ObjectKeyFromObject(hc).String(),
- }); err != nil {
- ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for HelmChart change")
- return nil
- }
-
- var reqs []reconcile.Request
- for i, hr := range list.Items {
- // If the HelmRelease is ready and the revision of the artifact equals to the
- // last attempted revision, we should not make a request for this HelmRelease
- if conditions.IsReady(&list.Items[i]) && hc.GetArtifact().HasRevision(hr.Status.GetLastAttemptedRevision()) {
- continue
- }
- reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
- }
- return reqs
-}
-
-func (r *HelmReleaseReconciler) requestsForOCIRepositoryChange(ctx context.Context, o client.Object) []reconcile.Request {
- or, ok := o.(*sourcev1.OCIRepository)
- if !ok {
- err := fmt.Errorf("expected an OCIRepository, got %T", o)
- ctrl.LoggerFrom(ctx).Error(err, "failed to get requests for OCIRepository change")
- return nil
- }
- // If we do not have an artifact, we have no requests to make
- if or.GetArtifact() == nil {
- return nil
- }
-
- var list v2.HelmReleaseList
- if err := r.List(ctx, &list, client.MatchingFields{
- v2.SourceIndexKey: sourcev1.OCIRepositoryKind + "/" + client.ObjectKeyFromObject(or).String(),
- }); err != nil {
- ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for OCIRepository change")
- return nil
- }
-
- var reqs []reconcile.Request
- for i, hr := range list.Items {
- // If the HelmRelease is ready and the digest of the artifact equals to the
- // last attempted revision digest, we should not make a request for this HelmRelease,
- // likewise if we cannot retrieve the artifact digest.
- digest := extractDigest(or.GetArtifact().Revision)
- if digest == "" {
- ctrl.LoggerFrom(ctx).Error(fmt.Errorf("wrong digest for %T", or), "failed to get requests for OCIRepository change")
- continue
- }
-
- if digest == hr.Status.LastAttemptedRevisionDigest {
- continue
- }
-
- reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
- }
- return reqs
-}
-
-// requestsForConfigDependency enqueues requests for watched ConfigMaps or Secrets
-// according to the specified index.
-func (r *HelmReleaseReconciler) requestsForConfigDependency(
- index string) func(ctx context.Context, o client.Object) []reconcile.Request {
-
- return func(ctx context.Context, o client.Object) []reconcile.Request {
- // List HelmReleases that have a dependency on the ConfigMap or Secret.
- var list v2.HelmReleaseList
- if err := r.List(ctx, &list, client.MatchingFields{
- index: client.ObjectKeyFromObject(o).String(),
- }); err != nil {
- ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for config dependency change",
- "index", index, "objectRef", map[string]string{
- "name": o.GetName(),
- "namespace": o.GetNamespace(),
- })
- return nil
- }
-
- // Enqueue requests for each HelmRelease in the list.
- reqs := make([]reconcile.Request, 0, len(list.Items))
- for i := range list.Items {
- reqs = append(reqs, reconcile.Request{
- NamespacedName: client.ObjectKeyFromObject(&list.Items[i]),
- })
- }
- return reqs
- }
-}
-
func isSourceReady(obj sourcev1.Source) (bool, string) {
if o, ok := obj.(conditions.Getter); ok {
return isReady(o, obj.GetArtifact())
diff --git a/internal/controller/helmrelease_indexers.go b/internal/controller/helmrelease_indexers.go
new file mode 100644
index 000000000..fab44af50
--- /dev/null
+++ b/internal/controller/helmrelease_indexers.go
@@ -0,0 +1,136 @@
+/*
+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"
+
+ "github.com/fluxcd/pkg/runtime/conditions"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ v2 "github.com/fluxcd/helm-controller/api/v2"
+)
+
+// requestsForHelmChartChange enqueues requests for watched HelmCharts
+// according to the specified index.
+func (r *HelmReleaseReconciler) requestsForHelmChartChange(ctx context.Context, o client.Object) []reconcile.Request {
+ hc, ok := o.(*sourcev1.HelmChart)
+ if !ok {
+ err := fmt.Errorf("expected a HelmChart, got %T", o)
+ ctrl.LoggerFrom(ctx).Error(err, "failed to get requests for HelmChart change")
+ return nil
+ }
+ // If we do not have an artifact, we have no requests to make
+ if hc.GetArtifact() == nil {
+ return nil
+ }
+
+ var list v2.HelmReleaseList
+ if err := r.List(ctx, &list, client.MatchingFields{
+ v2.SourceIndexKey: sourcev1.HelmChartKind + "/" + client.ObjectKeyFromObject(hc).String(),
+ }); err != nil {
+ ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for HelmChart change")
+ return nil
+ }
+
+ var reqs []reconcile.Request
+ for i, hr := range list.Items {
+ // If the HelmRelease is ready and the revision of the artifact equals to the
+ // last attempted revision, we should not make a request for this HelmRelease
+ if conditions.IsReady(&list.Items[i]) && hc.GetArtifact().HasRevision(hr.Status.GetLastAttemptedRevision()) {
+ continue
+ }
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
+
+// requestsForOCIRepositoryChange enqueues requests for watched OCIRepositories
+// according to the specified index.
+func (r *HelmReleaseReconciler) requestsForOCIRepositoryChange(ctx context.Context, o client.Object) []reconcile.Request {
+ or, ok := o.(*sourcev1.OCIRepository)
+ if !ok {
+ err := fmt.Errorf("expected an OCIRepository, got %T", o)
+ ctrl.LoggerFrom(ctx).Error(err, "failed to get requests for OCIRepository change")
+ return nil
+ }
+ // If we do not have an artifact, we have no requests to make
+ if or.GetArtifact() == nil {
+ return nil
+ }
+
+ var list v2.HelmReleaseList
+ if err := r.List(ctx, &list, client.MatchingFields{
+ v2.SourceIndexKey: sourcev1.OCIRepositoryKind + "/" + client.ObjectKeyFromObject(or).String(),
+ }); err != nil {
+ ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for OCIRepository change")
+ return nil
+ }
+
+ var reqs []reconcile.Request
+ for i, hr := range list.Items {
+ // If the HelmRelease is ready and the digest of the artifact equals to the
+ // last attempted revision digest, we should not make a request for this HelmRelease,
+ // likewise if we cannot retrieve the artifact digest.
+ digest := extractDigest(or.GetArtifact().Revision)
+ if digest == "" {
+ ctrl.LoggerFrom(ctx).Error(fmt.Errorf("wrong digest for %T", or), "failed to get requests for OCIRepository change")
+ continue
+ }
+
+ if digest == hr.Status.LastAttemptedRevisionDigest {
+ continue
+ }
+
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])})
+ }
+ return reqs
+}
+
+// requestsForConfigDependency enqueues requests for watched ConfigMaps or Secrets
+// according to the specified index.
+func (r *HelmReleaseReconciler) requestsForConfigDependency(
+ index string) func(ctx context.Context, o client.Object) []reconcile.Request {
+
+ return func(ctx context.Context, o client.Object) []reconcile.Request {
+ // List HelmReleases that have a dependency on the ConfigMap or Secret.
+ var list v2.HelmReleaseList
+ if err := r.List(ctx, &list, client.MatchingFields{
+ index: client.ObjectKeyFromObject(o).String(),
+ }); err != nil {
+ ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for config dependency change",
+ "index", index, "objectRef", map[string]string{
+ "name": o.GetName(),
+ "namespace": o.GetNamespace(),
+ })
+ return nil
+ }
+
+ // Enqueue requests for each HelmRelease in the list.
+ reqs := make([]reconcile.Request, 0, len(list.Items))
+ for i := range list.Items {
+ reqs = append(reqs, reconcile.Request{
+ NamespacedName: client.ObjectKeyFromObject(&list.Items[i]),
+ })
+ }
+ return reqs
+ }
+}
diff --git a/internal/controller/helmrelease_manager.go b/internal/controller/helmrelease_manager.go
new file mode 100644
index 000000000..2f50c6d35
--- /dev/null
+++ b/internal/controller/helmrelease_manager.go
@@ -0,0 +1,150 @@
+/*
+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"
+
+ v2 "github.com/fluxcd/helm-controller/api/v2"
+ intpredicates "github.com/fluxcd/helm-controller/internal/predicates"
+)
+
+type HelmReleaseReconcilerOptions struct {
+ HTTPRetry int
+ DependencyRequeueInterval time.Duration
+ RateLimiter workqueue.TypedRateLimiter[reconcile.Request]
+ WatchConfigsPredicate predicate.Predicate
+}
+
+func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error {
+ const (
+ indexConfigMap = ".metadata.configMap"
+ indexSecret = ".metadata.secret"
+ )
+
+ // Index the HelmRelease by the Source reference they point to.
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, v2.SourceIndexKey,
+ func(o client.Object) []string {
+ obj := o.(*v2.HelmRelease)
+ var kind, name, namespace string
+ switch {
+ case obj.HasChartRef() && !obj.HasChartTemplate():
+ kind = obj.Spec.ChartRef.Kind
+ name = obj.Spec.ChartRef.Name
+ namespace = obj.Spec.ChartRef.Namespace
+ if namespace == "" {
+ namespace = obj.GetNamespace()
+ }
+ case !obj.HasChartRef() && obj.HasChartTemplate():
+ kind = sourcev1.HelmChartKind
+ name = obj.GetHelmChartName()
+ namespace = obj.Spec.Chart.GetNamespace(obj.GetNamespace())
+ default:
+ return nil
+ }
+ return []string{fmt.Sprintf("%s/%s/%s", kind, namespace, name)}
+ },
+ ); err != nil {
+ return err
+ }
+
+ // Index the HelmRelease by the ConfigMap references they point to.
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, indexConfigMap,
+ func(o client.Object) []string {
+ obj := o.(*v2.HelmRelease)
+ 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))
+ }
+ for _, ref := range obj.Spec.ValuesFrom {
+ if ref.Kind == "ConfigMap" {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
+ }
+ }
+ return keys
+ },
+ ); err != nil {
+ return err
+ }
+
+ // Index the HelmRelease by the Secret references they point to.
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, indexSecret,
+ func(o client.Object) []string {
+ obj := o.(*v2.HelmRelease)
+ namespace := obj.GetNamespace()
+ var keys []string
+ if kc := obj.Spec.KubeConfig; kc != nil && kc.SecretRef != nil {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, kc.SecretRef.Name))
+ }
+ for _, ref := range obj.Spec.ValuesFrom {
+ if ref.Kind == "Secret" {
+ keys = append(keys, fmt.Sprintf("%s/%s", namespace, ref.Name))
+ }
+ }
+ return keys
+ },
+ ); err != nil {
+ return err
+ }
+
+ r.requeueDependency = opts.DependencyRequeueInterval
+ r.artifactFetchRetries = opts.HTTPRetry
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&v2.HelmRelease{}, builder.WithPredicates(
+ predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
+ )).
+ Watches(
+ &sourcev1.HelmChart{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForHelmChartChange),
+ builder.WithPredicates(intpredicates.SourceRevisionChangePredicate{}),
+ ).
+ Watches(
+ &sourcev1.OCIRepository{},
+ handler.EnqueueRequestsFromMapFunc(r.requestsForOCIRepositoryChange),
+ builder.WithPredicates(intpredicates.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)
+}
From 9b6b090ca408d058fd0be169c44f594892ff32ab Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 22:06:12 +0300
Subject: [PATCH 3/5] controller: Add `AdditiveCELDependencyCheck` feature gate
Signed-off-by: Stefan Prodan
---
internal/features/features.go | 8 ++++++++
main.go | 9 ++++++++-
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/internal/features/features.go b/internal/features/features.go
index 37eb9a863..de5bdb205 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -68,6 +68,11 @@ const (
// digest changes. When enabled, the controller will not
// append the digest to the chart version in Chart.yaml.
DisableChartDigestTracking = "DisableChartDigestTracking"
+
+ // 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{
@@ -92,6 +97,9 @@ var features = map[string]bool{
// DisableChartDigestTracking
// opt-in from v1.3.0
DisableChartDigestTracking: false,
+ // AdditiveCELDependencyCheck
+ // opt-in from v1.4.0
+ AdditiveCELDependencyCheck: false,
}
func init() {
diff --git a/main.go b/main.go
index 90759048f..bb759c127 100644
--- a/main.go
+++ b/main.go
@@ -204,7 +204,13 @@ func main() {
disableChartDigestTracking, err := features.Enabled(features.DisableChartDigestTracking)
if err != nil {
- setupLog.Error(err, "unable to check feature gate DisableChartDigestTracking")
+ setupLog.Error(err, "unable to check feature gate "+features.DisableChartDigestTracking)
+ 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)
}
@@ -318,6 +324,7 @@ func main() {
KubeConfigOpts: kubeConfigOpts,
FieldManager: controllerName,
DisableChartDigestTracking: disableChartDigestTracking,
+ AdditiveCELDependencyCheck: additiveCELDependencyCheck,
TokenCache: tokenCache,
}).SetupWithManager(ctx, mgr, controller.HelmReleaseReconcilerOptions{
DependencyRequeueInterval: requeueDependency,
From 32f20f73440759397466faa0444944dccdb15a5d Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 22:06:33 +0300
Subject: [PATCH 4/5] controller: Implement CEL evaluation for dependency
checks
Signed-off-by: Stefan Prodan
---
go.mod | 6 +
go.sum | 15 ++
internal/controller/helmrelease_controller.go | 107 ++++++++++++--
.../controller/helmrelease_controller_test.go | 130 ++++++++++++++++++
4 files changed, 243 insertions(+), 15 deletions(-)
diff --git a/go.mod b/go.mod
index 673cf84d2..69ed9d4b8 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,7 @@ require (
github.com/fluxcd/pkg/testserver v0.11.0
github.com/fluxcd/source-controller/api v1.6.0
github.com/go-logr/logr v1.4.3
+ github.com/google/cel-go v0.23.2
github.com/google/go-cmp v0.7.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/mitchellh/copystructure v1.2.0
@@ -54,6 +55,7 @@ require (
)
require (
+ cel.dev/expr v0.23.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
@@ -70,6 +72,7 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
@@ -172,6 +175,7 @@ require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
+ github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
@@ -193,6 +197,7 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.39.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
@@ -201,6 +206,7 @@ require (
golang.org/x/time v0.12.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
google.golang.org/api v0.241.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
diff --git a/go.sum b/go.sum
index 613328340..f7395e6cf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss=
+cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
@@ -42,6 +44,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
+github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
+github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@@ -205,6 +209,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4=
+github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -378,13 +384,20 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
+github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -481,6 +494,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go
index 5371c07f5..123be24c6 100644
--- a/internal/controller/helmrelease_controller.go
+++ b/internal/controller/helmrelease_controller.go
@@ -23,10 +23,13 @@ import (
"strings"
"time"
+ "github.com/fluxcd/pkg/runtime/cel"
+ celtypes "github.com/google/cel-go/common/types"
"helm.sh/helm/v3/pkg/chart"
corev1 "k8s.io/api/core/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
apierrutil "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/wait"
@@ -93,6 +96,7 @@ type HelmReleaseReconciler struct {
FieldManager string
DefaultServiceAccount string
DisableChartDigestTracking bool
+ AdditiveCELDependencyCheck bool
requeueDependency time.Duration
artifactFetchRetries int
@@ -205,6 +209,17 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe
log.Info(fmt.Sprintf("checking %d dependencies", c))
if err := r.checkDependencies(ctx, obj); err != nil {
+ // Check if this is a terminal error that should not trigger retries
+ if errors.Is(err, reconcile.TerminalError(nil)) {
+ const terminalErrorMessage = "Reconciliation failed terminally due to configuration error"
+ errMsg := fmt.Sprintf("%s: %v", terminalErrorMessage, err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, meta.InvalidCELExpressionReason, "%s", errMsg)
+ conditions.MarkStalled(obj, meta.InvalidCELExpressionReason, "%s", errMsg)
+ r.Eventf(obj, corev1.EventTypeWarning, meta.InvalidCELExpressionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+
+ // Retry on transient errors.
msg := fmt.Sprintf("dependencies do not meet ready condition (%s): retrying in %s",
err.Error(), r.requeueDependency.String())
conditions.MarkFalse(obj, meta.ReadyCondition, v2.DependencyNotReadyReason, "%s", err)
@@ -534,32 +549,94 @@ func (r *HelmReleaseReconciler) reconcileUninstall(ctx context.Context, getter g
return intreconcile.NewUninstall(cfg, r.EventRecorder).Reconcile(ctx, &intreconcile.Request{Object: obj})
}
-// checkDependencies checks if the dependencies of the given v2.HelmRelease
-// are Ready.
-// It returns an error if a dependency can not be retrieved or is not Ready,
-// otherwise nil.
+// checkDependencies checks if the dependencies of the current HelmRelease 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.
func (r *HelmReleaseReconciler) checkDependencies(ctx context.Context, obj *v2.HelmRelease) error {
- for _, d := range obj.Spec.DependsOn {
- ref := types.NamespacedName{
- Namespace: d.Namespace,
- Name: d.Name,
+ // Convert the HelmRelease object to Unstructured for CEL evaluation.
+ objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+ if err != nil {
+ return fmt.Errorf("failed to convert HelmRelease to unstructured: %w", err)
+ }
+
+ for _, depRef := range obj.Spec.DependsOn {
+ depName := types.NamespacedName{
+ Namespace: depRef.Namespace,
+ Name: depRef.Name,
+ }
+ if depName.Namespace == "" {
+ depName.Namespace = obj.GetNamespace()
}
- if ref.Namespace == "" {
- ref.Namespace = obj.GetNamespace()
+
+ // Check if the dependency exists by querying
+ // the API server bypassing the cache.
+ dep := &v2.HelmRelease{}
+ if err := r.APIReader.Get(ctx, depName, dep); err != nil {
+ return fmt.Errorf("unable to get '%s' dependency: %w", depName, err)
+ }
+
+ // 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)
+ }
}
- dHr := &v2.HelmRelease{}
- if err := r.APIReader.Get(ctx, ref, dHr); err != nil {
- return fmt.Errorf("unable to get '%s' dependency: %w", ref, err)
+ // 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
}
- if dHr.Generation != dHr.Status.ObservedGeneration || !conditions.IsTrue(dHr, meta.ReadyCondition) {
- return fmt.Errorf("dependency '%s' is not ready", ref)
+ // Check if the dependency observed generation is up to date
+ // and if the dependency is in a ready state.
+ if dep.Generation != dep.Status.ObservedGeneration || !conditions.IsTrue(dep, meta.ReadyCondition) {
+ return fmt.Errorf("dependency '%s' is not ready", depName)
}
}
return nil
}
+// evalReadyExpr evaluates the CEL expression for the dependency readiness check.
+func (r *HelmReleaseReconciler) evalReadyExpr(
+ ctx context.Context,
+ expr string,
+ selfMap map[string]any,
+ dep *v2.HelmRelease,
+) (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)
+}
+
// adoptLegacyRelease attempts to adopt a v2beta1 release into a v2
// release.
// This is done by retrieving the last successful release from the Helm storage
diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go
index d559a909c..40e333dd8 100644
--- a/internal/controller/helmrelease_controller_test.go
+++ b/internal/controller/helmrelease_controller_test.go
@@ -2861,6 +2861,69 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred())
},
},
+ {
+ name: "all dependencies ready with readyExpr",
+ obj: &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dependant",
+ Namespace: "some-namespace",
+ Labels: map[string]string{
+ "app/version": "v1.2.3",
+ },
+ },
+ Spec: v2.HelmReleaseSpec{
+ Values: &apiextensionsv1.JSON{
+ Raw: []byte(`{"version":"v1.2.3"}`),
+ },
+ DependsOn: []v2.DependencyReference{
+ {
+ Name: "dependency-1",
+ ReadyExpr: "self.spec.values.version == dep.spec.values.version",
+ },
+ {
+ Name: "dependency-2",
+ 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
+`,
+ },
+ },
+ },
+ },
+ objects: []client.Object{
+ &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dependency-1",
+ Namespace: "some-namespace",
+ },
+ Spec: v2.HelmReleaseSpec{
+ Values: &apiextensionsv1.JSON{
+ Raw: []byte(`{"version":"v1.2.3"}`),
+ },
+ },
+ },
+ &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 2,
+ Name: "dependency-2",
+ Namespace: "some-namespace",
+ Labels: map[string]string{
+ "app/version": "v1.2.3",
+ },
+ },
+ Status: v2.HelmReleaseStatus{
+ ObservedGeneration: 2,
+ Conditions: []metav1.Condition{
+ {Type: meta.ReadyCondition, Status: metav1.ConditionTrue},
+ },
+ },
+ },
+ },
+ expect: func(g *WithT, err error) {
+ g.Expect(err).ToNot(HaveOccurred())
+ },
+ },
{
name: "error on dependency with ObservedGeneration < Generation",
obj: &v2.HelmRelease{
@@ -2983,6 +3046,73 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
},
},
+ {
+ name: "error on dependency with readyExpr",
+ obj: &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dependant",
+ Namespace: "some-namespace",
+ },
+ Spec: v2.HelmReleaseSpec{
+ DependsOn: []v2.DependencyReference{
+ {
+ Name: "dependency-1",
+ ReadyExpr: "self.metadata.name == dep.metadata.name",
+ },
+ },
+ },
+ },
+ objects: []client.Object{
+ &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ Name: "dependency-1",
+ Namespace: "some-namespace",
+ },
+ Status: v2.HelmReleaseStatus{
+ ObservedGeneration: 1,
+ },
+ },
+ },
+ expect: func(g *WithT, err error) {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring("is not ready according to readyExpr eval"))
+ },
+ },
+ {
+ name: "terminal error on dependency with invalid readyExpr",
+ obj: &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dependant",
+ Namespace: "some-namespace",
+ },
+ Spec: v2.HelmReleaseSpec{
+ DependsOn: []v2.DependencyReference{
+ {
+ Name: "dependency-1",
+ ReadyExpr: "self.generation == deps.generation",
+ },
+ },
+ },
+ },
+ objects: []client.Object{
+ &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ Name: "dependency-1",
+ Namespace: "some-namespace",
+ },
+ Status: v2.HelmReleaseStatus{
+ ObservedGeneration: 1,
+ },
+ },
+ },
+ expect: func(g *WithT, err error) {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue())
+ g.Expect(err.Error()).To(ContainSubstring("failed to parse"))
+ },
+ },
}
for _, tt := range tests {
From 40d128a05d67407b8f43eaa5bf04910ec02184e6 Mon Sep 17 00:00:00 2001
From: Stefan Prodan
Date: Tue, 22 Jul 2025 22:17:24 +0300
Subject: [PATCH 5/5] docs: Add dependency ready expression to API docs
Signed-off-by: Stefan Prodan
---
docs/spec/v2/helmreleases.md | 55 +++++++++++++++++++++++++++++++++---
1 file changed, 51 insertions(+), 4 deletions(-)
diff --git a/docs/spec/v2/helmreleases.md b/docs/spec/v2/helmreleases.md
index 86365069a..630aca61d 100644
--- a/docs/spec/v2/helmreleases.md
+++ b/docs/spec/v2/helmreleases.md
@@ -396,12 +396,59 @@ spec:
- name: backend
```
-**Note:** This does not account for upgrade ordering. Kubernetes only allows
-applying one resource (HelmRelease in this case) at a time, so there is no
-way for the controller to know when a dependency HelmRelease may be updated.
-Also, circular dependencies between HelmRelease resources must be avoided,
+**Note:** Circular dependencies between HelmRelease resources must be avoided,
otherwise the interdependent HelmRelease resources will never be reconciled.
+#### 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 HelmRelease 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 in values before proceeding with the
+reconciliation of the dependent HelmRelease.
+
+```yaml
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: backend
+ namespace: default
+spec:
+ # ...omitted for brevity
+ values:
+ app:
+ version: v1.2.3
+---
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: frontend
+ namespace: default
+spec:
+ # ...omitted for brevity
+ values:
+ app:
+ version: v1.2.3
+ dependsOn:
+ - name: backend
+ readyExpr: >
+ dep.spec.values.app.version == self.spec.values.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 HelmRelease object being evaluated.
+- `self`: The HelmRelease 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/helm/options/#feature-gates).
+
### Values
The values for the Helm release can be specified in two ways: