|
| 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