Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions apis/meta/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ limitations under the License.

package meta

import "strings"
import (
"strings"
"unicode"
)

// ObjectWithDependencies describes a Kubernetes resource object with dependencies.
// +k8s:deepcopy-gen=false
Expand All @@ -27,10 +30,14 @@ type ObjectWithDependencies interface {

// MakeDependsOn parses a list of dependency strings into DependencyReference
// objects. Each dependency string can be in one of the following formats:
// - "name" - a dependency in the same namespace with no CEL expression
// - "namespace/name" - a dependency in a specific namespace
// - "name@readyExpr" - a dependency with a CEL readiness expression
// - "namespace/name@readyExpr" - a dependency in a specific namespace with a CEL expression
// - "name" - a Flux Applier API (Kustomization or HelmRelease) dependency in the same namespace
// - "namespace/name" - a Flux Applier API (Kustomization or HelmRelease) dependency in a specific namespace
// - "name@readyExpr" - a Flux Applier API (Kustomization or HelmRelease) dependency with a CEL readiness expression
// - "namespace/name@readyExpr" - a Flux Applier API (Kustomization or HelmRelease) dependency in a specific namespace with a CEL expression
// - "apiVersion/Kind/name" - a Kubernetes resource dependency in the same namespace
// - "apiVersion/Kind/name@readyExpr" - a Kubernetes resource dependency with a CEL readiness expression
// - "apiVersion/Kind/namespace/name" - a Kubernetes resource dependency in a specific namespace
// - "apiVersion/Kind/namespace/name@readyExpr" - a Kubernetes resource dependency in a specific namespace with a CEL readiness expression
//
// The @ symbol is used to separate the resource reference from the CEL expression.
// Note that @ cannot be part of resource names or namespaces per Kubernetes naming conventions:
Expand All @@ -48,11 +55,35 @@ func MakeDependsOn(deps []string) []DependencyReference {
dep = dep[:idx]
}

// Split the namespace/name.
if parts := strings.SplitN(dep, "/", 2); len(parts) == 2 {
// Parse the apiVersion/Kind/namespace/name dependency string into DependencyReference objects.
parts := strings.SplitN(dep, "/", 5)
switch len(parts) {
case 5:
ref.APIVersion = parts[0] + "/" + parts[1]
ref.Kind = parts[2]
ref.Namespace = parts[3]
ref.Name = parts[4]
case 4:
// parts[1] starts with uppercase → core API ("v1/Pod/namespace/name")
// parts[1] starts with lowercase → group/version ("apps/v1/Deployment/name")
if unicode.IsUpper(rune(parts[1][0])) {
ref.APIVersion = parts[0]
ref.Kind = parts[1]
ref.Namespace = parts[2]
ref.Name = parts[3]
} else {
ref.APIVersion = parts[0] + "/" + parts[1]
ref.Kind = parts[2]
ref.Name = parts[3]
}
case 3:
ref.APIVersion = parts[0]
ref.Kind = parts[1]
ref.Name = parts[2]
case 2:
ref.Namespace = parts[0]
ref.Name = parts[1]
} else {
case 1:
ref.Name = dep
}

Expand Down
135 changes: 106 additions & 29 deletions apis/meta/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,58 +38,102 @@ func TestMakeDependsOn(t *testing.T) {
want: []meta.DependencyReference{},
},
{
name: "single name only",
name: "single name only (Flux Applier API)",
deps: []string{"redis"},
want: []meta.DependencyReference{
{Name: "redis"},
},
},
{
name: "single with namespace",
name: "single (Flux Applier API) with namespace",
deps: []string{"default/redis"},
want: []meta.DependencyReference{
{Namespace: "default", Name: "redis"},
},
},
{
name: "single with CEL expression",
deps: []string{"redis@status.ready==true"},
name: "single (Flux Applier API) with CEL expression",
deps: []string{"redis@dep.status.ready==true"},
want: []meta.DependencyReference{
{Name: "redis", ReadyExpr: "status.ready==true"},
{Name: "redis", ReadyExpr: "dep.status.ready==true"},
},
},
{
name: "single with namespace and CEL expression",
deps: []string{"default/redis@status.ready==true"},
name: "single (Flux Applier API) with namespace and CEL expression",
deps: []string{"default/redis@dep.status.ready==true"},
want: []meta.DependencyReference{
{Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"},
{Namespace: "default", Name: "redis", ReadyExpr: "dep.status.ready==true"},
},
},
{
name: "single Kubernetes object",
deps: []string{"v1/Pod/redis-abc"},
want: []meta.DependencyReference{
{APIVersion: "v1", Kind: "Pod", Name: "redis-abc"},
},
},
{
name: "single Kubernetes object with namespace",
deps: []string{"v1/Secret/default/redis"},
want: []meta.DependencyReference{
{APIVersion: "v1", Kind: "Secret", Namespace: "default", Name: "redis"},
},
},
{
name: "single Kubernetes object with CEL expression",
deps: []string{"apps/v1/DaemonSet/redis@dep.status.numberReady==dep.status.desiredNumberScheduled"},
want: []meta.DependencyReference{
{APIVersion: "apps/v1", Kind: "DaemonSet", Name: "redis", ReadyExpr: "dep.status.numberReady==dep.status.desiredNumberScheduled"},
},
},
{
name: "single Kubernetes object with namespace and CEL expression",
deps: []string{"apps/v1/StatefulSet/default/redis@dep.status.readyReplicas==dep.spec.replicas"},
want: []meta.DependencyReference{
{APIVersion: "apps/v1", Kind: "StatefulSet", Namespace: "default", Name: "redis", ReadyExpr: "dep.status.readyReplicas==dep.spec.replicas"},
},
},
{
name: "multiple dependencies",
deps: []string{
"redis",
"default/postgres@status.ready==true",
"v1/Pod/podinfo",
"v1/PersistentVolumeClaim/backend@dep.status.phase==Bound",
"default/postgres@dep.status.observedGeneration==dep.metadata.generation",
"infra/cert-manager",
"source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator@true",
"apiextensions.k8s.io/v1/CustomResourceDefinition/resourcesets.fluxcd.controlplane.io@dep.status.storedVersions.size()==dep.spec.versions.size()",
},
want: []meta.DependencyReference{
{Name: "redis"},
{Namespace: "default", Name: "postgres", ReadyExpr: "status.ready==true"},
{APIVersion: "v1", Kind: "Pod", Name: "podinfo"},
{APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "backend", ReadyExpr: "dep.status.phase==Bound"},
{Namespace: "default", Name: "postgres", ReadyExpr: "dep.status.observedGeneration==dep.metadata.generation"},
{Namespace: "infra", Name: "cert-manager"},
{APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "flux-system", Name: "flux-operator", ReadyExpr: "true"},
{APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "resourcesets.fluxcd.controlplane.io", ReadyExpr: "dep.status.storedVersions.size()==dep.spec.versions.size()"},
},
},
{
name: "CEL expression with multiple operators",
deps: []string{"default/app@status.ready==true && status.observed==1"},
name: "CEL expression with function calls",
deps: []string{
"v1/ConfigMap/podinfo@!has(dep.data)",
"networking.k8s.io/v1/Ingress/infra/ingress@has(dep.status.loadBalancer.ingress)",
},
want: []meta.DependencyReference{
{Namespace: "default", Name: "app", ReadyExpr: "status.ready==true && status.observed==1"},
{APIVersion: "v1", Kind: "ConfigMap", Name: "podinfo", ReadyExpr: "!has(dep.data)"},
{APIVersion: "networking.k8s.io/v1", Kind: "Ingress", Namespace: "infra", Name: "ingress", ReadyExpr: "has(dep.status.loadBalancer.ingress)"},
},
},
{
name: "CEL expression with function calls",
deps: []string{"infra/ingress@has(status.loadBalancer.ingress)"},
name: "CEL expression with multiple operators",
deps: []string{
"default/app@dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)",
"v1/Pod/kube-system/kube-apiserver@dep.status.phase==Running && dep.status.observedGeneration==dep.metadata.generation",
},
want: []meta.DependencyReference{
{Namespace: "infra", Name: "ingress", ReadyExpr: "has(status.loadBalancer.ingress)"},
{Namespace: "default", Name: "app", ReadyExpr: "dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)"},
{APIVersion: "v1", Kind: "Pod", Namespace: "kube-system", Name: "kube-apiserver", ReadyExpr: "dep.status.phase==Running && dep.status.observedGeneration==dep.metadata.generation"},
},
},
}
Expand All @@ -111,29 +155,59 @@ func TestDependencyReferenceString(t *testing.T) {
want string
}{
{
name: "name only",
name: "name only (Flux Applier API)",
ref: meta.DependencyReference{Name: "redis"},
want: "redis",
},
{
name: "namespace and name",
name: "namespace and name (Flux Applier API)",
ref: meta.DependencyReference{Namespace: "default", Name: "redis"},
want: "default/redis",
},
{
name: "name and CEL expression",
ref: meta.DependencyReference{Name: "redis", ReadyExpr: "status.ready==true"},
want: "redis@status.ready==true",
name: "name and CEL expression (Flux Applier API)",
ref: meta.DependencyReference{Name: "redis", ReadyExpr: "dep.status.observedGeneration>=1"},
want: "redis@dep.status.observedGeneration>=1",
},
{
name: "namespace, name, and CEL expression (Flux Applier API)",
ref: meta.DependencyReference{Namespace: "default", Name: "redis", ReadyExpr: "dep.status.observedGeneration>=1"},
want: "default/redis@dep.status.observedGeneration>=1",
},
{
name: "name with complex CEL expression (Flux Applier API)",
ref: meta.DependencyReference{Name: "app", ReadyExpr: "dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)"},
want: "app@dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)",
},
{
name: "name only (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Name: "redis"},
want: "v1/Pod/redis",
},
{
name: "namespace and name (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "apps/v1", Kind: "DaemonSet", Namespace: "default", Name: "redis"},
want: "apps/v1/DaemonSet/default/redis",
},
{
name: "name and CEL expression (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "v1", Kind: "PersistentVolume", Name: "redis", ReadyExpr: "dep.status.phase==Bound && dep.status.observedGeneration==dep.metadata.generation"},
want: "v1/PersistentVolume/redis@dep.status.phase==Bound && dep.status.observedGeneration==dep.metadata.generation",
},
{
name: "namespace, name and CEL expression (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "v1", Kind: "PersistentVolumeClaim", Namespace: "default", Name: "redis", ReadyExpr: "dep.status.phase==Bound && dep.status.observedGeneration==dep.metadata.generation"},
want: "v1/PersistentVolumeClaim/default/redis@dep.status.phase==Bound && dep.status.observedGeneration==dep.metadata.generation",
},
{
name: "namespace, name, and CEL expression",
ref: meta.DependencyReference{Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"},
want: "default/redis@status.ready==true",
name: "name and complex CEL expression (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "GitRepository", Name: "infra-controllers", ReadyExpr: "dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)"},
want: "source.toolkit.fluxcd.io/v1/GitRepository/infra-controllers@dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)",
},
{
name: "name with complex CEL expression",
ref: meta.DependencyReference{Name: "app", ReadyExpr: "status.ready==true && status.observed==1"},
want: "app@status.ready==true && status.observed==1",
name: "namespace, name and complex CEL expression (Kubernetes object)",
ref: meta.DependencyReference{APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "flux-system", Name: "flux-operator", ReadyExpr: "dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)"},
want: "source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator@dep.status.conditions.filter(c, c.type == 'Ready').all(c, c.status == 'True' && c.observedGeneration == dep.metadata.generation)",
},
}

Expand All @@ -151,8 +225,11 @@ func TestDependencyReferenceRoundTrip(t *testing.T) {
tests := []meta.DependencyReference{
{Name: "redis"},
{Namespace: "default", Name: "postgres"},
{Name: "cache", ReadyExpr: "status.ready==true"},
{Namespace: "infra", Name: "ingress", ReadyExpr: "has(status.loadBalancer.ingress)"},
{Name: "cache", ReadyExpr: "dep.status.observedGeneration==dep.metadata.generation"},
{Namespace: "infra", Name: "ingress", ReadyExpr: "has(dep.status.loadBalancer.ingress)"},
{APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io", ReadyExpr: "dep.status.storedVersions.size()==dep.spec.versions.size()"},
{APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Name: "cert-manager", ReadyExpr: ""},
{APIVersion: "v1", Kind: "Node", Name: "control-plane-2", ReadyExpr: "true"},
}

for _, original := range tests {
Expand Down
68 changes: 62 additions & 6 deletions apis/meta/reference_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ type NamespacedObjectReference struct {
Namespace string `json:"namespace,omitempty"`
}

// TypedNamespacedObjectReference contains enough information to locate the typed referenced Kubernetes resource object
// in any namespace.
type TypedNamespacedObjectReference struct {
// APIVersion of the referent.
// +optional
APIVersion string `json:"apiVersion,omitempty"`

// Kind of the referent.
// +required
Kind string `json:"kind"`

// Name of the referent.
// +required
Name string `json:"name"`

// Namespace of the referent, when not specified it acts as TypedLocalObjectReference.
// +optional
Namespace string `json:"namespace,omitempty"`
}

// String implements the fmt.Stringer interface for NamespacedObjectReference.
func (in NamespacedObjectReference) String() string {
if in.Namespace != "" {
Expand All @@ -43,15 +63,42 @@ func (in NamespacedObjectReference) String() string {
return in.Name
}

// String implements the fmt.Stringer interface for TypedNamespacedObjectReference.
func (in TypedNamespacedObjectReference) String() string {
s := in.Name
if in.Namespace != "" {
s = in.Namespace + "/" + s
}
if in.Kind != "" {
s = in.Kind + "/" + s
}
if in.APIVersion != "" {
s = in.APIVersion + "/" + s
}
return s
}

// DependencyReference contains enough information to locate the referenced Kubernetes resource object
// and optional CEL expression to assess its readiness.
// with optional CEL expression readiness check. When the dependency is a Flux Applier API
// resource (Kustomization or HelmRelease), defaults are applied during reconciliation.
type DependencyReference struct {
// Name of the referent.
// APIVersion of the resource to depend on, defaults to the API group version of the
// Flux Applier API resource (Kustomization or HelmRelease) that contains the reference
// when the dependency is of the same kind.
// +optional
APIVersion string `json:"apiVersion,omitempty"`

// Kind of the resource to depend on, defaults to the kind of the
// Flux Applier API resource (Kustomization or HelmRelease) that contains the reference.
// +optional
Kind string `json:"kind,omitempty"`
Comment on lines +85 to +94

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because most API types would normally require a custom ReadyExpr anyway, we would like to make it so that if the API group (ignoring the version) or the kind differ from the type managed by the respective controller, then ReadyExpr becomes mandatory.

This means everything except Kustomization will require ReadyExpr in kustomize-controller, and everything except HelmRelease will require ReadyExpr in helm-controller.

Maybe we can add xvalidation rule with a CEL expression to the DependsOn field in each controller to enforce this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the ReadyExpr-only approach is adopted, this proposal would provide a clear separation of behavior between HR-to-HR/KS-to-KS and cross-resource dependencies, and would reduce the cognitive load of writing such dependency rules 👍

@vecil vecil Jun 7, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, I have the feeling this proposal would introduce additional complexity when defining dependencies on resource types other than Appliers. Built-in readiness checks based on fluxcd/cli-utils/pkg/kstatus/status types and functions may already cover many use cases.

However, the proposed xvalidation rule introduces a limitation: it would require readyExpr to always be set, even when no custom check is desired. This prevents relying solely on built-in readiness checks in cases where they would otherwise be sufficient.

  • When the AdditiveCELDependencyCheck feature gate is enabled, built-in readiness checks will always run alongside required readyExpr, with only the overhead of specifying a value (e.g. "true").
  • When the gate is disabled, requiring readyExpr via xvalidation would prevent users from relying on built-in readiness checks alone.


// Name of the resource to depend on.
// +required
Name string `json:"name"`

// Namespace of the referent, defaults to the namespace of the resource
// object that contains the reference.
// Namespace of the resource to depend on, defaults to the namespace of the Flux
// Applier API resource (Kustomization or HelmRelease) that contains the reference.
// +optional
Namespace string `json:"namespace,omitempty"`

Expand All @@ -65,13 +112,22 @@ type DependencyReference struct {
}

// String implements the fmt.Stringer interface for DependencyReference.
// Returns the dependency reference in the format: [namespace/]name[@readyExpr]
// Examples: "app", "ns/app", "app@ready", "ns/app@obj.status.ready"
// Returns the dependency reference in the format: [apiVersion/][kind/][namespace/]name[:ready][@readyExpr].
// Examples: "app", "ns/app", "app@ready", "ns/app@obj.status.ready",
// "v1/Secret/ns/secret", "Pod/app-abc:false", "Pod/ns/app-abc:true", "Pod/ns/app-abc:true@obj.status.phase",
// "HelmRelease/app", "Kustomization/ns/app", "helmreleases.helm.toolkit.fluxcd.io/v2/HelmRelease/ns/app",
// "apiextensions.k8s.io/v1/CustomResourceDefinition/kustomizations.kustomize.toolkit.fluxcd.io:true".
func (in DependencyReference) String() string {
s := in.Name
if in.Namespace != "" {
s = in.Namespace + "/" + s
}
if in.Kind != "" {
s = in.Kind + "/" + s
}
if in.APIVersion != "" {
s = in.APIVersion + "/" + s
}
if in.ReadyExpr != "" {
s = s + "@" + in.ReadyExpr
}
Expand Down
15 changes: 15 additions & 0 deletions apis/meta/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading