Skip to content

Commit 337759d

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: opencode/minimax-m3-free, opencode/mimo-v2.5-free
1 parent 4712b5a commit 337759d

5 files changed

Lines changed: 920 additions & 12 deletions

File tree

runtime/dependency/check.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
celtypes "github.com/google/cel-go/common/types"
24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
25+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
28+
29+
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
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+
func BuildDependencyExpressions(obj Dependent) ([]*cel.Expression, error) {
61+
exprs := make([]*cel.Expression, len(obj.GetDependsOn()))
62+
for i, dep := range obj.GetDependsOn() {
63+
if dep.ReadyExpr == "" {
64+
continue
65+
}
66+
expr, err := cel.NewExpression(dep.ReadyExpr,
67+
cel.WithCompile(),
68+
cel.WithOutputType(celtypes.BoolType),
69+
cel.WithStructVariables(selfName, depName),
70+
)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to parse expression for dependency %s: %w", dep, err)
73+
}
74+
exprs[i] = expr
75+
}
76+
return exprs, nil
77+
}
78+
79+
// CheckDependencies verifies every dependency declared by obj exists and is ready.
80+
//
81+
// A dependency with a non-empty ReadyExpr is evaluated using that CEL
82+
// expression. By default, the ReadyExpr replaces the kstatus check; use
83+
// WithAdditiveCEL to run both. A dependency with an empty ReadyExpr
84+
// falls back to the kstatus check, plus a same-kind Ready-condition
85+
// check because kstatus.Compute() tolerates missing conditions.
86+
//
87+
// Controller-specific semantics (e.g. source-revision equality) are not
88+
// handled here; callers should check them after this returns nil.
89+
func CheckDependencies(
90+
ctx context.Context,
91+
c ctrlclient.Reader,
92+
obj *unstructured.Unstructured,
93+
opts ...CheckOption,
94+
) error {
95+
var o checkOptions
96+
for _, opt := range opts {
97+
opt(&o)
98+
}
99+
100+
deps, err := getDependsOn(obj)
101+
if err != nil {
102+
return err
103+
}
104+
105+
unstructuredObj := &unstructuredDependent{obj, deps}
106+
exprs, err := BuildDependencyExpressions(unstructuredObj)
107+
if err != nil {
108+
return err
109+
}
110+
111+
for i, dep := range deps {
112+
dep = ApplyDependencyDefaults(unstructuredObj, dep)
113+
depObj, err := FetchDependency(ctx, c, dep)
114+
if err != nil {
115+
return err
116+
}
117+
118+
if dep.ReadyExpr != "" {
119+
if err := EvaluateCEL(ctx, obj, depObj, exprs[i]); err != nil {
120+
return err
121+
}
122+
if !o.additiveCEL {
123+
continue
124+
}
125+
}
126+
127+
stat, err := status.Compute(depObj)
128+
if err != nil {
129+
return fmt.Errorf("dependency %s is not ready: %w", dep, err)
130+
}
131+
if stat.Status != status.CurrentStatus {
132+
return fmt.Errorf("dependency %s is not ready: status %s", dep, stat.Status)
133+
}
134+
135+
// kstatus.Compute() tolerates missing conditions, so verify the Ready
136+
// condition explicitly for same-Kind dependencies.
137+
if dep.Kind != obj.GetKind() {
138+
continue
139+
}
140+
if !conditions.IsTrue(conditions.UnstructuredGetter(depObj), meta.ReadyCondition) {
141+
return fmt.Errorf("dependency %s is not ready", dep)
142+
}
143+
}
144+
145+
return nil
146+
}
147+
148+
// getDependsOn extracts spec.dependsOn from an unstructured object.
149+
func getDependsOn(obj *unstructured.Unstructured) ([]meta.DependencyReference, error) {
150+
rawSpec, found, err := unstructured.NestedMap(obj.Object, "spec")
151+
if err != nil {
152+
return nil, fmt.Errorf("failed to read spec: %w", err)
153+
}
154+
if !found {
155+
return nil, nil
156+
}
157+
158+
var spec struct {
159+
DependsOn []meta.DependencyReference `json:"dependsOn"`
160+
}
161+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(rawSpec, &spec); err != nil {
162+
return nil, fmt.Errorf("failed to parse spec: %w", err)
163+
}
164+
return spec.DependsOn, nil
165+
}
166+
167+
// ApplyDependencyDefaults applies defaults to dep: Kind defaults to the
168+
// parent's, then APIVersion and Namespace default to the dependent's
169+
// when Kind matches.
170+
func ApplyDependencyDefaults(obj Dependent, dep meta.DependencyReference) meta.DependencyReference {
171+
if dep.Kind == "" {
172+
dep.Kind = obj.GetKind()
173+
}
174+
if dep.Kind != obj.GetKind() {
175+
return dep
176+
}
177+
if dep.APIVersion == "" {
178+
dep.APIVersion = obj.GetAPIVersion()
179+
}
180+
if dep.Namespace == "" {
181+
dep.Namespace = obj.GetNamespace()
182+
}
183+
return dep
184+
}
185+
186+
// FetchDependency retrieves the dependency object from the cluster.
187+
func FetchDependency(
188+
ctx context.Context,
189+
c ctrlclient.Reader,
190+
dep meta.DependencyReference,
191+
) (*unstructured.Unstructured, error) {
192+
depObj := &unstructured.Unstructured{
193+
Object: map[string]any{
194+
"apiVersion": dep.APIVersion,
195+
"kind": dep.Kind,
196+
"metadata": map[string]any{
197+
"name": dep.Name,
198+
"namespace": dep.Namespace,
199+
},
200+
},
201+
}
202+
203+
if err := c.Get(ctx, ctrlclient.ObjectKeyFromObject(depObj), depObj); err != nil {
204+
if apierrors.IsNotFound(err) {
205+
return nil, fmt.Errorf("dependency %s not found: %w", dep, err)
206+
}
207+
return nil, fmt.Errorf("failed to get dependency %s: %w", dep, err)
208+
}
209+
return depObj, nil
210+
}
211+
212+
// EvaluateCEL runs the dep's ReadyExpr with the parent and dependency
213+
// objects as struct variables, and returns nil if it evaluates to true.
214+
func EvaluateCEL(
215+
ctx context.Context,
216+
obj *unstructured.Unstructured,
217+
dep *unstructured.Unstructured,
218+
expr *cel.Expression,
219+
) error {
220+
depID := fmt.Sprintf("%s/%s/%s/%s",
221+
dep.GetAPIVersion(), dep.GetKind(), dep.GetNamespace(), dep.GetName())
222+
vars := map[string]any{
223+
selfName: obj.UnstructuredContent(),
224+
depName: dep.UnstructuredContent(),
225+
}
226+
227+
ready, err := expr.EvaluateBoolean(ctx, vars)
228+
if err != nil {
229+
return fmt.Errorf("failed to evaluate dependency %s: %w", depID, err)
230+
}
231+
if !ready {
232+
return fmt.Errorf("dependency %s is not ready according to readyExpr eval", depID)
233+
}
234+
return nil
235+
}

0 commit comments

Comments
 (0)