Skip to content

Commit c915f71

Browse files
committed
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 <vincent.dely@ik.me> Assisted-by: claude-code/claude-fable-5, opencode/minimax-m3-free
1 parent 4c96090 commit c915f71

5 files changed

Lines changed: 968 additions & 12 deletions

File tree

runtime/dependency/check.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package dependency
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
24+
celtypes "github.com/google/cel-go/common/types"
25+
apierrors "k8s.io/apimachinery/pkg/api/errors"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
29+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
30+
31+
"github.com/fluxcd/pkg/apis/meta"
32+
"github.com/fluxcd/pkg/runtime/cel"
33+
"github.com/fluxcd/pkg/runtime/conditions"
34+
)
35+
36+
const (
37+
selfName = "self"
38+
depName = "dep"
39+
)
40+
41+
// CheckOption configures CheckDependencies behavior.
42+
type CheckOption func(*checkOptions)
43+
44+
type checkOptions struct {
45+
additiveCEL bool
46+
}
47+
48+
// WithAdditiveCEL configures CheckDependencies to run both the CEL
49+
// expression evaluation and the built-in kstatus readiness check.
50+
func WithAdditiveCEL() CheckOption {
51+
return func(o *checkOptions) {
52+
o.additiveCEL = true
53+
}
54+
}
55+
56+
// BuildDependencyExpressions parses the ReadyExpr of each dependency
57+
// declared by obj and returns a slice aligned to obj.GetDependsOn().
58+
// Entries without a ReadyExpr are nil. Each expression has access to
59+
// self (the parent) and dep (the dependency) as struct variables.
60+
//
61+
// An invalid expression is returned as a reconcile.TerminalError, as it
62+
// cannot recover without a change of spec. Callers should detect it with
63+
// errors.Is and mark the object as stalled instead of retrying.
64+
func BuildDependencyExpressions(obj Dependent) ([]*cel.Expression, error) {
65+
exprs := make([]*cel.Expression, len(obj.GetDependsOn()))
66+
for i, dep := range obj.GetDependsOn() {
67+
if dep.ReadyExpr == "" {
68+
continue
69+
}
70+
expr, err := cel.NewExpression(dep.ReadyExpr,
71+
cel.WithCompile(),
72+
cel.WithOutputType(celtypes.BoolType),
73+
cel.WithStructVariables(selfName, depName),
74+
)
75+
if err != nil {
76+
return nil, reconcile.TerminalError(fmt.Errorf("failed to parse expression for dependency %s: %w", dep, err))
77+
}
78+
exprs[i] = expr
79+
}
80+
return exprs, nil
81+
}
82+
83+
// CheckDependencies verifies every dependency declared by obj exists and is ready.
84+
//
85+
// A dependency with a non-empty ReadyExpr is evaluated using that CEL
86+
// expression. By default, the ReadyExpr replaces the kstatus check; use
87+
// WithAdditiveCEL to run both. A dependency with an empty ReadyExpr
88+
// falls back to the kstatus check, plus a same-kind Ready-condition
89+
// check because kstatus.Compute() tolerates missing conditions.
90+
//
91+
// An invalid ReadyExpr is returned as a reconcile.TerminalError; all
92+
// other errors are transient and callers should retry.
93+
//
94+
// The given client should preferably be an uncached reader (e.g. the
95+
// manager's APIReader), so that dependency status is observed without
96+
// cache lag and no informers are started for arbitrary dependency kinds.
97+
//
98+
// Controller-specific semantics (e.g. source-revision equality) are not
99+
// handled here; callers should check them after this returns nil.
100+
func CheckDependencies(
101+
ctx context.Context,
102+
c ctrlclient.Reader,
103+
obj *unstructured.Unstructured,
104+
opts ...CheckOption,
105+
) error {
106+
var o checkOptions
107+
for _, opt := range opts {
108+
opt(&o)
109+
}
110+
111+
deps, err := getDependsOn(obj)
112+
if err != nil {
113+
return err
114+
}
115+
116+
unstructuredObj := &unstructuredDependent{obj, deps}
117+
exprs, err := BuildDependencyExpressions(unstructuredObj)
118+
if err != nil {
119+
return err
120+
}
121+
122+
for i, dep := range deps {
123+
dep = ApplyDependencyDefaults(unstructuredObj, dep)
124+
depObj, err := FetchDependency(ctx, c, dep)
125+
if err != nil {
126+
return err
127+
}
128+
129+
if dep.ReadyExpr != "" {
130+
if err := EvaluateCEL(ctx, obj, depObj, exprs[i]); err != nil {
131+
return err
132+
}
133+
if !o.additiveCEL {
134+
continue
135+
}
136+
}
137+
138+
stat, err := status.Compute(depObj)
139+
if err != nil {
140+
return fmt.Errorf("dependency %s is not ready: %w", dep, err)
141+
}
142+
if stat.Status != status.CurrentStatus {
143+
return fmt.Errorf("dependency %s is not ready: status %s", dep, stat.Status)
144+
}
145+
146+
// kstatus.Compute() tolerates missing conditions, so verify the Ready
147+
// condition explicitly for same-Kind dependencies.
148+
if dep.APIVersion != obj.GetAPIVersion() || dep.Kind != obj.GetKind() {
149+
continue
150+
}
151+
if !conditions.IsTrue(conditions.UnstructuredGetter(depObj), meta.ReadyCondition) {
152+
return fmt.Errorf("dependency %s is not ready", dep)
153+
}
154+
}
155+
156+
return nil
157+
}
158+
159+
// getDependsOn extracts spec.dependsOn from an unstructured object.
160+
func getDependsOn(obj *unstructured.Unstructured) ([]meta.DependencyReference, error) {
161+
rawSpec, found, err := unstructured.NestedMap(obj.Object, "spec")
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to read spec: %w", err)
164+
}
165+
if !found {
166+
return nil, nil
167+
}
168+
169+
var spec struct {
170+
DependsOn []meta.DependencyReference `json:"dependsOn"`
171+
}
172+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(rawSpec, &spec); err != nil {
173+
return nil, fmt.Errorf("failed to parse spec: %w", err)
174+
}
175+
return spec.DependsOn, nil
176+
}
177+
178+
// ApplyDependencyDefaults applies defaults to dep: Kind defaults to the
179+
// parent's, then APIVersion and Namespace default to the dependent's
180+
// when Kind matches.
181+
func ApplyDependencyDefaults(obj Dependent, dep meta.DependencyReference) meta.DependencyReference {
182+
if dep.Kind == "" {
183+
dep.Kind = obj.GetKind()
184+
}
185+
if dep.Kind != obj.GetKind() {
186+
return dep
187+
}
188+
if dep.APIVersion == "" {
189+
dep.APIVersion = obj.GetAPIVersion()
190+
}
191+
if dep.Namespace == "" {
192+
dep.Namespace = obj.GetNamespace()
193+
}
194+
return dep
195+
}
196+
197+
// FetchDependency retrieves the dependency object from the cluster.
198+
func FetchDependency(
199+
ctx context.Context,
200+
c ctrlclient.Reader,
201+
dep meta.DependencyReference,
202+
) (*unstructured.Unstructured, error) {
203+
depObj := &unstructured.Unstructured{
204+
Object: map[string]any{
205+
"apiVersion": dep.APIVersion,
206+
"kind": dep.Kind,
207+
"metadata": map[string]any{
208+
"name": dep.Name,
209+
"namespace": dep.Namespace,
210+
},
211+
},
212+
}
213+
214+
if err := c.Get(ctx, ctrlclient.ObjectKeyFromObject(depObj), depObj); err != nil {
215+
if apierrors.IsNotFound(err) {
216+
return nil, fmt.Errorf("dependency %s not found: %w", dep, err)
217+
}
218+
return nil, fmt.Errorf("failed to get dependency %s: %w", dep, err)
219+
}
220+
return depObj, nil
221+
}
222+
223+
// EvaluateCEL runs the dep's ReadyExpr with the parent and dependency
224+
// objects as struct variables, and returns nil if it evaluates to true.
225+
func EvaluateCEL(
226+
ctx context.Context,
227+
obj *unstructured.Unstructured,
228+
dep *unstructured.Unstructured,
229+
expr *cel.Expression,
230+
) error {
231+
depID := fmt.Sprintf("%s/%s/%s/%s",
232+
dep.GetAPIVersion(), dep.GetKind(), dep.GetNamespace(), dep.GetName())
233+
vars := map[string]any{
234+
selfName: obj.UnstructuredContent(),
235+
depName: dep.UnstructuredContent(),
236+
}
237+
238+
ready, err := expr.EvaluateBoolean(ctx, vars)
239+
if err != nil {
240+
return fmt.Errorf("failed to evaluate dependency %s: %w", depID, err)
241+
}
242+
if !ready {
243+
return fmt.Errorf("dependency %s is not ready according to readyExpr eval", depID)
244+
}
245+
return nil
246+
}

0 commit comments

Comments
 (0)