Skip to content

Commit

Permalink
[TEP-0145] Add CEL evaluation
Browse files Browse the repository at this point in the history
This commit adds CEL evaluation. Users are able to use CEL in
WhenExpression if the feature flag enable-cel-in-whenexpression is
enabled.If the evluation is false, the PipelineTask will be skipped.

Signed-off-by: Yongxuan Zhang [email protected]
  • Loading branch information
Yongxuanzhang committed Oct 24, 2023
1 parent dcd34c1 commit cb0d8e5
Show file tree
Hide file tree
Showing 17 changed files with 740 additions and 20 deletions.
1 change: 0 additions & 1 deletion config/config-feature-flags.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,4 @@ data:
# allowing examination of the logs on the pods from cancelled taskruns
keep-pod-on-cancel: "false"
# Setting this flag to "true" will enable the CEL evaluation in WhenExpression
# This feature is in preview mode and not implemented yet. Please check #7244 for the updates.
enable-cel-in-whenexpression: "false"
5 changes: 4 additions & 1 deletion docs/pipeline-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1932,7 +1932,10 @@ ParamValue
<th>Description</th>
</tr>
</thead>
<tbody><tr><td><p>&#34;Cancelled&#34;</p></td>
<tbody><tr><td><p>&#34;CELEvaluationFailed&#34;</p></td>
<td><p>ReasonCELEvaluationFailed indicates the pipeline fails the CEL evaluation</p>
</td>
</tr><tr><td><p>&#34;Cancelled&#34;</p></td>
<td><p>PipelineRunReasonCancelled is the reason set when the PipelineRun cancelled by the user
This reason may be found with a corev1.ConditionFalse status, if the cancellation was processed successfully
This reason may be found with a corev1.ConditionUnknown status, if the cancellation is being processed or failed</p>
Expand Down
67 changes: 64 additions & 3 deletions docs/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,9 +779,70 @@ There are a lot of scenarios where `when` expressions can be really useful. Some

> :seedling: **`CEL in WhenExpression` is an [alpha](additional-configs.md#alpha-features) feature.**
> The `enable-cel-in-whenexpression` feature flag must be set to `"true"` to enable the use of `CEL` in `WhenExpression`.
>
> :warning: This feature is in a preview mode.
> It is still in a very early stage of development and is not yet fully functional

CEL (Common Expression Language) is a declarative language designed for simplicity, speed, safety, and portability which can be used to express a wide variety of conditions and computations.

You can define a CEL expression in `WhenExpression` to guard the execution of a `Task`. The CEL expression must evaluate to either `true` or `false`. You can use a single line of CEL string to replace current `WhenExpressions`'s `input`+`operator`+`values`. For example:

```yaml
# current WhenExpressions
when:
- input: "foo"
operator: "in"
values: ["foo", "bar"]
- input: "duh"
operator: "notin"
values: ["foo", "bar"]
# with cel
when:
- cel: "'foo' in ['foo', 'bar']"
- cel: "!('duh' in ['foo', 'bar'])"
```

CEL can offer more conditional functions, such as numeric comparisons (e.g. `>`, `<=`, etc), logic operators (e.g. `OR`, `AND`), Regex Pattern Matching. For example:

```yaml
when:
# test coverage result is larger than 90%
- cel: "'$(tasks.unit-test.results.test-coverage)' > 0.9"
# params is not empty, or params2 is 8.5 or 8.6
- cel: "'$(params.param1)' != '' || '$(params.param2)' == '8.5' || '$(params.param2)' == '8.6'"
# param branch matches pattern `release/.*`
- cel: "'$(params.branch)'.matches('release/.*')"
```
##### Variable substitution in CEL
`CEL` supports [string substitutions](https://github.com/tektoncd/pipeline/blob/main/docs/variables.md#variables-available-in-a-pipeline), you can reference string, array indexing or object value of a param/result. For example:

```yaml
when:
# string result
- cel: "$(tasks.unit-test.results.test-coverage) > 0.9"
# array indexing result
- cel: "$(tasks.unit-test.results.test-coverage[0]) > 0.9"
# object result key
- cel: "'$(tasks.objectTask.results.repo.url)'.matches('github.com/tektoncd/.*')"
# string param
- cel: "'$(params.foo)' == 'foo'"
# array indexing
- cel: "'$(params.branch[0])' == 'foo'"
# object param key
- cel: "'$(params.repo.url)'.matches('github.com/tektoncd/.*')"
```

**Note:** the reference needs to be wrapped with single quotes.
Whole `Array` and `Object` replacements are not supported yet. The following usage is not supported:

```yaml
when:
- cel: "'foo' in '$(params.array_params[*]']"
- cel: "'foo' in '$(params.object_params[*]']"
```

In addition to the cases listed above, you can craft any valid CEL expression as defined by the [cel-spec language definition](https://github.com/google/cel-spec/blob/master/doc/langdef.md)


`CEL` expression is validated at admission webhook and a validation error will be returned if the expression is invalid.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: guarded-pr-by-cel-
spec:
pipelineSpec:
params:
- name: path
type: string
description: The path of the file to be created
workspaces:
- name: source
description: |
This workspace is shared among all the pipeline tasks to read/write common resources
tasks:
- name: create-file # when expression using parameter, evaluates to true
when:
- cel: "'$(params.path)' == 'README.md'"
workspaces:
- name: source
workspace: source
taskSpec:
workspaces:
- name: source
description: The workspace to create the readme file in
steps:
- name: write-new-stuff
image: ubuntu
script: 'touch $(workspaces.source.path)/README.md'
- name: check-file
params:
- name: path
value: "$(params.path)"
workspaces:
- name: source
workspace: source
runAfter:
- create-file
taskSpec:
params:
- name: path
workspaces:
- name: source
description: The workspace to check for the file
results:
- name: exists
description: indicates whether the file exists or is missing
steps:
- name: check-file
image: alpine
script: |
if test -f $(workspaces.source.path)/$(params.path); then
printf yes | tee $(results.exists.path)
else
printf no | tee $(results.exists.path)
fi
- name: echo-file-exists # when expression using task result, evaluates to true
when:
- cel: "'$(tasks.check-file.results.exists)' == 'yes'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: 'echo file exists'
- name: task-should-be-skipped-1
when:
- cel: "'$(tasks.check-file.results.exists)'=='missing'" # when expression using task result, evaluates to false
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: task-should-be-skipped-2 # when expression using parameter, evaluates to false
when:
- cel: "'$(params.path)'!='README.md'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: task-should-be-skipped-3 # task with when expression and run after
runAfter:
- echo-file-exists
when:
- cel: "'monday'=='friday'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
finally:
- name: finally-task-should-be-skipped-1 # when expression using execution status, evaluates to false
when:
- cel: "'$(tasks.echo-file-exists.status)'=='Failure'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: finally-task-should-be-skipped-2 # when expression using task result, evaluates to false
when:
- cel: "'$(tasks.check-file.results.exists)'=='missing'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: finally-task-should-be-skipped-3 # when expression using parameter, evaluates to false
when:
- cel: "'$(params.path)'!='README.md'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: finally-task-should-be-skipped-4 # when expression using tasks execution status, evaluates to false
when:
- cel: "'$(tasks.status)'=='Failure'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: finally-task-should-be-skipped-5 # when expression using tasks execution status, evaluates to false
when:
- cel: "'$(tasks.status)'=='Succeeded'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: exit 1
- name: finally-task-should-be-executed # when expression using execution status, tasks execution status, param, and results
when:
- cel: "'$(tasks.echo-file-exists.status)'=='Succeeded'"
- cel: "'$(tasks.status)'=='Completed'"
- cel: "'$(tasks.check-file.results.exists)'=='yes'"
- cel: "'$(params.path)'=='README.md'"
taskSpec:
steps:
- name: echo
image: ubuntu
script: 'echo finally done'
params:
- name: path
value: README.md
workspaces:
- name: source
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 16Mi
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/pipeline/v1/pipelinerun_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ const (
PipelineRunReasonResourceVerificationFailed PipelineRunReason = "ResourceVerificationFailed"
// ReasonCreateRunFailed indicates that the pipeline fails to create the taskrun or other run resources
PipelineRunReasonCreateRunFailed PipelineRunReason = "CreateRunFailed"
// ReasonCELEvaluationFailed indicates the pipeline fails the CEL evaluation
PipelineRunReasonCELEvaluationFailed PipelineRunReason = "CELEvaluationFailed"
)

func (t PipelineRunReason) String() string {
Expand Down
11 changes: 9 additions & 2 deletions pkg/apis/pipeline/v1/when_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func (we *WhenExpression) isTrue() bool {

func (we *WhenExpression) applyReplacements(replacements map[string]string, arrayReplacements map[string][]string) WhenExpression {
replacedInput := substitution.ApplyReplacements(we.Input, replacements)
replacedCEL := substitution.ApplyReplacements(we.CEL, replacements)

var replacedValues []string
for _, val := range we.Values {
Expand All @@ -79,13 +80,14 @@ func (we *WhenExpression) applyReplacements(replacements map[string]string, arra
}
}

return WhenExpression{Input: replacedInput, Operator: we.Operator, Values: replacedValues}
return WhenExpression{Input: replacedInput, Operator: we.Operator, Values: replacedValues, CEL: replacedCEL}
}

// GetVarSubstitutionExpressions extracts all the values between "$(" and ")" in a When Expression
func (we *WhenExpression) GetVarSubstitutionExpressions() ([]string, bool) {
var allExpressions []string
allExpressions = append(allExpressions, validateString(we.Input)...)
allExpressions = append(allExpressions, validateString(we.CEL)...)
for _, value := range we.Values {
allExpressions = append(allExpressions, validateString(value)...)
}
Expand All @@ -99,8 +101,13 @@ type WhenExpressions []WhenExpression
// AllowsExecution evaluates an Input's relationship to an array of Values, based on the Operator,
// to determine whether all the When Expressions are True. If they are all True, the guarded Task is
// executed, otherwise it is skipped.
func (wes WhenExpressions) AllowsExecution() bool {
// If CEL expression exists, AllowsExecution will get the evaluated results from evaluatedCEL and determine
// if the Task should be skipped.
func (wes WhenExpressions) AllowsExecution(evaluatedCEL map[string]bool) bool {
for _, we := range wes {
if we.CEL != "" {
return evaluatedCEL[we.CEL]
}
if !we.isTrue() {
return false
}
Expand Down
21 changes: 20 additions & 1 deletion pkg/apis/pipeline/v1/when_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestAllowsExecution(t *testing.T) {
tests := []struct {
name string
whenExpressions WhenExpressions
evaluatedCEL map[string]bool
expected bool
}{{
name: "in expression",
Expand Down Expand Up @@ -77,10 +78,28 @@ func TestAllowsExecution(t *testing.T) {
},
},
expected: true,
}, {
name: "CEL is true",
whenExpressions: WhenExpressions{
{
CEL: "'foo'=='foo'",
},
},
evaluatedCEL: map[string]bool{"'foo'=='foo'": true},
expected: true,
}, {
name: "CEL is false",
whenExpressions: WhenExpressions{
{
CEL: "'foo'!='foo'",
},
},
evaluatedCEL: map[string]bool{"'foo'!='foo'": false},
expected: false,
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.whenExpressions.AllowsExecution()
got := tc.whenExpressions.AllowsExecution(tc.evaluatedCEL)
if d := cmp.Diff(tc.expected, got); d != "" {
t.Errorf("Error evaluating AllowsExecution() for When Expressions in test case %s", diff.PrintWantGot(d))
}
Expand Down
11 changes: 9 additions & 2 deletions pkg/apis/pipeline/v1beta1/when_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func (we *WhenExpression) isTrue() bool {

func (we *WhenExpression) applyReplacements(replacements map[string]string, arrayReplacements map[string][]string) WhenExpression {
replacedInput := substitution.ApplyReplacements(we.Input, replacements)
replacedCEL := substitution.ApplyReplacements(we.CEL, replacements)

var replacedValues []string
for _, val := range we.Values {
Expand All @@ -79,13 +80,14 @@ func (we *WhenExpression) applyReplacements(replacements map[string]string, arra
}
}

return WhenExpression{Input: replacedInput, Operator: we.Operator, Values: replacedValues}
return WhenExpression{Input: replacedInput, Operator: we.Operator, Values: replacedValues, CEL: replacedCEL}
}

// GetVarSubstitutionExpressions extracts all the values between "$(" and ")" in a When Expression
func (we *WhenExpression) GetVarSubstitutionExpressions() ([]string, bool) {
var allExpressions []string
allExpressions = append(allExpressions, validateString(we.Input)...)
allExpressions = append(allExpressions, validateString(we.CEL)...)
for _, value := range we.Values {
allExpressions = append(allExpressions, validateString(value)...)
}
Expand All @@ -99,8 +101,13 @@ type WhenExpressions []WhenExpression
// AllowsExecution evaluates an Input's relationship to an array of Values, based on the Operator,
// to determine whether all the When Expressions are True. If they are all True, the guarded Task is
// executed, otherwise it is skipped.
func (wes WhenExpressions) AllowsExecution() bool {
// If CEL expression exists, AllowsExecution will get the evaluated results from evaluatedCEL and determine
// if the Task should be skipped.
func (wes WhenExpressions) AllowsExecution(evaluatedCEL map[string]bool) bool {
for _, we := range wes {
if we.CEL != "" {
return evaluatedCEL[we.CEL]
}
if !we.isTrue() {
return false
}
Expand Down
Loading

0 comments on commit cb0d8e5

Please sign in to comment.