Feat: add a root-agnostic CEL engine for $( ) expressions

Merged
BR
24d ago
+5500-1

Part of kubevela/kubevela#7409

Description

Adds cel, an engine for CEL expressions written inside $( ) in a properties tree, and cel/template, which parses those fragments and walks the tree.

The engine knows no roots by name. A host declares the roots its expressions may read and passes a resolver for each one. EvalTree collects every read in the tree, calls each root's resolver once with all of its reads, and only then evaluates. Expressions never perform I/O, and every read is known before evaluation, so it can be type-checked and validated at admission.

engine, _ := cel.NewEngine(
	cel.Root{Name: "source"},
	cel.Root{Name: "context"},
)

out, err := engine.EvalTree(ctx, properties, map[string]cel.Resolver{
	"source":  cel.ResolverFunc(fetchSources), // gets every source.* read, with the property it feeds
	"context": cel.Static(renderContext),
}, cel.TreeOptions{})
properties:
  url: 'https://$(source.registry.host)/health'
  replicas: '$(source.sizing.replicas)'        # a lone expression keeps its type
  owner: '$(has(context.labels.owner) ? context.labels.owner : "unassigned")'

What is in it

EngineCEL environments per set of roots, compile and typed-environment caches, evaluation, reference extraction with has() / ternary guard analysis
PlanA tree parsed once, serving every stage: Reads(root) for dependencies and status, Expressions() for per-target typing, Check(permitted, checkers) for admission with a Checker per root (every fault at once), Eval(ctx, resolvers, opts) to resolve and evaluate; EvalTree is Plan then Eval
Resolver, ReadA resolver answers all reads of one root; each Read carries the path and the property it feeds
ErrNotReadyA resolver's "wait, not fail" signal, checked with errors.Is
Unknown, TreeOptions.UnknownA root with no value in this evaluation (a dry run); the host renders those reads
TypedMarks values a schema has already typed, so integral floats stay floats
PlanJSONPlans a JSON document once for its bytes: the same properties reach admission and every render, and a plan is read-only once built
CheckTypes, Target, TypeMismatch, MayBeAbsentAsserts each expression's result fits the schema where it lands: kind and element types, text embedding, dyn accepted, an unguarded possibly-missing read refused where the target is required. A bare read is judged by the kind its schema declares (TypeOptions.DeclaredKind), since CEL types CUE's number as double; KindFits exposes the rule
QualifierLiteral-argument member calls on a root, such as db.at("east"), answered from values the resolver delivered; refused on roots that do not declare them
DeclType, OptionalPath, ElementsCompatibleCUE schema to CEL type, "can this path be absent under this schema", element-type compatibility
template$( ) parsing with $$( as an escape, tree Walk / Map, Reference rendering

Only pure extension libraries are enabled (ext.Strings, ext.Lists). A test refuses any import of github.com/oam-dev/kubevela from this tree.

Why here

KubeVela already evaluates $( ) expressions in component, trait, policy and workflow step properties (pkg/definition/celexpr, pkg/sources). Step properties are substituted when the workflow is generated, because workflow cannot import kubevela. With the engine here, KubeVela can pass its resolvers to the workflow engine, and a step can be evaluated when it runs: fresh source values per step, and later roots native to the workflow such as step outputs.

Dependencies

github.com/google/cel-go v0.20.1 moves from indirect to direct, at the version already required. k8s.io/apiserver (for apiserver/pkg/cel) and cuelang.org/go are existing direct dependencies.

Testing

  • go test ./cel/...: engine, resolvers, guards, language behaviour, caches, typing, template parsing.
  • make reviewable passes.

Summary by cubic

Adds a root-agnostic CEL engine for $( ) expressions in a properties tree, so KubeVela can evaluate step properties when the step runs instead of when the workflow is generated. The engine knows no roots by name: a host declares the roots expressions may read and passes one Resolver per root, which answers every read before any evaluation, so expressions never perform I/O and every read is known statically.

  • Engine builds CEL environments per set of roots with caches, and extracts references with has(), negation, &&/|| guard, and ternary arm analysis.
  • Plan parses a tree once and serves every stage: Reads(root), Expressions(), Check(permitted, checkers), and Eval(ctx, resolvers, opts); EvalTree is Plan then Eval.
  • CheckTypes verifies each expression's result fits the target CUE schema and refuses unguarded possibly-missing reads where the target is required; Qualifier supports literal-argument member calls on roots that declare them.
  • ErrNotReady signals a wait; Unknown lets a host render reads of a root with no value in a dry run; TreeOptions.OnEvalError hands evaluation errors to the host.
  • cel/template parses $( ) fragments with $$( as an escape and walks or rewrites the tree.
  • github.com/google/cel-go v0.20.1 becomes a direct dependency at the already-required version.