From 8c3ac7a1d0a43dc1025b0017450ac59fb7960ffc Mon Sep 17 00:00:00 2001 From: vecil Date: Wed, 20 May 2026 05:03:28 +0200 Subject: [PATCH 1/3] feat(api): extend DependencyReference type to support any Kubernetes resource Dependencies can now be expressed on any Kubernetes object, improving flexibility in release ordering and readiness behavior. Signed-off-by: Vincent Dely --- apis/meta/dependencies.go | 63 ++++++-- apis/meta/dependencies_test.go | 242 +++++++++++++++++++++++++++-- apis/meta/reference_types.go | 78 +++++++++- apis/meta/zz_generated.deepcopy.go | 20 +++ runtime/dependency/sort.go | 38 +++-- runtime/dependency/sort_test.go | 63 ++++++-- 6 files changed, 449 insertions(+), 55 deletions(-) diff --git a/apis/meta/dependencies.go b/apis/meta/dependencies.go index a194fa216..79be08a8a 100644 --- a/apis/meta/dependencies.go +++ b/apis/meta/dependencies.go @@ -16,7 +16,11 @@ limitations under the License. package meta -import "strings" +import ( + "strconv" + "strings" + "unicode" +) // ObjectWithDependencies describes a Kubernetes resource object with dependencies. // +k8s:deepcopy-gen=false @@ -27,13 +31,20 @@ 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 (enabled) +// - "namespace/name@readyExpr" - a Flux Applier API (Kustomization or HelmRelease) dependency in a specific namespace with a CEL expression (enabled) +// - "apiVersion/Kind/name" - a Kubernetes resource dependency in the same namespace +// - "apiVersion/Kind/name@readyExpr" - a Kubernetes resource dependency with a CEL readiness expression (disabled) +// - "apiVersion/Kind/name:true@readyExpr" - a Kubernetes resource dependency with a CEL readiness expression (enabled) +// - "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 (disabled) +// - "apiVersion/Kind/namespace/name:true@readyExpr" - a Kubernetes resource dependency in a specific namespace with a CEL readiness expression (enabled) // +// The : symbol is used to separate the resource reference from the readiness check enablement. // 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: +// Note that : and @ cannot be part of resource names or namespaces per Kubernetes naming conventions: // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ // For CEL expression syntax, see: // https://github.com/google/cel-spec/blob/master/doc/langdef.md @@ -48,11 +59,45 @@ func MakeDependsOn(deps []string) []DependencyReference { dep = dep[:idx] } - // Split the namespace/name. - if parts := strings.SplitN(dep, "/", 2); len(parts) == 2 { + // Split off the readiness check boolean value if present. + if idx := strings.Index(dep, ":"); idx != -1 { + ready, err := strconv.ParseBool(dep[idx+1:]) + if err != nil { + ready = false + } + ref.Ready = new(ready) + dep = dep[:idx] + } + + // 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 } diff --git a/apis/meta/dependencies_test.go b/apis/meta/dependencies_test.go index 03851107d..5ad631c9b 100644 --- a/apis/meta/dependencies_test.go +++ b/apis/meta/dependencies_test.go @@ -38,58 +38,186 @@ 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 name (Flux Applier API) with readiness check", + deps: []string{"redis:true"}, + want: []meta.DependencyReference{ + {Name: "redis", Ready: new(true)}, + }, + }, + { + name: "single (Flux Applier API) with namespace", deps: []string{"default/redis"}, want: []meta.DependencyReference{ {Namespace: "default", Name: "redis"}, }, }, { - name: "single with CEL expression", + name: "single (Flux Applier API) with namespace and readiness check", + deps: []string{"default/redis:true"}, + want: []meta.DependencyReference{ + {Namespace: "default", Name: "redis", Ready: new(true)}, + }, + }, + { + name: "single (Flux Applier API) with CEL expression", deps: []string{"redis@status.ready==true"}, want: []meta.DependencyReference{ {Name: "redis", ReadyExpr: "status.ready==true"}, }, }, { - name: "single with namespace and CEL expression", + name: "single (Flux Applier API) with CEL expression (disabled)", + deps: []string{"redis:false@status.ready==true"}, + want: []meta.DependencyReference{ + {Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single (Flux Applier API) with CEL expression (enabled)", + deps: []string{"redis:true@status.ready==true"}, + want: []meta.DependencyReference{ + {Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single (Flux Applier API) with namespace and CEL expression", deps: []string{"default/redis@status.ready==true"}, want: []meta.DependencyReference{ {Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, }, }, + { + name: "single (Flux Applier API) with namespace and CEL expression (disabled)", + deps: []string{"default/redis:false@status.ready==true"}, + want: []meta.DependencyReference{ + {Namespace: "default", Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single (Flux Applier API) with namespace and CEL expression (enabled)", + deps: []string{"default/redis:true@status.ready==true"}, + want: []meta.DependencyReference{ + {Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "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 readiness check", + deps: []string{"apps/v1/Deployment/redis:true"}, + want: []meta.DependencyReference{ + {APIVersion: "apps/v1", Kind: "Deployment", Name: "redis", Ready: new(true)}, + }, + }, + { + 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 namespace and readiness check", + deps: []string{"v1/ConfigMap/default/redis:true"}, + want: []meta.DependencyReference{ + {APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "redis", Ready: new(true)}, + }, + }, + { + name: "single Kubernetes object with CEL expression", + deps: []string{"apps/v1/DaemonSet/redis@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "apps/v1", Kind: "DaemonSet", Name: "redis", ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single Kubernetes object with CEL expression (disabled)", + deps: []string{"apiextensions.k8s.io/v1/CustomResourceDefinition/kustomizations.kustomize.toolkit.fluxcd.io:false@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "kustomizations.kustomize.toolkit.fluxcd.io", Ready: new(false), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single Kubernetes object with CEL expression (enabled)", + deps: []string{"apiextensions.k8s.io/v1/CustomResourceDefinition/helmreleases.helm.toolkit.fluxcd.io:true@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io", Ready: new(true), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single Kubernetes object with namespace and CEL expression", + deps: []string{"apps/v1/StatefulSet/default/redis@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "apps/v1", Kind: "StatefulSet", Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single Kubernetes object with namespace and CEL expression (disabled)", + deps: []string{"source.toolkit.fluxcd.io/v1/OCIRepository/default/redis:false@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "default", Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, + }, + }, + { + name: "single Kubernetes object with namespace and CEL expression (enabled)", + deps: []string{"source.toolkit.fluxcd.io/v1/GitRepository/default/redis:true@status.ready==true"}, + want: []meta.DependencyReference{ + {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "GitRepository", Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + }, + }, { name: "multiple dependencies", deps: []string{ "redis", + "v1/Pod/podinfo:true", + "v1/PersistentVolumeClaim/backend:true@status.phase==Bound", "default/postgres@status.ready==true", - "infra/cert-manager", + "infra/cert-manager:true", + "source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator:false", + "apiextensions.k8s.io/v1/CustomResourceDefinition/resourcesets.fluxcd.controlplane.io:true@status.ready==true", }, want: []meta.DependencyReference{ {Name: "redis"}, + {APIVersion: "v1", Kind: "Pod", Name: "podinfo", Ready: new(true)}, + {APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "backend", Ready: new(true), ReadyExpr: "status.phase==Bound"}, {Namespace: "default", Name: "postgres", ReadyExpr: "status.ready==true"}, - {Namespace: "infra", Name: "cert-manager"}, + {Namespace: "infra", Name: "cert-manager", Ready: new(true)}, + {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "flux-system", Name: "flux-operator", Ready: new(false)}, + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "resourcesets.fluxcd.controlplane.io", Ready: new(true), ReadyExpr: "status.ready==true"}, }, }, { name: "CEL expression with multiple operators", - deps: []string{"default/app@status.ready==true && status.observed==1"}, + deps: []string{ + "default/app@status.ready==true && status.observed==1", + "v1/Pod/kube-system/kube-apiserver:true@status.phase==Running && status.observedGeneration==metadata.generation", + }, want: []meta.DependencyReference{ {Namespace: "default", Name: "app", ReadyExpr: "status.ready==true && status.observed==1"}, + {APIVersion: "v1", Kind: "Pod", Namespace: "kube-system", Name: "kube-apiserver", Ready: new(true), ReadyExpr: "status.phase==Running && status.observedGeneration==metadata.generation"}, }, }, { name: "CEL expression with function calls", - deps: []string{"infra/ingress@has(status.loadBalancer.ingress)"}, + deps: []string{ + "infra/ingress@has(status.loadBalancer.ingress)", + "v1/ConfigMap/podinfo:true@!has(data)", + }, want: []meta.DependencyReference{ {Namespace: "infra", Name: "ingress", ReadyExpr: "has(status.loadBalancer.ingress)"}, + {APIVersion: "v1", Kind: "ConfigMap", Name: "podinfo", Ready: new(true), ReadyExpr: "!has(data)"}, }, }, } @@ -111,30 +239,115 @@ 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", + name: "name and readiness check (Flux Applier API)", + ref: meta.DependencyReference{Name: "redis", Ready: new(false)}, + want: "redis:false", + }, + { + name: "namespace, name, and readiness check (Flux Applier API)", + ref: meta.DependencyReference{Namespace: "default", Name: "redis", Ready: new(true)}, + want: "default/redis:true", + }, + { + name: "name and CEL expression (Flux Applier API)", ref: meta.DependencyReference{Name: "redis", ReadyExpr: "status.ready==true"}, want: "redis@status.ready==true", }, { - name: "namespace, name, and CEL expression", + name: "namespace, name, and CEL expression (Flux Applier API)", ref: meta.DependencyReference{Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, want: "default/redis@status.ready==true", }, { - name: "name with complex CEL expression", + name: "name, readiness check and CEL expression (Flux Applier API)", + ref: meta.DependencyReference{Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, + want: "redis:false@status.ready==true", + }, + { + name: "namespace, name, readiness check and CEL expression (Flux Applier API)", + ref: meta.DependencyReference{Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + want: "default/redis:true@status.ready==true", + }, + { + name: "name with complex CEL expression (Flux Applier API)", ref: meta.DependencyReference{Name: "app", ReadyExpr: "status.ready==true && status.observed==1"}, want: "app@status.ready==true && status.observed==1", }, + { + name: "name with readiness check and complex CEL expression (Flux Applier API)", + ref: meta.DependencyReference{Name: "app", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, + want: "app:true@status.ready==true && status.observed==1", + }, + { + 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 readiness check (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "Secret", Name: "redis", Ready: new(false)}, + want: "v1/Secret/redis:false", + }, + { + name: "namespace, name and readiness check (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "redis", Ready: new(true)}, + want: "v1/ConfigMap/default/redis:true", + }, + { + name: "name and CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "PersistentVolume", Name: "redis", ReadyExpr: "status.ready==true"}, + want: "v1/PersistentVolume/redis@status.ready==true", + }, + { + name: "namespace, name and CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "PersistentVolumeClaim", Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, + want: "v1/PersistentVolumeClaim/default/redis@status.ready==true", + }, + { + name: "name, readiness check and CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "Node", Name: "control-plane-2", Ready: new(false), ReadyExpr: "status.ready==true"}, + want: "v1/Node/control-plane-2:false@status.ready==true", + }, + { + name: "namespace, name, readiness check and CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "v1", Kind: "Ingress", Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + want: "v1/Ingress/default/redis:true@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: "status.ready==true && status.observed==1"}, + want: "source.toolkit.fluxcd.io/v1/GitRepository/infra-controllers@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: "status.ready==true && status.observed==1"}, + want: "source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator@status.ready==true && status.observed==1", + }, + { + name: "name, readiness check and complex CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, + want: "apiextensions.k8s.io/v1/CustomResourceDefinition/helmreleases.helm.toolkit.fluxcd.io:true@status.ready==true && status.observed==1", + }, + { + name: "namespace, name, readiness check and complex CEL expression (Kubernetes object)", + ref: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "app", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, + want: "kustomize.toolkit.fluxcd.io/v1/Kustomization/app:true@status.ready==true && status.observed==1", + }, } for _, tt := range tests { @@ -153,6 +366,9 @@ func TestDependencyReferenceRoundTrip(t *testing.T) { {Namespace: "default", Name: "postgres"}, {Name: "cache", ReadyExpr: "status.ready==true"}, {Namespace: "infra", Name: "ingress", ReadyExpr: "has(status.loadBalancer.ingress)"}, + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io"}, + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Name: "cert-manager", Ready: new(false), ReadyExpr: "status.ready==true"}, + {APIVersion: "v1", Kind: "Node", Name: "control-plane-2", Ready: new(true)}, } for _, original := range tests { diff --git a/apis/meta/reference_types.go b/apis/meta/reference_types.go index 48a2b0935..e12d9674b 100644 --- a/apis/meta/reference_types.go +++ b/apis/meta/reference_types.go @@ -16,6 +16,8 @@ limitations under the License. package meta +import "strconv" + // LocalObjectReference contains enough information to locate the referenced Kubernetes resource object. type LocalObjectReference struct { // Name of the referent. @@ -35,6 +37,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 != "" { @@ -43,18 +65,50 @@ 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 built-in or 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"` + + // 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"` + // Ready checks if the resource Ready status condition is true, defaults to + // true when the dependency is a Flux Applier API resource (Kustomization or HelmRelease). + // +optional + Ready *bool `json:"ready,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. @@ -65,13 +119,25 @@ 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.Ready != nil { + s = s + ":" + strconv.FormatBool(*in.Ready) + } if in.ReadyExpr != "" { s = s + "@" + in.ReadyExpr } diff --git a/apis/meta/zz_generated.deepcopy.go b/apis/meta/zz_generated.deepcopy.go index f211dbe10..c33d58222 100644 --- a/apis/meta/zz_generated.deepcopy.go +++ b/apis/meta/zz_generated.deepcopy.go @@ -53,6 +53,11 @@ func (in *Artifact) DeepCopy() *Artifact { // 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 + if in.Ready != nil { + in, out := &in.Ready, &out.Ready + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependencyReference. @@ -226,6 +231,21 @@ func (in *Snapshot) DeepCopy() *Snapshot { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TypedNamespacedObjectReference) DeepCopyInto(out *TypedNamespacedObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypedNamespacedObjectReference. +func (in *TypedNamespacedObjectReference) DeepCopy() *TypedNamespacedObjectReference { + if in == nil { + return nil + } + out := new(TypedNamespacedObjectReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ValuesReference) DeepCopyInto(out *ValuesReference) { *out = *in diff --git a/runtime/dependency/sort.go b/runtime/dependency/sort.go index 54de6cbf6..d922208e1 100644 --- a/runtime/dependency/sort.go +++ b/runtime/dependency/sort.go @@ -27,6 +27,8 @@ import ( // Dependent interface defines methods that a Kubernetes resource object should // implement in order to use the dependency package for ordering dependencies. type Dependent interface { + GetAPIVersion() string + GetKind() string GetName() string GetNamespace() string meta.ObjectWithDependencies @@ -39,29 +41,35 @@ const ( ) // Sort takes a slice of Dependent objects and returns a sorted slice of -// NamespacedObjectReference based on their dependencies. It performs a +// TypedNamespacedObjectReference based on their dependencies. It performs a // topological sort using a depth-first search algorithm, which has // runtime complexity of O(|V| + |E|), where |V| is the number of // vertices (objects) and |E| is the number of edges (dependencies). // // Reference: // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search -func Sort(objects []Dependent) ([]meta.NamespacedObjectReference, error) { +func Sort(objects []Dependent) ([]meta.TypedNamespacedObjectReference, error) { // Build vertices and edges. - vertices := make([]meta.NamespacedObjectReference, 0, len(objects)) - edges := make(map[meta.NamespacedObjectReference][]meta.NamespacedObjectReference) + vertices := make([]meta.TypedNamespacedObjectReference, 0, len(objects)) + edges := make(map[meta.TypedNamespacedObjectReference][]meta.TypedNamespacedObjectReference) for _, obj := range objects { - u := meta.NamespacedObjectReference{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), + u := meta.TypedNamespacedObjectReference{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Name: obj.GetName(), + Namespace: obj.GetNamespace(), } vertices = append(vertices, u) for _, depRef := range obj.GetDependsOn() { - v := meta.NamespacedObjectReference{ - Name: depRef.Name, - Namespace: depRef.Namespace, + v := meta.TypedNamespacedObjectReference{ + APIVersion: depRef.APIVersion, + Kind: depRef.Kind, + Name: depRef.Name, + Namespace: depRef.Namespace, } - if v.Namespace == "" { + // Default the namespace only when the dependency has the same Kind + // as the parent object. + if v.Namespace == "" && (v.Kind == "" || v.Kind == obj.GetKind()) { v.Namespace = obj.GetNamespace() } edges[u] = append(edges[u], v) @@ -69,10 +77,10 @@ func Sort(objects []Dependent) ([]meta.NamespacedObjectReference, error) { } // Compute topological order with depth-first search. - var sorted []meta.NamespacedObjectReference - var depthFirstSearch func(u meta.NamespacedObjectReference) (cycle []string) - mark := make(map[meta.NamespacedObjectReference]byte) - depthFirstSearch = func(u meta.NamespacedObjectReference) []string { + var sorted []meta.TypedNamespacedObjectReference + var depthFirstSearch func(u meta.TypedNamespacedObjectReference) (cycle []string) + mark := make(map[meta.TypedNamespacedObjectReference]byte) + depthFirstSearch = func(u meta.TypedNamespacedObjectReference) []string { mark[u] = temporaryMark for _, v := range edges[u] { if mark[v] == permanentMark { diff --git a/runtime/dependency/sort_test.go b/runtime/dependency/sort_test.go index d89dcf5c5..9e2967ac4 100644 --- a/runtime/dependency/sort_test.go +++ b/runtime/dependency/sort_test.go @@ -26,9 +26,19 @@ import ( ) type object struct { - name string - namespace string - dependsOn []meta.DependencyReference + apiVersion string + kind string + name string + namespace string + dependsOn []meta.DependencyReference +} + +func (in *object) GetAPIVersion() string { + return in.apiVersion +} + +func (in *object) GetKind() string { + return in.kind } func (in *object) GetName() string { @@ -47,7 +57,7 @@ func TestSort(t *testing.T) { for _, tt := range []struct { name string objects []dependency.Dependent - want []meta.NamespacedObjectReference + want []meta.TypedNamespacedObjectReference err string }{ { @@ -59,6 +69,7 @@ func TestSort(t *testing.T) { dependsOn: []meta.DependencyReference{ {Namespace: "linkerd", Name: "linkerd"}, {Namespace: "default", Name: "backend"}, + {APIVersion: "cert-manager.io/v1", Kind: "Certificate", Namespace: "default", Name: "frontend"}, }, }, &object{ @@ -72,10 +83,17 @@ func TestSort(t *testing.T) { {Namespace: "linkerd", Name: "linkerd"}, }, }, + &object{ + apiVersion: "cert-manager.io/v1", + kind: "Certificate", + name: "frontend", + namespace: "default", + }, }, - want: []meta.NamespacedObjectReference{ + want: []meta.TypedNamespacedObjectReference{ {Namespace: "linkerd", Name: "linkerd"}, {Namespace: "default", Name: "backend"}, + {APIVersion: "cert-manager.io/v1", Kind: "Certificate", Namespace: "default", Name: "frontend"}, {Namespace: "default", Name: "frontend"}, }, }, @@ -86,12 +104,14 @@ func TestSort(t *testing.T) { name: "dependency", namespace: "default", dependsOn: []meta.DependencyReference{ - {Namespace: "default", Name: "endless"}, + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "endless"}, }, }, &object{ - name: "endless", - namespace: "default", + apiVersion: "helm.toolkit.fluxcd.io/v2", + kind: "HelmRelease", + name: "endless", + namespace: "default", dependsOn: []meta.DependencyReference{ {Namespace: "default", Name: "circular"}, }, @@ -104,7 +124,7 @@ func TestSort(t *testing.T) { }, }, }, - err: "circular dependency detected: default/dependency -> default/endless -> default/circular -> default/dependency", + err: "circular dependency detected: default/dependency -> helm.toolkit.fluxcd.io/v2/HelmRelease/default/endless -> default/circular -> default/dependency", }, { name: "missing namespace", @@ -117,12 +137,19 @@ func TestSort(t *testing.T) { name: "frontend", namespace: "application", dependsOn: []meta.DependencyReference{ + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "resourcesets.fluxcd.controlplane.io"}, {Name: "backend"}, }, }, + &object{ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + name: "resourcesets.fluxcd.controlplane.io", + }, }, - want: []meta.NamespacedObjectReference{ + want: []meta.TypedNamespacedObjectReference{ {Namespace: "application", Name: "backend"}, + {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "resourcesets.fluxcd.controlplane.io"}, {Namespace: "application", Name: "frontend"}, }, }, @@ -133,6 +160,7 @@ func TestSort(t *testing.T) { name: "backend", namespace: "default", dependsOn: []meta.DependencyReference{ + {APIVersion: "cert-manager.io/v1", Kind: "ClusterIssuer", Namespace: "default", Name: "acme-issuer"}, {Namespace: "default", Name: "common"}, }, }, @@ -141,17 +169,24 @@ func TestSort(t *testing.T) { namespace: "default", dependsOn: []meta.DependencyReference{ {Namespace: "default", Name: "infra"}, + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "podinfo"}, }, }, &object{ name: "common", namespace: "default", + dependsOn: []meta.DependencyReference{ + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "crds"}, + }, }, }, - want: []meta.NamespacedObjectReference{ + want: []meta.TypedNamespacedObjectReference{ + {APIVersion: "cert-manager.io/v1", Kind: "ClusterIssuer", Namespace: "default", Name: "acme-issuer"}, + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "crds"}, {Namespace: "default", Name: "common"}, {Namespace: "default", Name: "backend"}, {Namespace: "default", Name: "infra"}, + {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "podinfo"}, {Namespace: "default", Name: "frontend"}, }, }, @@ -162,13 +197,17 @@ func TestSort(t *testing.T) { name: "frontend", namespace: "default", dependsOn: []meta.DependencyReference{ + {APIVersion: "cert-manager.io/v1", Kind: "ClusterIssuer", Namespace: "default", Name: "acme-issuer"}, {Namespace: "linkerd", Name: "linkerd"}, + {APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "backend-data"}, {Namespace: "default", Name: "backend"}, }, }, }, - want: []meta.NamespacedObjectReference{ + want: []meta.TypedNamespacedObjectReference{ + {APIVersion: "cert-manager.io/v1", Kind: "ClusterIssuer", Namespace: "default", Name: "acme-issuer"}, {Namespace: "linkerd", Name: "linkerd"}, + {APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "backend-data"}, {Namespace: "default", Name: "backend"}, {Namespace: "default", Name: "frontend"}, }, From 4c960904e99e6db7462862575c5080327b08327c Mon Sep 17 00:00:00 2001 From: vecil Date: Sun, 7 Jun 2026 12:18:46 +0200 Subject: [PATCH 2/3] fix(api): remove unwanted ready field, use dep variable in readyExpr values The Ready field is removed from the DependencyReference type. To perform an existence check on a dependency, ReadyExpr can be set to "true", effectively disabling readiness checks. Tests now use the dep variable as expected by Applier controllers. Signed-off-by: Vincent Dely --- apis/meta/dependencies.go | 24 +-- apis/meta/dependencies_test.go | 237 ++++++----------------------- apis/meta/reference_types.go | 18 +-- apis/meta/zz_generated.deepcopy.go | 5 - 4 files changed, 58 insertions(+), 226 deletions(-) diff --git a/apis/meta/dependencies.go b/apis/meta/dependencies.go index 79be08a8a..2e14bea7c 100644 --- a/apis/meta/dependencies.go +++ b/apis/meta/dependencies.go @@ -17,7 +17,6 @@ limitations under the License. package meta import ( - "strconv" "strings" "unicode" ) @@ -33,18 +32,15 @@ type ObjectWithDependencies interface { // objects. Each dependency string can be in one of the following formats: // - "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 (enabled) -// - "namespace/name@readyExpr" - a Flux Applier API (Kustomization or HelmRelease) dependency in a specific namespace with a CEL expression (enabled) +// - "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 (disabled) -// - "apiVersion/Kind/name:true@readyExpr" - a Kubernetes resource dependency with a CEL readiness expression (enabled) +// - "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 (disabled) -// - "apiVersion/Kind/namespace/name:true@readyExpr" - a Kubernetes resource dependency in a specific namespace with a CEL readiness expression (enabled) +// - "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 readiness check enablement. // The @ symbol is used to separate the resource reference from the CEL expression. -// Note that : and @ cannot be part of resource names or namespaces per Kubernetes naming conventions: +// Note that @ cannot be part of resource names or namespaces per Kubernetes naming conventions: // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ // For CEL expression syntax, see: // https://github.com/google/cel-spec/blob/master/doc/langdef.md @@ -59,16 +55,6 @@ func MakeDependsOn(deps []string) []DependencyReference { dep = dep[:idx] } - // Split off the readiness check boolean value if present. - if idx := strings.Index(dep, ":"); idx != -1 { - ready, err := strconv.ParseBool(dep[idx+1:]) - if err != nil { - ready = false - } - ref.Ready = new(ready) - dep = dep[:idx] - } - // Parse the apiVersion/Kind/namespace/name dependency string into DependencyReference objects. parts := strings.SplitN(dep, "/", 5) switch len(parts) { diff --git a/apis/meta/dependencies_test.go b/apis/meta/dependencies_test.go index 5ad631c9b..8923b4dca 100644 --- a/apis/meta/dependencies_test.go +++ b/apis/meta/dependencies_test.go @@ -44,13 +44,6 @@ func TestMakeDependsOn(t *testing.T) { {Name: "redis"}, }, }, - { - name: "single name (Flux Applier API) with readiness check", - deps: []string{"redis:true"}, - want: []meta.DependencyReference{ - {Name: "redis", Ready: new(true)}, - }, - }, { name: "single (Flux Applier API) with namespace", deps: []string{"default/redis"}, @@ -58,53 +51,18 @@ func TestMakeDependsOn(t *testing.T) { {Namespace: "default", Name: "redis"}, }, }, - { - name: "single (Flux Applier API) with namespace and readiness check", - deps: []string{"default/redis:true"}, - want: []meta.DependencyReference{ - {Namespace: "default", Name: "redis", Ready: new(true)}, - }, - }, { name: "single (Flux Applier API) with CEL expression", - deps: []string{"redis@status.ready==true"}, + deps: []string{"redis@dep.status.ready==true"}, want: []meta.DependencyReference{ - {Name: "redis", ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single (Flux Applier API) with CEL expression (disabled)", - deps: []string{"redis:false@status.ready==true"}, - want: []meta.DependencyReference{ - {Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single (Flux Applier API) with CEL expression (enabled)", - deps: []string{"redis:true@status.ready==true"}, - want: []meta.DependencyReference{ - {Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + {Name: "redis", ReadyExpr: "dep.status.ready==true"}, }, }, { name: "single (Flux Applier API) with namespace and CEL expression", - deps: []string{"default/redis@status.ready==true"}, + deps: []string{"default/redis@dep.status.ready==true"}, want: []meta.DependencyReference{ - {Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single (Flux Applier API) with namespace and CEL expression (disabled)", - deps: []string{"default/redis:false@status.ready==true"}, - want: []meta.DependencyReference{ - {Namespace: "default", Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single (Flux Applier API) with namespace and CEL expression (enabled)", - deps: []string{"default/redis:true@status.ready==true"}, - want: []meta.DependencyReference{ - {Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + {Namespace: "default", Name: "redis", ReadyExpr: "dep.status.ready==true"}, }, }, { @@ -114,13 +72,6 @@ func TestMakeDependsOn(t *testing.T) { {APIVersion: "v1", Kind: "Pod", Name: "redis-abc"}, }, }, - { - name: "single Kubernetes object with readiness check", - deps: []string{"apps/v1/Deployment/redis:true"}, - want: []meta.DependencyReference{ - {APIVersion: "apps/v1", Kind: "Deployment", Name: "redis", Ready: new(true)}, - }, - }, { name: "single Kubernetes object with namespace", deps: []string{"v1/Secret/default/redis"}, @@ -128,96 +79,61 @@ func TestMakeDependsOn(t *testing.T) { {APIVersion: "v1", Kind: "Secret", Namespace: "default", Name: "redis"}, }, }, - { - name: "single Kubernetes object with namespace and readiness check", - deps: []string{"v1/ConfigMap/default/redis:true"}, - want: []meta.DependencyReference{ - {APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "redis", Ready: new(true)}, - }, - }, { name: "single Kubernetes object with CEL expression", - deps: []string{"apps/v1/DaemonSet/redis@status.ready==true"}, - want: []meta.DependencyReference{ - {APIVersion: "apps/v1", Kind: "DaemonSet", Name: "redis", ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single Kubernetes object with CEL expression (disabled)", - deps: []string{"apiextensions.k8s.io/v1/CustomResourceDefinition/kustomizations.kustomize.toolkit.fluxcd.io:false@status.ready==true"}, - want: []meta.DependencyReference{ - {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "kustomizations.kustomize.toolkit.fluxcd.io", Ready: new(false), ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single Kubernetes object with CEL expression (enabled)", - deps: []string{"apiextensions.k8s.io/v1/CustomResourceDefinition/helmreleases.helm.toolkit.fluxcd.io:true@status.ready==true"}, + deps: []string{"apps/v1/DaemonSet/redis@dep.status.numberReady==dep.status.desiredNumberScheduled"}, want: []meta.DependencyReference{ - {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io", Ready: new(true), ReadyExpr: "status.ready==true"}, + {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@status.ready==true"}, - want: []meta.DependencyReference{ - {APIVersion: "apps/v1", Kind: "StatefulSet", Namespace: "default", Name: "redis", ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single Kubernetes object with namespace and CEL expression (disabled)", - deps: []string{"source.toolkit.fluxcd.io/v1/OCIRepository/default/redis:false@status.ready==true"}, + deps: []string{"apps/v1/StatefulSet/default/redis@dep.status.readyReplicas==dep.spec.replicas"}, want: []meta.DependencyReference{ - {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "default", Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, - }, - }, - { - name: "single Kubernetes object with namespace and CEL expression (enabled)", - deps: []string{"source.toolkit.fluxcd.io/v1/GitRepository/default/redis:true@status.ready==true"}, - want: []meta.DependencyReference{ - {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "GitRepository", Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, + {APIVersion: "apps/v1", Kind: "StatefulSet", Namespace: "default", Name: "redis", ReadyExpr: "dep.status.readyReplicas==dep.spec.replicas"}, }, }, { name: "multiple dependencies", deps: []string{ "redis", - "v1/Pod/podinfo:true", - "v1/PersistentVolumeClaim/backend:true@status.phase==Bound", - "default/postgres@status.ready==true", - "infra/cert-manager:true", - "source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator:false", - "apiextensions.k8s.io/v1/CustomResourceDefinition/resourcesets.fluxcd.controlplane.io:true@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"}, - {APIVersion: "v1", Kind: "Pod", Name: "podinfo", Ready: new(true)}, - {APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "backend", Ready: new(true), ReadyExpr: "status.phase==Bound"}, - {Namespace: "default", Name: "postgres", ReadyExpr: "status.ready==true"}, - {Namespace: "infra", Name: "cert-manager", Ready: new(true)}, - {APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "OCIRepository", Namespace: "flux-system", Name: "flux-operator", Ready: new(false)}, - {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "resourcesets.fluxcd.controlplane.io", Ready: new(true), 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", + name: "CEL expression with function calls", deps: []string{ - "default/app@status.ready==true && status.observed==1", - "v1/Pod/kube-system/kube-apiserver:true@status.phase==Running && status.observedGeneration==metadata.generation", + "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: "Pod", Namespace: "kube-system", Name: "kube-apiserver", Ready: new(true), ReadyExpr: "status.phase==Running && status.observedGeneration==metadata.generation"}, + {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", + name: "CEL expression with multiple operators", deps: []string{ - "infra/ingress@has(status.loadBalancer.ingress)", - "v1/ConfigMap/podinfo:true@!has(data)", + "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)"}, - {APIVersion: "v1", Kind: "ConfigMap", Name: "podinfo", Ready: new(true), ReadyExpr: "!has(data)"}, + {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"}, }, }, } @@ -248,45 +164,20 @@ func TestDependencyReferenceString(t *testing.T) { ref: meta.DependencyReference{Namespace: "default", Name: "redis"}, want: "default/redis", }, - { - name: "name and readiness check (Flux Applier API)", - ref: meta.DependencyReference{Name: "redis", Ready: new(false)}, - want: "redis:false", - }, - { - name: "namespace, name, and readiness check (Flux Applier API)", - ref: meta.DependencyReference{Namespace: "default", Name: "redis", Ready: new(true)}, - want: "default/redis:true", - }, { name: "name and CEL expression (Flux Applier API)", - ref: meta.DependencyReference{Name: "redis", ReadyExpr: "status.ready==true"}, - want: "redis@status.ready==true", + 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: "status.ready==true"}, - want: "default/redis@status.ready==true", - }, - { - name: "name, readiness check and CEL expression (Flux Applier API)", - ref: meta.DependencyReference{Name: "redis", Ready: new(false), ReadyExpr: "status.ready==true"}, - want: "redis:false@status.ready==true", - }, - { - name: "namespace, name, readiness check and CEL expression (Flux Applier API)", - ref: meta.DependencyReference{Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, - want: "default/redis:true@status.ready==true", + 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: "status.ready==true && status.observed==1"}, - want: "app@status.ready==true && status.observed==1", - }, - { - name: "name with readiness check and complex CEL expression (Flux Applier API)", - ref: meta.DependencyReference{Name: "app", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, - want: "app:true@status.ready==true && status.observed==1", + 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)", @@ -298,55 +189,25 @@ func TestDependencyReferenceString(t *testing.T) { ref: meta.DependencyReference{APIVersion: "apps/v1", Kind: "DaemonSet", Namespace: "default", Name: "redis"}, want: "apps/v1/DaemonSet/default/redis", }, - { - name: "name and readiness check (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "v1", Kind: "Secret", Name: "redis", Ready: new(false)}, - want: "v1/Secret/redis:false", - }, - { - name: "namespace, name and readiness check (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "redis", Ready: new(true)}, - want: "v1/ConfigMap/default/redis:true", - }, { name: "name and CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "v1", Kind: "PersistentVolume", Name: "redis", ReadyExpr: "status.ready==true"}, - want: "v1/PersistentVolume/redis@status.ready==true", + 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: "status.ready==true"}, - want: "v1/PersistentVolumeClaim/default/redis@status.ready==true", - }, - { - name: "name, readiness check and CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "v1", Kind: "Node", Name: "control-plane-2", Ready: new(false), ReadyExpr: "status.ready==true"}, - want: "v1/Node/control-plane-2:false@status.ready==true", - }, - { - name: "namespace, name, readiness check and CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "v1", Kind: "Ingress", Namespace: "default", Name: "redis", Ready: new(true), ReadyExpr: "status.ready==true"}, - want: "v1/Ingress/default/redis:true@status.ready==true", + 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: "name and complex CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "source.toolkit.fluxcd.io/v1", Kind: "GitRepository", Name: "infra-controllers", ReadyExpr: "status.ready==true && status.observed==1"}, - want: "source.toolkit.fluxcd.io/v1/GitRepository/infra-controllers@status.ready==true && status.observed==1", + 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: "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: "status.ready==true && status.observed==1"}, - want: "source.toolkit.fluxcd.io/v1/OCIRepository/flux-system/flux-operator@status.ready==true && status.observed==1", - }, - { - name: "name, readiness check and complex CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, - want: "apiextensions.k8s.io/v1/CustomResourceDefinition/helmreleases.helm.toolkit.fluxcd.io:true@status.ready==true && status.observed==1", - }, - { - name: "namespace, name, readiness check and complex CEL expression (Kubernetes object)", - ref: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "app", Ready: new(true), ReadyExpr: "status.ready==true && status.observed==1"}, - want: "kustomize.toolkit.fluxcd.io/v1/Kustomization/app:true@status.ready==true && status.observed==1", + 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)", }, } @@ -364,11 +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)"}, - {APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "helmreleases.helm.toolkit.fluxcd.io"}, - {APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Name: "cert-manager", Ready: new(false), ReadyExpr: "status.ready==true"}, - {APIVersion: "v1", Kind: "Node", Name: "control-plane-2", Ready: new(true)}, + {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 { diff --git a/apis/meta/reference_types.go b/apis/meta/reference_types.go index e12d9674b..f5b9eabda 100644 --- a/apis/meta/reference_types.go +++ b/apis/meta/reference_types.go @@ -16,8 +16,6 @@ limitations under the License. package meta -import "strconv" - // LocalObjectReference contains enough information to locate the referenced Kubernetes resource object. type LocalObjectReference struct { // Name of the referent. @@ -81,7 +79,7 @@ func (in TypedNamespacedObjectReference) String() string { } // DependencyReference contains enough information to locate the referenced Kubernetes resource object -// with optional built-in or CEL expression readiness check. When the dependency is a Flux Applier API +// 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 { // APIVersion of the resource to depend on, defaults to the API group version of the @@ -104,11 +102,6 @@ type DependencyReference struct { // +optional Namespace string `json:"namespace,omitempty"` - // Ready checks if the resource Ready status condition is true, defaults to - // true when the dependency is a Flux Applier API resource (Kustomization or HelmRelease). - // +optional - Ready *bool `json:"ready,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. @@ -119,11 +112,11 @@ type DependencyReference struct { } // String implements the fmt.Stringer interface for DependencyReference. -// Returns the dependency reference in the format: [apiVersion/][kind/][namespace/]name[:ready][@readyExpr]. +// Returns the dependency reference in the format: [apiVersion/][kind/][namespace/]name[@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", +// "v1/Secret/ns/secret", "v1/Pod/ns/app-abc@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". +// "apiextensions.k8s.io/v1/CustomResourceDefinition/kustomizations.kustomize.toolkit.fluxcd.io". func (in DependencyReference) String() string { s := in.Name if in.Namespace != "" { @@ -135,9 +128,6 @@ func (in DependencyReference) String() string { if in.APIVersion != "" { s = in.APIVersion + "/" + s } - if in.Ready != nil { - s = s + ":" + strconv.FormatBool(*in.Ready) - } if in.ReadyExpr != "" { s = s + "@" + in.ReadyExpr } diff --git a/apis/meta/zz_generated.deepcopy.go b/apis/meta/zz_generated.deepcopy.go index c33d58222..0ab8ae7ca 100644 --- a/apis/meta/zz_generated.deepcopy.go +++ b/apis/meta/zz_generated.deepcopy.go @@ -53,11 +53,6 @@ func (in *Artifact) DeepCopy() *Artifact { // 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 - if in.Ready != nil { - in, out := &in.Ready, &out.Ready - *out = new(bool) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependencyReference. From c915f71802ba43b9ed52e63aa0c772efd3d8d148 Mon Sep 17 00:00:00 2001 From: vecil Date: Mon, 8 Jun 2026 00:15:25 +0200 Subject: [PATCH 3/3] feat(dependency): migrate kstatus and CEL-based dependency checking into shared SDK Dependency checking is consolidated into runtime/dependency so helm-controller and kustomize-controller share the same implementation. Signed-off-by: Vincent Dely Assisted-by: claude-code/claude-fable-5, opencode/minimax-m3-free --- runtime/dependency/check.go | 246 +++++++++++ runtime/dependency/check_test.go | 671 +++++++++++++++++++++++++++++++ runtime/dependency/dependent.go | 42 ++ runtime/dependency/doc.go | 11 +- runtime/dependency/sort.go | 10 - 5 files changed, 968 insertions(+), 12 deletions(-) create mode 100644 runtime/dependency/check.go create mode 100644 runtime/dependency/check_test.go create mode 100644 runtime/dependency/dependent.go diff --git a/runtime/dependency/check.go b/runtime/dependency/check.go new file mode 100644 index 000000000..29df25579 --- /dev/null +++ b/runtime/dependency/check.go @@ -0,0 +1,246 @@ +/* +Copyright 2026 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 dependency + +import ( + "context" + "fmt" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + celtypes "github.com/google/cel-go/common/types" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/cel" + "github.com/fluxcd/pkg/runtime/conditions" +) + +const ( + selfName = "self" + depName = "dep" +) + +// CheckOption configures CheckDependencies behavior. +type CheckOption func(*checkOptions) + +type checkOptions struct { + additiveCEL bool +} + +// WithAdditiveCEL configures CheckDependencies to run both the CEL +// expression evaluation and the built-in kstatus readiness check. +func WithAdditiveCEL() CheckOption { + return func(o *checkOptions) { + o.additiveCEL = true + } +} + +// BuildDependencyExpressions parses the ReadyExpr of each dependency +// declared by obj and returns a slice aligned to obj.GetDependsOn(). +// Entries without a ReadyExpr are nil. Each expression has access to +// self (the parent) and dep (the dependency) as struct variables. +// +// An invalid expression is returned as a reconcile.TerminalError, as it +// cannot recover without a change of spec. Callers should detect it with +// errors.Is and mark the object as stalled instead of retrying. +func BuildDependencyExpressions(obj Dependent) ([]*cel.Expression, error) { + exprs := make([]*cel.Expression, len(obj.GetDependsOn())) + for i, dep := range obj.GetDependsOn() { + if dep.ReadyExpr == "" { + continue + } + expr, err := cel.NewExpression(dep.ReadyExpr, + cel.WithCompile(), + cel.WithOutputType(celtypes.BoolType), + cel.WithStructVariables(selfName, depName), + ) + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("failed to parse expression for dependency %s: %w", dep, err)) + } + exprs[i] = expr + } + return exprs, nil +} + +// CheckDependencies verifies every dependency declared by obj exists and is ready. +// +// A dependency with a non-empty ReadyExpr is evaluated using that CEL +// expression. By default, the ReadyExpr replaces the kstatus check; use +// WithAdditiveCEL to run both. A dependency with an empty ReadyExpr +// falls back to the kstatus check, plus a same-kind Ready-condition +// check because kstatus.Compute() tolerates missing conditions. +// +// An invalid ReadyExpr is returned as a reconcile.TerminalError; all +// other errors are transient and callers should retry. +// +// The given client should preferably be an uncached reader (e.g. the +// manager's APIReader), so that dependency status is observed without +// cache lag and no informers are started for arbitrary dependency kinds. +// +// Controller-specific semantics (e.g. source-revision equality) are not +// handled here; callers should check them after this returns nil. +func CheckDependencies( + ctx context.Context, + c ctrlclient.Reader, + obj *unstructured.Unstructured, + opts ...CheckOption, +) error { + var o checkOptions + for _, opt := range opts { + opt(&o) + } + + deps, err := getDependsOn(obj) + if err != nil { + return err + } + + unstructuredObj := &unstructuredDependent{obj, deps} + exprs, err := BuildDependencyExpressions(unstructuredObj) + if err != nil { + return err + } + + for i, dep := range deps { + dep = ApplyDependencyDefaults(unstructuredObj, dep) + depObj, err := FetchDependency(ctx, c, dep) + if err != nil { + return err + } + + if dep.ReadyExpr != "" { + if err := EvaluateCEL(ctx, obj, depObj, exprs[i]); err != nil { + return err + } + if !o.additiveCEL { + continue + } + } + + stat, err := status.Compute(depObj) + if err != nil { + return fmt.Errorf("dependency %s is not ready: %w", dep, err) + } + if stat.Status != status.CurrentStatus { + return fmt.Errorf("dependency %s is not ready: status %s", dep, stat.Status) + } + + // kstatus.Compute() tolerates missing conditions, so verify the Ready + // condition explicitly for same-Kind dependencies. + if dep.APIVersion != obj.GetAPIVersion() || dep.Kind != obj.GetKind() { + continue + } + if !conditions.IsTrue(conditions.UnstructuredGetter(depObj), meta.ReadyCondition) { + return fmt.Errorf("dependency %s is not ready", dep) + } + } + + return nil +} + +// getDependsOn extracts spec.dependsOn from an unstructured object. +func getDependsOn(obj *unstructured.Unstructured) ([]meta.DependencyReference, error) { + rawSpec, found, err := unstructured.NestedMap(obj.Object, "spec") + if err != nil { + return nil, fmt.Errorf("failed to read spec: %w", err) + } + if !found { + return nil, nil + } + + var spec struct { + DependsOn []meta.DependencyReference `json:"dependsOn"` + } + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(rawSpec, &spec); err != nil { + return nil, fmt.Errorf("failed to parse spec: %w", err) + } + return spec.DependsOn, nil +} + +// ApplyDependencyDefaults applies defaults to dep: Kind defaults to the +// parent's, then APIVersion and Namespace default to the dependent's +// when Kind matches. +func ApplyDependencyDefaults(obj Dependent, dep meta.DependencyReference) meta.DependencyReference { + if dep.Kind == "" { + dep.Kind = obj.GetKind() + } + if dep.Kind != obj.GetKind() { + return dep + } + if dep.APIVersion == "" { + dep.APIVersion = obj.GetAPIVersion() + } + if dep.Namespace == "" { + dep.Namespace = obj.GetNamespace() + } + return dep +} + +// FetchDependency retrieves the dependency object from the cluster. +func FetchDependency( + ctx context.Context, + c ctrlclient.Reader, + dep meta.DependencyReference, +) (*unstructured.Unstructured, error) { + depObj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": dep.APIVersion, + "kind": dep.Kind, + "metadata": map[string]any{ + "name": dep.Name, + "namespace": dep.Namespace, + }, + }, + } + + if err := c.Get(ctx, ctrlclient.ObjectKeyFromObject(depObj), depObj); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("dependency %s not found: %w", dep, err) + } + return nil, fmt.Errorf("failed to get dependency %s: %w", dep, err) + } + return depObj, nil +} + +// EvaluateCEL runs the dep's ReadyExpr with the parent and dependency +// objects as struct variables, and returns nil if it evaluates to true. +func EvaluateCEL( + ctx context.Context, + obj *unstructured.Unstructured, + dep *unstructured.Unstructured, + expr *cel.Expression, +) error { + depID := fmt.Sprintf("%s/%s/%s/%s", + dep.GetAPIVersion(), dep.GetKind(), dep.GetNamespace(), dep.GetName()) + vars := map[string]any{ + selfName: obj.UnstructuredContent(), + depName: dep.UnstructuredContent(), + } + + ready, err := expr.EvaluateBoolean(ctx, vars) + if err != nil { + return fmt.Errorf("failed to evaluate dependency %s: %w", depID, err) + } + if !ready { + return fmt.Errorf("dependency %s is not ready according to readyExpr eval", depID) + } + return nil +} diff --git a/runtime/dependency/check_test.go b/runtime/dependency/check_test.go new file mode 100644 index 000000000..b26952aba --- /dev/null +++ b/runtime/dependency/check_test.go @@ -0,0 +1,671 @@ +/* +Copyright 2026 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 dependency_test + +import ( + "context" + "errors" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/dependency" +) + +var ( + kustomizationGVK = schema.GroupVersionKind{Group: "kustomize.toolkit.fluxcd.io", Version: "v1", Kind: "Kustomization"} + helmReleaseGVK = schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"} + foreignGVK = schema.GroupVersionKind{Group: "other.example.com", Version: "v1", Kind: "Kustomization"} + crdGVK = schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} + podGVK = schema.GroupVersionKind{Version: "v1", Kind: "Pod"} + serviceGVK = schema.GroupVersionKind{Version: "v1", Kind: "Service"} + secretGVK = schema.GroupVersionKind{Version: "v1", Kind: "Secret"} +) + +// Cross-kind and Kubernetes-native dependency references shared across tests. +var ( + certManagerRef = meta.DependencyReference{ + APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Namespace: "default", Name: "cert-manager", + } + secretRef = meta.DependencyReference{APIVersion: "v1", Kind: "Secret", Namespace: "default", Name: "credentials"} + podRef = meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "workload"} + crdRef = meta.DependencyReference{ + APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", + Name: "kustomizations.kustomize.toolkit.fluxcd.io", + } +) + +// newObject returns an unstructured object of the given kind, with an +// optional status subresource. An empty namespace makes the object +// cluster-scoped. +func newObject(gvk schema.GroupVersionKind, namespace, name string, status ...map[string]any) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetNamespace(namespace) + obj.SetName(name) + if len(status) > 0 { + obj.Object["status"] = status[0] + } + return obj +} + +// newDependent returns an unstructured object named default/applier of the +// given kind, declaring the given dependencies in spec.dependsOn. +func newDependent(gvk schema.GroupVersionKind, deps ...meta.DependencyReference) *unstructured.Unstructured { + obj := newObject(gvk, "default", "applier") + if len(deps) == 0 { + return obj + } + + list := make([]any, 0, len(deps)) + for _, dep := range deps { + ref := map[string]any{"name": dep.Name} + if dep.APIVersion != "" { + ref["apiVersion"] = dep.APIVersion + } + if dep.Kind != "" { + ref["kind"] = dep.Kind + } + if dep.Namespace != "" { + ref["namespace"] = dep.Namespace + } + if dep.ReadyExpr != "" { + ref["readyExpr"] = dep.ReadyExpr + } + list = append(list, ref) + } + + obj.Object["spec"] = map[string]any{"dependsOn": list} + return obj +} + +// withReadyExpr returns a copy of ref with the given CEL readiness expression. +func withReadyExpr(ref meta.DependencyReference, expr string) meta.DependencyReference { + ref.ReadyExpr = expr + return ref +} + +// condition returns a status condition of the given type and status. +func condition(condType, condStatus string) map[string]any { + return map[string]any{ + "type": condType, + "status": condStatus, + "reason": "TestReason", + "message": "test message", + "lastTransitionTime": "2026-01-01T00:00:00Z", + } +} + +// readyStatus returns a status subresource with the Ready condition set to +// the given status. +func readyStatus(condStatus string) map[string]any { + return map[string]any{ + "conditions": []any{condition(meta.ReadyCondition, condStatus)}, + } +} + +// checkCase is a single CheckDependencies scenario run by runCheckCases. +type checkCase struct { + name string + obj *unstructured.Unstructured + objects []ctrlclient.Object + err string + terminal bool +} + +// runCheckCases runs CheckDependencies with the given options for each +// case, against a fake client seeded with the case objects. +func runCheckCases(t *testing.T, cases []checkCase, opts ...dependency.CheckOption) { + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + c := fake.NewClientBuilder().WithObjects(tt.objects...).Build() + err := dependency.CheckDependencies(context.Background(), c, tt.obj, opts...) + if tt.err == "" { + g.Expect(err).NotTo(HaveOccurred()) + return + } + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(Equal(tt.terminal), "terminal error mismatch") + }) + } +} + +func TestBuildDependencyExpressions(t *testing.T) { + for _, tt := range []struct { + name string + deps []meta.DependencyReference + nils []bool + err string + }{ + { + name: "no expressions", + deps: []meta.DependencyReference{ + {Name: "infra"}, + {Kind: "HelmRelease", Name: "cert-manager"}, + }, + nils: []bool{true, true}, + }, + { + name: "expressions aligned to dependencies", + deps: []meta.DependencyReference{ + {Name: "infra"}, + {Kind: "Pod", Name: "workload", ReadyExpr: `dep.status.phase == 'Running'`}, + {Name: "backend", ReadyExpr: `self.metadata.namespace == dep.metadata.namespace`}, + }, + nils: []bool{true, false, false}, + }, + { + name: "invalid expression syntax", + deps: []meta.DependencyReference{ + {Name: "infra", ReadyExpr: `dep.metadata.name ==`}, + }, + err: "failed to parse expression for dependency infra@dep.metadata.name ==", + }, + { + name: "expression not evaluating to a boolean", + deps: []meta.DependencyReference{ + {Name: "infra", ReadyExpr: `dep.metadata.name`}, + }, + err: "failed to parse expression for dependency infra@dep.metadata.name", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + exprs, err := dependency.BuildDependencyExpressions(&object{ + apiVersion: kustomizationGVK.GroupVersion().String(), + kind: kustomizationGVK.Kind, + name: "applier", + namespace: "default", + dependsOn: tt.deps, + }) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue(), "expected a terminal error") + g.Expect(exprs).To(BeNil()) + return + } + + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(exprs).To(HaveLen(len(tt.deps))) + for i, isNil := range tt.nils { + if isNil { + g.Expect(exprs[i]).To(BeNil(), "expression %d", i) + } else { + g.Expect(exprs[i]).NotTo(BeNil(), "expression %d", i) + } + } + }) + } +} + +func TestApplyDependencyDefaults(t *testing.T) { + parent := &object{ + apiVersion: kustomizationGVK.GroupVersion().String(), + kind: kustomizationGVK.Kind, + name: "applier", + namespace: "default", + } + + for _, tt := range []struct { + name string + dep meta.DependencyReference + want meta.DependencyReference + }{ + { + name: "name only defaults to the parent kind, API version and namespace", + dep: meta.DependencyReference{Name: "infra"}, + want: meta.DependencyReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Namespace: "default", + Name: "infra", + }, + }, + { + name: "same kind keeps the given namespace", + dep: meta.DependencyReference{Namespace: "infra-ns", Name: "infra"}, + want: meta.DependencyReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Namespace: "infra-ns", + Name: "infra", + }, + }, + { + name: "same kind keeps the given API version", + dep: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1beta2", Kind: "Kustomization", Name: "infra"}, + want: meta.DependencyReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1beta2", + Kind: "Kustomization", + Namespace: "default", + Name: "infra", + }, + }, + { + name: "same kind matching ignores the API group", + dep: meta.DependencyReference{APIVersion: "other.example.com/v1", Kind: "Kustomization", Name: "infra"}, + want: meta.DependencyReference{ + APIVersion: "other.example.com/v1", + Kind: "Kustomization", + Namespace: "default", + Name: "infra", + }, + }, + { + name: "cross-kind gets no defaults", + dep: meta.DependencyReference{APIVersion: "helm.toolkit.fluxcd.io/v2", Kind: "HelmRelease", Name: "infra"}, + want: meta.DependencyReference{ + APIVersion: "helm.toolkit.fluxcd.io/v2", + Kind: "HelmRelease", + Name: "infra", + }, + }, + { + name: "fully specified reference is unchanged", + dep: meta.DependencyReference{ + APIVersion: "v1", + Kind: "Secret", + Namespace: "apps", + Name: "credentials", + }, + want: meta.DependencyReference{ + APIVersion: "v1", + Kind: "Secret", + Namespace: "apps", + Name: "credentials", + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(dependency.ApplyDependencyDefaults(parent, tt.dep)).To(Equal(tt.want)) + }) + } +} + +func TestEvaluateCEL(t *testing.T) { + for _, tt := range []struct { + name string + expr string + err string + }{ + { + name: "expression evaluates to true", + expr: `self.metadata.namespace == dep.metadata.namespace`, + }, + { + name: "expression evaluates to false", + expr: `dep.metadata.name == 'other'`, + err: "dependency v1/Pod/default/workload is not ready according to readyExpr eval", + }, + { + name: "expression fails to evaluate on a missing field", + expr: `dep.status.phase == 'Running'`, + err: "failed to evaluate dependency v1/Pod/default/workload", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + exprs, err := dependency.BuildDependencyExpressions(&object{ + dependsOn: []meta.DependencyReference{{Kind: "Pod", Name: "workload", ReadyExpr: tt.expr}}, + }) + g.Expect(err).NotTo(HaveOccurred()) + + self := newObject(kustomizationGVK, "default", "applier") + depObj := newObject(podGVK, "default", "workload") + err = dependency.EvaluateCEL(context.Background(), self, depObj, exprs[0]) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + }) + } +} + +// TestCheckDependencies covers the parsing of spec.dependsOn from the +// object declaring the dependencies. +func TestCheckDependencies(t *testing.T) { + noDependsOn := newObject(kustomizationGVK, "default", "applier") + noDependsOn.Object["spec"] = map[string]any{"interval": "5m"} + + malformedSpec := newObject(kustomizationGVK, "default", "applier") + malformedSpec.Object["spec"] = "invalid" + + malformedDependsOn := newObject(kustomizationGVK, "default", "applier") + malformedDependsOn.Object["spec"] = map[string]any{"dependsOn": "invalid"} + + runCheckCases(t, []checkCase{ + { + name: "no dependencies", + obj: newDependent(kustomizationGVK), + }, + { + name: "spec without dependsOn", + obj: noDependsOn, + }, + { + name: "malformed spec", + obj: malformedSpec, + err: "failed to read spec", + }, + { + name: "malformed dependsOn", + obj: malformedDependsOn, + err: "failed to parse spec", + }, + }) +} + +// TestCheckDependencies_SameKind covers dependencies between objects of +// the same kind, where the namespace and API version default to the +// parent's, and the Ready condition is verified in addition to kstatus. +func TestCheckDependencies_SameKind(t *testing.T) { + staleKustomization := newObject(kustomizationGVK, "default", "infra", + map[string]any{"observedGeneration": int64(1)}) + staleKustomization.SetGeneration(2) + + terminatingKustomization := newObject(kustomizationGVK, "default", "infra", readyStatus("True")) + terminatingKustomization.SetDeletionTimestamp(new(metav1.Now())) + terminatingKustomization.SetFinalizers([]string{"finalizers.fluxcd.io"}) + + runCheckCases(t, []checkCase{ + { + name: "Kustomization depending on a ready Kustomization with defaults applied", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra", readyStatus("True"))}, + }, + { + name: "HelmRelease depending on a ready HelmRelease with defaults applied", + obj: newDependent(helmReleaseGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{newObject(helmReleaseGVK, "default", "infra", readyStatus("True"))}, + }, + { + name: "dependency in another namespace", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Namespace: "infra-ns", Name: "infra"}), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "infra-ns", "infra", readyStatus("True"))}, + }, + { + name: "dependency not found", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra not found", + }, + { + name: "dependency without Ready condition", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra")}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra is not ready", + }, + { + name: "dependency with Ready condition False", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra", readyStatus("False"))}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra is not ready: status InProgress", + }, + { + name: "dependency with stale observed generation", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{staleKustomization}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra is not ready: status InProgress", + }, + { + name: "dependency being deleted", + obj: newDependent(kustomizationGVK, meta.DependencyReference{Name: "infra"}), + objects: []ctrlclient.Object{terminatingKustomization}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra is not ready: status Terminating", + }, + { + name: "dependency of same kind but in a different API group doesn't require the Ready condition", + obj: newDependent(kustomizationGVK, meta.DependencyReference{ + APIVersion: "other.example.com/v1", Kind: "Kustomization", Name: "infra", + }), + objects: []ctrlclient.Object{newObject(foreignGVK, "default", "infra")}, + }, + { + name: "multiple dependencies with one not ready", + obj: newDependent(kustomizationGVK, + meta.DependencyReference{Name: "infra"}, + meta.DependencyReference{Name: "backend"}, + ), + objects: []ctrlclient.Object{ + newObject(kustomizationGVK, "default", "infra", readyStatus("True")), + newObject(kustomizationGVK, "default", "backend", readyStatus("False")), + }, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/backend is not ready", + }, + }) +} + +// TestCheckDependencies_CrossKind covers dependencies on a kind other than +// the parent's: Flux Applier APIs depending on each other and on +// Kubernetes-native resources, checked with kstatus only. +func TestCheckDependencies_CrossKind(t *testing.T) { + readyCertManager := newObject(helmReleaseGVK, "default", "cert-manager", readyStatus("True")) + establishedCRD := newObject(crdGVK, "", crdRef.Name, map[string]any{ + "conditions": []any{condition("Established", "True")}, + }) + + infraKustomizationRef := meta.DependencyReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Namespace: "default", Name: "infra", + } + + lbService := newObject(serviceGVK, "default", "app") + lbService.Object["spec"] = map[string]any{"type": "LoadBalancer"} + + runCheckCases(t, []checkCase{ + { + name: "Kustomization depending on a ready HelmRelease", + obj: newDependent(kustomizationGVK, certManagerRef), + objects: []ctrlclient.Object{readyCertManager}, + }, + { + name: "Kustomization depending on a failed HelmRelease", + obj: newDependent(kustomizationGVK, certManagerRef), + objects: []ctrlclient.Object{newObject(helmReleaseGVK, "default", "cert-manager", readyStatus("False"))}, + err: "dependency helm.toolkit.fluxcd.io/v2/HelmRelease/default/cert-manager is not ready: status InProgress", + }, + { + name: "Kustomization depending on a stalled HelmRelease", + obj: newDependent(kustomizationGVK, certManagerRef), + objects: []ctrlclient.Object{ + newObject(helmReleaseGVK, "default", "cert-manager", map[string]any{ + "conditions": []any{condition("Stalled", "True")}, + }), + }, + err: "dependency helm.toolkit.fluxcd.io/v2/HelmRelease/default/cert-manager is not ready: status Failed", + }, + { + name: "HelmRelease depending on a ready Kustomization", + obj: newDependent(helmReleaseGVK, infraKustomizationRef), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra", readyStatus("True"))}, + }, + { + name: "HelmRelease depending on a not ready Kustomization", + obj: newDependent(helmReleaseGVK, infraKustomizationRef), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra", readyStatus("False"))}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra is not ready: status InProgress", + }, + { + name: "HelmRelease depending on an existing Secret", + obj: newDependent(helmReleaseGVK, secretRef), + objects: []ctrlclient.Object{newObject(secretGVK, "default", "credentials")}, + }, + { + name: "Pod dependency running and ready", + obj: newDependent(kustomizationGVK, podRef), + objects: []ctrlclient.Object{ + newObject(podGVK, "default", "workload", map[string]any{ + "phase": "Running", + "conditions": []any{condition("Ready", "True")}, + }), + }, + }, + { + name: "Pod dependency pending", + obj: newDependent(kustomizationGVK, podRef), + objects: []ctrlclient.Object{newObject(podGVK, "default", "workload", map[string]any{"phase": "Pending"})}, + err: "dependency v1/Pod/default/workload is not ready: status InProgress", + }, + { + name: "Pod dependency in Unknown phase", + obj: newDependent(kustomizationGVK, podRef), + objects: []ctrlclient.Object{newObject(podGVK, "default", "workload", map[string]any{"phase": "Unknown"})}, + err: "dependency v1/Pod/default/workload is not ready: unknown phase Unknown", + }, + { + name: "Service dependency of type LoadBalancer without cluster IP", + obj: newDependent(kustomizationGVK, meta.DependencyReference{ + APIVersion: "v1", Kind: "Service", Namespace: "default", Name: "app", + }), + objects: []ctrlclient.Object{lbService}, + err: "dependency v1/Service/default/app is not ready: status InProgress", + }, + { + name: "cluster-scoped CRD dependency established", + obj: newDependent(kustomizationGVK, crdRef), + objects: []ctrlclient.Object{establishedCRD}, + }, + { + name: "cluster-scoped CRD dependency not established", + obj: newDependent(kustomizationGVK, crdRef), + objects: []ctrlclient.Object{newObject(crdGVK, "", crdRef.Name)}, + err: "dependency apiextensions.k8s.io/v1/CustomResourceDefinition/kustomizations.kustomize.toolkit.fluxcd.io is not ready: status InProgress", + }, + { + name: "namespaced dependency without namespace is not found", + obj: newDependent(kustomizationGVK, meta.DependencyReference{APIVersion: "v1", Kind: "Secret", Name: "credentials"}), + objects: []ctrlclient.Object{newObject(secretGVK, "default", "credentials")}, + err: "dependency v1/Secret/credentials not found", + }, + { + // The readyExpr of the second dependency must be evaluated + // against the second dependency only, verifying the expression + // slice stays aligned in a list of mixed dependencies. + name: "multiple dependencies of mixed kinds and readyExpr all ready", + obj: newDependent(kustomizationGVK, + meta.DependencyReference{Name: "infra"}, + withReadyExpr(certManagerRef, `dep.metadata.name == 'cert-manager'`), + crdRef, + ), + objects: []ctrlclient.Object{ + newObject(kustomizationGVK, "default", "infra", readyStatus("True")), + readyCertManager, + establishedCRD, + }, + }, + }) +} + +// TestCheckDependencies_ReadyExpr covers dependencies with a CEL readiness +// expression, which by default replaces the built-in kstatus check. +func TestCheckDependencies_ReadyExpr(t *testing.T) { + runCheckCases(t, []checkCase{ + { + // The dependency has no Ready condition, so the built-in + // same-kind check would fail: the readyExpr replaces it. + name: "readyExpr replaces the built-in readiness check", + obj: newDependent(kustomizationGVK, withReadyExpr(meta.DependencyReference{Name: "infra"}, `dep.metadata.name == 'infra'`)), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra")}, + }, + { + name: "readyExpr on the dependency conditions", + obj: newDependent(kustomizationGVK, withReadyExpr(certManagerRef, + `dep.status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')`)), + objects: []ctrlclient.Object{newObject(helmReleaseGVK, "default", "cert-manager", readyStatus("True"))}, + }, + { + name: "readyExpr referencing only self", + obj: newDependent(kustomizationGVK, withReadyExpr(secretRef, `self.metadata.name == 'applier'`)), + objects: []ctrlclient.Object{newObject(secretGVK, "default", "credentials")}, + }, + { + name: "readyExpr not met", + obj: newDependent(kustomizationGVK, withReadyExpr(secretRef, `has(dep.data)`)), + objects: []ctrlclient.Object{newObject(secretGVK, "default", "credentials")}, + err: "dependency v1/Secret/default/credentials is not ready according to readyExpr eval", + }, + { + name: "readyExpr evaluation error on a missing field is transient", + obj: newDependent(kustomizationGVK, withReadyExpr(podRef, `dep.status.phase == 'Running'`)), + objects: []ctrlclient.Object{newObject(podGVK, "default", "workload")}, + err: "failed to evaluate dependency v1/Pod/default/workload", + }, + { + name: "invalid readyExpr fails terminally before any dependency is fetched", + obj: newDependent(kustomizationGVK, withReadyExpr(meta.DependencyReference{Name: "infra"}, `dep.metadata.name ==`)), + err: "failed to parse expression for dependency infra@dep.metadata.name ==", + terminal: true, + }, + }) +} + +// TestCheckDependencies_AdditiveCEL covers the WithAdditiveCEL option, +// which combines the readyExpr with the built-in check instead of +// replacing it. +func TestCheckDependencies_AdditiveCEL(t *testing.T) { + readyCertManager := newObject(helmReleaseGVK, "default", "cert-manager", readyStatus("True")) + + runCheckCases(t, []checkCase{ + { + name: "requires the built-in check to pass", + obj: newDependent(kustomizationGVK, withReadyExpr(certManagerRef, `dep.metadata.name == 'cert-manager'`)), + objects: []ctrlclient.Object{newObject(helmReleaseGVK, "default", "cert-manager", readyStatus("False"))}, + err: "dependency helm.toolkit.fluxcd.io/v2/HelmRelease/default/cert-manager@dep.metadata.name == 'cert-manager' is not ready: status InProgress", + }, + { + name: "requires the readyExpr to pass", + obj: newDependent(kustomizationGVK, withReadyExpr(certManagerRef, `dep.metadata.name == 'other'`)), + objects: []ctrlclient.Object{readyCertManager}, + err: "dependency helm.toolkit.fluxcd.io/v2/HelmRelease/default/cert-manager is not ready according to readyExpr eval", + }, + { + name: "passes when both checks pass", + obj: newDependent(kustomizationGVK, withReadyExpr(certManagerRef, `dep.metadata.name == 'cert-manager'`)), + objects: []ctrlclient.Object{readyCertManager}, + }, + { + name: "without readyExpr falls back to the built-in check", + obj: newDependent(kustomizationGVK, certManagerRef), + objects: []ctrlclient.Object{readyCertManager}, + }, + { + // The readyExpr passes and kstatus reports Current, but the + // same-kind dependency has no Ready condition. + name: "still requires the same-kind Ready condition", + obj: newDependent(kustomizationGVK, withReadyExpr(meta.DependencyReference{Name: "infra"}, `dep.metadata.name == 'infra'`)), + objects: []ctrlclient.Object{newObject(kustomizationGVK, "default", "infra")}, + err: "dependency kustomize.toolkit.fluxcd.io/v1/Kustomization/default/infra@dep.metadata.name == 'infra' is not ready", + }, + }, dependency.WithAdditiveCEL()) +} diff --git a/runtime/dependency/dependent.go b/runtime/dependency/dependent.go new file mode 100644 index 000000000..d0d69bfc3 --- /dev/null +++ b/runtime/dependency/dependent.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 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 dependency + +import ( + "github.com/fluxcd/pkg/apis/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Dependent is a Kubernetes object that carries identity metadata +// and dependency declarations for sorting, existence verification, +// and readiness checking. +type Dependent interface { + GetAPIVersion() string + GetKind() string + GetName() string + GetNamespace() string + meta.ObjectWithDependencies +} + +// unstructuredDependent adapts an *unstructured.Unstructured to the +// Dependent interface by injecting the parsed spec.dependsOn list. +type unstructuredDependent struct { + *unstructured.Unstructured + deps []meta.DependencyReference +} + +func (d *unstructuredDependent) GetDependsOn() []meta.DependencyReference { return d.deps } diff --git a/runtime/dependency/doc.go b/runtime/dependency/doc.go index 96c729fdb..42925104d 100644 --- a/runtime/dependency/doc.go +++ b/runtime/dependency/doc.go @@ -14,6 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package dependency contains an utility for sorting a set of Kubernetes -// resource objects that implement the Dependent interface. +// Package dependency provides topological sorting and readiness checking +// of Kubernetes resource dependencies. +// +// Sort orders Dependent objects by their dependsOn references using +// depth-first search. +// +// CheckDependencies verifies that all dependencies of an object exist in +// the cluster and meet readiness criteria, with optional CEL-based +// readiness expressions. package dependency diff --git a/runtime/dependency/sort.go b/runtime/dependency/sort.go index d922208e1..38fd617f2 100644 --- a/runtime/dependency/sort.go +++ b/runtime/dependency/sort.go @@ -24,16 +24,6 @@ import ( "github.com/fluxcd/pkg/apis/meta" ) -// Dependent interface defines methods that a Kubernetes resource object should -// implement in order to use the dependency package for ordering dependencies. -type Dependent interface { - GetAPIVersion() string - GetKind() string - GetName() string - GetNamespace() string - meta.ObjectWithDependencies -} - const ( unmarked = iota permanentMark