Feat: $( ) expressions in workflow step properties, resolved when each step runs

Merged
BR
7d ago
+3258-34

Description of your changes

Lets a step's properties hold $( ) expressions, evaluated on the kubevela/pkg CEL engine when the step starts rather than before the run, so a step can read what earlier steps produced.

steps:
  - name: list
    type: read-config
    properties: {name: tenants}
    outputs:
      - {name: tenants, valueFrom: output.config.tenants}
  - name: per-tenant
    type: step-group
    forEach:
      items: '$(steps.list.outputs.tenants)'
    subSteps:
      - name: save
        type: create-config
        properties:
          name: 'greeting-$(each.item.name)'
          config: {message: 'hi $(each.item.name) #$(each.index) in $(context.name)'}
  • Roots: steps.<name>.outputs.<output> reads an output that step declares; each.item and each.index read the current forEach iteration, by the names inputs[].from uses; context reads the step's context, as a CUE template does. Inside an iteration a body step's outputs are that iteration's; outside the loop they are the lists it collected.
  • When: at step start, every reconcile until the step finishes. A read whose step is still running waits (waiting for step list); a read of a step that failed fails. A skipped step's outputs read as absent, as an input reads one as null: a guarded read (has(steps.a.outputs.o) ? ... : default) takes its default, an unguarded one fails, and the implied dependsOn does not skip the reader as a written one would. forEach.items may be an expression, resolved once when the loop starts.
  • Ordering: a read of steps.X is an implied dependsOn: X, by the rules KubeVela uses for component reads: not to the reader itself, a later step in a workflow run in order, or one that closes a cycle.
  • Status: status.dependencies lists what each step waits on and whether dependsOn or an expression said so, in the shape the Application uses for components: {step, dependsOn, source}.
  • Hosts: StepGeneratorOptions.Expressions adds a host's roots with a resolver for each (KubeVela adds source). A resolver is told the run and step it answers for (expressions.RunFrom), so it can hold a value for the whole run in the workflow context. A step's own properties are resolved before a StepConvertor runs, so text a convertor adds, such as a component's properties, is left for whatever it belongs to.
  • Opt-in: the EnableCelExpressions feature gate (chart workflow.enableCelExpressions, off by default) and the annotation workflowrun.oam.dev/cel-expressions: "true". Without both, properties are left as written.
  • Admission: for a run that opts in, every expression is checked without evaluating it: it compiles; it reads only steps outputs that exist and that only one step declares; each sits in a forEach body; and no read could wait forever (the reader itself, a later step in order, a cycle).

Part of kubevela/kubevela#7414.

I have:

  • Read and followed KubeVela's contribution process.
  • Related Docs updated properly: to follow.
  • Run make reviewable to ensure this PR is ready for review. Ran gofmt, golangci-lint and staticcheck, and regenerated the CRD and chart README; not the full target.
  • Added backport release-x.y labels to auto-backport this PR if necessary.

How has this code been tested

  • Unit tests for the resolvers (waits, failures, guarded reads, forEach scoping, self reads), implied dependsOn, admission, and the run scope a host resolver gets.
  • Step-run tests: resolve, wait, fail, a skip winning over a wait, the $$( escape, and resolution before a convertor.
  • Generator and executor specs through the real engine: a sub-step reading an earlier step, a forEach whose items and body read steps and each, and a DAG ordered only by its reads.
  • A controller spec reconciling a WorkflowRun that opts in, and one that does not.
  • An e2e spec (e2e/expressions_test.go) on k3d; the e2e install turns the gate on.
  • End to end through KubeVela on k3d, including source values held for a run across a controller restart (kubevela/kubevela PR to follow).

TestSendEmail (and its legacy copy) and the "request step resolving headers from a Kubernetes Secret" e2e spec fail locally for reasons unrelated to this change: the first needs network access the sandbox denies, the second calls a mock server by cluster DNS, which a controller run on the host cannot resolve.

Special notes for your reviewer

  • The WorkflowRun CRD gains status.dependencies. KubeVela carries it into the Application's status.workflow in the companion PR.
  • Pinning a value for the run is left to the host: KubeVela's source values are typed against their schema in a way the workflow cannot store faithfully, so it keeps them itself through RunFrom.

Summary by cubic

Lets workflow step properties and forEach.items hold $( ) CEL expressions, resolved when the step starts instead of before the run, so a step can read what earlier steps produced.

Behavior

  • steps.<name>.outputs.<output> reads a declared output; each.item, each.index, and context are also available, and $( written as $\u0028 is still treated as an expression.
  • Reading steps.X implies dependsOn: X; reads wait for running steps and fail if the step failed. A skipped step's outputs read as absent, so a guarded read takes its default, and the implied dependsOn holds the reader pending without skipping it as a written one would. A forEach body's reads see that iteration's values.
  • Hosts add their own roots with per-run resolvers via StepGeneratorOptions.Expressions; a step's properties resolve before a StepConvertor runs, which receives a copy so in-place edits can't alter the step as written.
  • A run is known by its start time (stored to the second), so a host resolver is told the same run across reconciles and never the zero time; the instance is initialized before its runners are generated.
  • Restarting a step also restarts steps whose expressions read it, and status.dependencies lists what each step waits on and why.

Migration

  • Opt in per run with the EnableCelExpressions feature gate (workflow.enableCelExpressions, off by default) and the workflowrun.oam.dev/cel-expressions: "true" annotation; without both, properties are left as written.
  • Admission rejects expressions that don't compile, read missing or ambiguous outputs, use each outside a forEach body, or could wait forever, including an in-order read of an earlier step that waits on the reader. A forEach.items must be a list or one $( ) expression that resolves to one; text around an expression is refused, while whitespace around it is still treated as one. A referenced workflow is checked with its own steps, mode, and roots only, since nothing else resolves its WorkflowRun.