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 4712b5a4e109f85281cf6748340e7360ff1b2b29 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 | 12 +- apis/meta/zz_generated.deepcopy.go | 5 - 4 files changed, 55 insertions(+), 223 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..f88db5af6 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. @@ -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 337759d00277afa7f582be81316f1620f114a0db 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: opencode/minimax-m3-free, opencode/mimo-v2.5-free --- runtime/dependency/check.go | 235 ++++++++++++ runtime/dependency/check_test.go | 634 +++++++++++++++++++++++++++++++ runtime/dependency/dependent.go | 42 ++ runtime/dependency/doc.go | 11 +- runtime/dependency/sort.go | 10 - 5 files changed, 920 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..17f2f449a --- /dev/null +++ b/runtime/dependency/check.go @@ -0,0 +1,235 @@ +/* +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" + + 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" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + + "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. +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, 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. +// +// 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.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..66b01cfd0 --- /dev/null +++ b/runtime/dependency/check_test.go @@ -0,0 +1,634 @@ +/* +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" + "testing" + + celtypes "github.com/google/cel-go/common/types" + . "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" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/cel" + "github.com/fluxcd/pkg/runtime/conditions" + "github.com/fluxcd/pkg/runtime/dependency" +) + +var ( + cmGVK = schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"} + podGVK = schema.GroupVersionKind{Version: "v1", Kind: "Pod"} + kustomizationGVK = schema.GroupVersionKind{Group: "kustomize.toolkit.fluxcd.io", Version: "v1", Kind: "Kustomization"} + + testScheme = func() *runtime.Scheme { + s := runtime.NewScheme() + s.AddKnownTypeWithName(cmGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(podGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(kustomizationGVK, &unstructured.Unstructured{}) + return s + }() +) + +func newDep(gvk schema.GroupVersionKind, ns, name string, status map[string]any, conds ...*metav1.Condition) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetName(name) + u.SetNamespace(ns) + for k, v := range status { + _ = unstructured.SetNestedField(u.Object, v, "status", k) + } + if len(conds) > 0 { + list := make([]metav1.Condition, len(conds)) + for i, c := range conds { + list[i] = *c + } + conditions.UnstructuredSetter(u).SetConditions(list) + } + return u +} + +func applier(refs ...meta.DependencyReference) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(kustomizationGVK) + obj.SetName("applier") + obj.SetNamespace("default") + if len(refs) > 0 { + depMaps := make([]any, len(refs)) + for i, ref := range refs { + depMaps[i] = map[string]any{ + "apiVersion": ref.APIVersion, + "kind": ref.Kind, + "name": ref.Name, + "namespace": ref.Namespace, + "readyExpr": ref.ReadyExpr, + } + } + _ = unstructured.SetNestedSlice(obj.Object, depMaps, "spec", "dependsOn") + } + return obj +} + +func condition(condType string, status metav1.ConditionStatus) *metav1.Condition { + return &metav1.Condition{Type: condType, Status: status, LastTransitionTime: metav1.Now()} +} + +func TestBuildDependencyExpressions(t *testing.T) { + for _, tt := range []struct { + name string + deps []meta.DependencyReference + nils []bool + err string + }{ + { + name: "all empty ReadyExpr", + deps: []meta.DependencyReference{ + {Kind: "Pod", Name: "pod1"}, + {Kind: "Pod", Name: "pod2"}, + }, + nils: []bool{true, true}, + }, + { + name: "all valid ReadyExpr", + deps: []meta.DependencyReference{ + {Kind: "Pod", Name: "pod1", ReadyExpr: "true"}, + {Kind: "Pod", Name: "pod2", ReadyExpr: "dep.status.phase == 'Running'"}, + }, + nils: []bool{false, false}, + }, + { + name: "mixed empty and valid ReadyExpr", + deps: []meta.DependencyReference{ + {Kind: "Pod", Name: "pod1"}, + {Kind: "Pod", Name: "pod2", ReadyExpr: "true"}, + {Kind: "Pod", Name: "pod3", ReadyExpr: "dep.status.phase == 'Running'"}, + }, + nils: []bool{true, false, false}, + }, + { + name: "invalid ReadyExpr syntax", + deps: []meta.DependencyReference{ + {Kind: "Pod", Name: "pod1", ReadyExpr: "foo."}, + }, + err: "failed to parse expression for dependency Pod/pod1", + }, + { + name: "invalid ReadyExpr undeclared variable", + deps: []meta.DependencyReference{ + {Kind: "Pod", Name: "pod1", ReadyExpr: "deps.metadata.generation == self.metadata.generation"}, + }, + err: "failed to parse expression for dependency Pod/pod1", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + obj := &object{kind: "Kustomization", dependsOn: tt.deps} + exprs, err := dependency.BuildDependencyExpressions(obj) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + g.Expect(exprs).To(BeNil()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(exprs).To(HaveLen(len(tt.deps))) + for i, wantNil := range tt.nils { + if wantNil { + g.Expect(exprs[i]).To(BeNil()) + } else { + g.Expect(exprs[i]).NotTo(BeNil()) + } + } + }) + } +} + +func TestApplyDependencyDefaults(t *testing.T) { + p := &object{ + apiVersion: "kustomize.toolkit.fluxcd.io/v1", + kind: "Kustomization", + name: "self", + namespace: "default", + } + for _, tt := range []struct { + name string + dep meta.DependencyReference + want meta.DependencyReference + }{ + { + name: "Kind empty defaults to parent", + dep: meta.DependencyReference{Name: "ks1", Namespace: "other"}, + want: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "ks1", Namespace: "other"}, + }, + { + name: "Kind different from parent leaves APIVersion/Namespace alone", + dep: meta.DependencyReference{Kind: "ConfigMap", Name: "cm1"}, + want: meta.DependencyReference{Kind: "ConfigMap", Name: "cm1"}, + }, + { + name: "Kind matches, APIVersion empty defaults to parent", + dep: meta.DependencyReference{Kind: "Kustomization", Name: "ks1"}, + want: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "ks1", Namespace: "default"}, + }, + { + name: "all set, no changes", + dep: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "ks1", Namespace: "default"}, + want: meta.DependencyReference{APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "ks1", Namespace: "default"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(dependency.ApplyDependencyDefaults(p, tt.dep)).To(Equal(tt.want)) + }) + } +} + +func TestFetchDependency(t *testing.T) { + for _, tt := range []struct { + name string + dep meta.DependencyReference + depObj *unstructured.Unstructured + err string + }{ + { + name: "object exists", + dep: meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1"}, + depObj: newDep(podGVK, "default", "pod1", nil), + }, + { + name: "object not found", + dep: meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "missing"}, + err: "not found", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + builder := fake.NewClientBuilder().WithScheme(testScheme) + if tt.depObj != nil { + builder = builder.WithObjects(tt.depObj) + } + got, err := dependency.FetchDependency(context.Background(), builder.Build(), tt.dep) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + g.Expect(got).To(BeNil()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).NotTo(BeNil()) + g.Expect(got.GetName()).To(Equal(tt.dep.Name)) + }) + } +} + +func TestEvaluateCEL(t *testing.T) { + for _, tt := range []struct { + name string + expr string + obj *unstructured.Unstructured + dep *unstructured.Unstructured + err string + }{ + { + name: "true", + expr: "dep.status.phase == 'Running'", + obj: applier(), + dep: newDep(podGVK, "default", "pod1", map[string]any{"phase": "Running"}), + }, + { + name: "false", + expr: "dep.status.phase == 'Running'", + obj: applier(), + dep: newDep(podGVK, "default", "pod1", map[string]any{"phase": "Pending"}), + err: "not ready according to readyExpr", + }, + { + name: "evaluation error", + expr: "dep.status.missing.field == 'x'", + obj: applier(), + dep: newDep(podGVK, "default", "pod1", nil), + err: "failed to evaluate dependency", + }, + { + name: "true with self property access", + expr: "self.metadata.annotations['key'] == dep.metadata.annotations['key']", + obj: func() *unstructured.Unstructured { + obj := applier() + obj.SetAnnotations(map[string]string{"key": "val"}) + return obj + }(), + dep: func() *unstructured.Unstructured { + d := newDep(podGVK, "default", "pod1", nil) + d.SetAnnotations(map[string]string{"key": "val"}) + return d + }(), + }, + { + name: "false with self property access", + expr: "self.metadata.annotations['key'] == dep.metadata.annotations['key']", + obj: func() *unstructured.Unstructured { + obj := applier() + obj.SetAnnotations(map[string]string{"key": "val1"}) + return obj + }(), + dep: func() *unstructured.Unstructured { + d := newDep(podGVK, "default", "pod1", nil) + d.SetAnnotations(map[string]string{"key": "val2"}) + return d + }(), + err: "not ready according to readyExpr", + }, + { + name: "has() function true", + expr: "has(dep.data)", + obj: applier(), + dep: func() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(cmGVK) + u.SetName("cm1") + u.SetNamespace("default") + _ = unstructured.SetNestedField(u.Object, map[string]any{"key": "val"}, "data") + return u + }(), + }, + { + name: "has() function false", + expr: "has(dep.data)", + obj: applier(), + dep: func() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(cmGVK) + u.SetName("cm1") + u.SetNamespace("default") + return u + }(), + err: "not ready according to readyExpr", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + expr, err := cel.NewExpression(tt.expr, + cel.WithCompile(), + cel.WithOutputType(celtypes.BoolType), + cel.WithStructVariables("self", "dep"), + ) + g.Expect(err).NotTo(HaveOccurred()) + err = dependency.EvaluateCEL(context.Background(), tt.obj, tt.dep, expr) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + return + } + g.Expect(err).NotTo(HaveOccurred()) + }) + } +} + +func TestCheckDependencies(t *testing.T) { + for _, tt := range []struct { + name string + obj *unstructured.Unstructured + deps []*unstructured.Unstructured + opts []dependency.CheckOption + err string + }{ + { + name: "Ready=True passes", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + newDep(kustomizationGVK, "default", "r1", nil, + condition(meta.ReadyCondition, metav1.ConditionTrue)), + }, + }, + { + name: "no conditions fails (same-kind check)", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + newDep(kustomizationGVK, "default", "r1", nil), + }, + err: "is not ready", + }, + { + name: "non-Ready condition only fails", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + newDep(kustomizationGVK, "default", "r1", nil, + condition("Available", metav1.ConditionTrue)), + }, + err: "is not ready", + }, + { + name: "Ready=Unknown caught by kstatus", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + newDep(kustomizationGVK, "default", "r1", nil, + condition(meta.ReadyCondition, metav1.ConditionUnknown)), + }, + err: "not ready: status InProgress", + }, + { + name: "Ready=False with matching ObsGen/Gen fails (same-kind check)", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + newDep(kustomizationGVK, "default", "r1", + map[string]any{"observedGeneration": int64(1)}, + condition(meta.ReadyCondition, metav1.ConditionFalse)), + }, + err: "is not ready", + }, + { + name: "Ready=True but ObsGen < Gen caught by kstatus (same-kind)", + obj: applier(meta.DependencyReference{Kind: "Kustomization", Name: "r1"}), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + d := newDep(kustomizationGVK, "default", "r1", + map[string]any{"observedGeneration": int64(1)}, + condition(meta.ReadyCondition, metav1.ConditionTrue)) + d.SetGeneration(2) + return d + }(), + }, + err: "not ready: status InProgress", + }, + { + name: "Ready=True but ObsGen < Gen caught by kstatus (cross-kind Pod)", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "dep.status.phase == 'Running'", + }), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + d := newDep(podGVK, "default", "pod1", + map[string]any{"phase": "Running", "observedGeneration": int64(1)}) + d.SetGeneration(2) + return d + }(), + }, + opts: []dependency.CheckOption{dependency.WithAdditiveCEL()}, + err: "not ready", + }, + { + name: "CEL true", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "dep.status.phase == 'Running'", + }), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", map[string]any{"phase": "Running"}), + }, + }, + { + name: "CEL false", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "dep.status.phase == 'Running'", + }), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", map[string]any{"phase": "Pending"}), + }, + err: "not ready according to readyExpr", + }, + { + name: "CEL evaluation error", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "dep.status.missing.field == 'x'", + }), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", nil), + }, + err: "failed to evaluate dependency", + }, + { + name: "CEL true additive still runs kstatus check", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "dep.status.phase == 'Running'", + }), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", map[string]any{"phase": "Running"}), + }, + opts: []dependency.CheckOption{dependency.WithAdditiveCEL()}, + err: "not ready", + }, + { + name: "CEL with self property access match", + obj: func() *unstructured.Unstructured { + obj := applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "self.metadata.annotations['app/version'] == dep.metadata.annotations['app/version']", + }) + obj.SetAnnotations(map[string]string{"app/version": "v1.2.3"}) + return obj + }(), + deps: func() []*unstructured.Unstructured { + d := newDep(podGVK, "default", "pod1", nil) + d.SetAnnotations(map[string]string{"app/version": "v1.2.3"}) + return []*unstructured.Unstructured{d} + }(), + }, + { + name: "CEL with self property access mismatch", + obj: func() *unstructured.Unstructured { + obj := applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", + ReadyExpr: "self.metadata.annotations['app/version'] == dep.metadata.annotations['app/version']", + }) + obj.SetAnnotations(map[string]string{"app/version": "v1.2.4"}) + return obj + }(), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + d := newDep(podGVK, "default", "pod1", nil) + d.SetAnnotations(map[string]string{"app/version": "v1.2.3"}) + return d + }(), + }, + err: "not ready according to readyExpr", + }, + { + name: "CEL with has() function true", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "cm1", + ReadyExpr: "has(dep.data)", + }), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(cmGVK) + u.SetName("cm1") + u.SetNamespace("default") + _ = unstructured.SetNestedField(u.Object, map[string]any{"key": "val"}, "data") + return u + }(), + }, + }, + { + name: "CEL with has() function false", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "cm1", + ReadyExpr: "has(dep.data)", + }), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(cmGVK) + u.SetName("cm1") + u.SetNamespace("default") + return u + }(), + }, + err: "not ready according to readyExpr", + }, + { + name: "CEL with !has() function", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "cm1", + ReadyExpr: "!has(dep.data)", + }), + deps: []*unstructured.Unstructured{ + func() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(cmGVK) + u.SetName("cm1") + u.SetNamespace("default") + _ = unstructured.SetNestedField(u.Object, map[string]any{"key": "val"}, "data") + return u + }(), + }, + err: "not ready according to readyExpr", + }, + { + name: "cross-kind ConfigMap skips check", + obj: applier(meta.DependencyReference{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "config1"}), + deps: []*unstructured.Unstructured{ + newDep(cmGVK, "default", "config1", nil), + }, + }, + { + name: "missing object returns not found", + obj: applier(meta.DependencyReference{ + APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "missing", + ReadyExpr: "true", + }), + err: "not found", + }, + { + name: "no dependsOn returns nil", + obj: applier(), + deps: []*unstructured.Unstructured{}, + }, + { + name: "multiple deps first fails", + obj: applier( + meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", ReadyExpr: "dep.status.phase == 'Running'"}, + meta.DependencyReference{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "cm1"}, + ), + deps: []*unstructured.Unstructured{ + newDep(cmGVK, "default", "cm1", nil), + }, + err: "not found", + }, + { + name: "multiple deps second fails first passes", + obj: applier( + meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", ReadyExpr: "dep.status.phase == 'Running'"}, + meta.DependencyReference{Kind: "Kustomization", Name: "r1"}, + ), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", map[string]any{"phase": "Running"}), + newDep(kustomizationGVK, "default", "r1", nil), + }, + err: "is not ready", + }, + { + name: "multiple deps all pass", + obj: applier( + meta.DependencyReference{APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "pod1", ReadyExpr: "dep.status.phase == 'Running'"}, + meta.DependencyReference{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "cm1"}, + ), + deps: []*unstructured.Unstructured{ + newDep(podGVK, "default", "pod1", map[string]any{"phase": "Running"}), + newDep(cmGVK, "default", "cm1", nil), + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + builder := fake.NewClientBuilder().WithScheme(testScheme) + if len(tt.deps) > 0 { + objs := make([]client.Object, len(tt.deps)) + for i, d := range tt.deps { + objs[i] = d + } + builder = builder.WithObjects(objs...) + } + err := dependency.CheckDependencies(context.Background(), builder.Build(), tt.obj, tt.opts...) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + return + } + g.Expect(err).NotTo(HaveOccurred()) + }) + } +} 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