diff --git a/config/config-feature-flags.yaml b/config/config-feature-flags.yaml index a49d26ccb78..c9d9c61a04c 100644 --- a/config/config-feature-flags.yaml +++ b/config/config-feature-flags.yaml @@ -122,7 +122,6 @@ 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" # Setting this flag to "true" will enable the use of StepActions in Steps # This feature is in preview mode and not implemented yet. Please check #7259 for updates. diff --git a/docs/pipeline-api.md b/docs/pipeline-api.md index 39d002be95c..b89b21370bc 100644 --- a/docs/pipeline-api.md +++ b/docs/pipeline-api.md @@ -1932,7 +1932,10 @@ ParamValue
"Cancelled"
"CELEvaluationFailed"
ReasonCELEvaluationFailed indicates the pipeline fails the CEL evaluation
+"Cancelled"
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
diff --git a/docs/pipelines.md b/docs/pipelines.md index 44071ab5a3a..a39ff748fa7 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -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. diff --git a/examples/v1/pipelineruns/alpha/pipelinerun-with-cel-when-expressions.yaml b/examples/v1/pipelineruns/alpha/pipelinerun-with-cel-when-expressions.yaml new file mode 100644 index 00000000000..2c91a6c3251 --- /dev/null +++ b/examples/v1/pipelineruns/alpha/pipelinerun-with-cel-when-expressions.yaml @@ -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 diff --git a/go.mod b/go.mod index 0d9cc3d4be2..822aa0bea1c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/apis/pipeline/v1/pipelinerun_types.go b/pkg/apis/pipeline/v1/pipelinerun_types.go index fda0168c09b..88aad636b89 100644 --- a/pkg/apis/pipeline/v1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1/pipelinerun_types.go @@ -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 { diff --git a/pkg/apis/pipeline/v1/when_types.go b/pkg/apis/pipeline/v1/when_types.go index 45a8bdbd98c..dfd6948ce73 100644 --- a/pkg/apis/pipeline/v1/when_types.go +++ b/pkg/apis/pipeline/v1/when_types.go @@ -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 { @@ -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)...) } @@ -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 } diff --git a/pkg/apis/pipeline/v1/when_types_test.go b/pkg/apis/pipeline/v1/when_types_test.go index 5c54b26f014..22fe919d9fa 100644 --- a/pkg/apis/pipeline/v1/when_types_test.go +++ b/pkg/apis/pipeline/v1/when_types_test.go @@ -28,6 +28,7 @@ func TestAllowsExecution(t *testing.T) { tests := []struct { name string whenExpressions WhenExpressions + evaluatedCEL map[string]bool expected bool }{{ name: "in expression", @@ -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)) } diff --git a/pkg/apis/pipeline/v1beta1/when_types.go b/pkg/apis/pipeline/v1beta1/when_types.go index 76a78ea0ea1..8cea2f5ebf1 100644 --- a/pkg/apis/pipeline/v1beta1/when_types.go +++ b/pkg/apis/pipeline/v1beta1/when_types.go @@ -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 { @@ -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)...) } @@ -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 } diff --git a/pkg/apis/pipeline/v1beta1/when_types_test.go b/pkg/apis/pipeline/v1beta1/when_types_test.go index aba8ff8d8a9..914040d48d8 100644 --- a/pkg/apis/pipeline/v1beta1/when_types_test.go +++ b/pkg/apis/pipeline/v1beta1/when_types_test.go @@ -28,6 +28,7 @@ func TestAllowsExecution(t *testing.T) { tests := []struct { name string whenExpressions WhenExpressions + evaluatedCEL map[string]bool expected bool }{{ name: "in expression", @@ -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)) } diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index 90a61b73420..ba861be168d 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -626,6 +626,16 @@ func (c *Reconciler) reconcile(ctx context.Context, pr *v1.PipelineRun, getPipel } } + // Evaluate the CEL of PipelineTask after the variable substitutions and validations. + for _, rpt := range pipelineRunFacts.State { + err := rpt.EvaluateCEL() + if err != nil { + logger.Errorf("Error evaluating CEL %s: %v", pr.Name, err) + pr.Status.MarkFailed(string(v1.PipelineRunReasonCELEvaluationFailed), err.Error()) + return controller.NewPermanentError(err) + } + } + // check if pipeline run is not gracefully cancelled and there are active task runs, which require cancelling if pr.IsGracefullyCancelled() && pipelineRunFacts.IsRunning() { // If the pipelinerun is cancelled, cancel tasks, but run finally diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index e118173020c..4c4277c3a41 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -4023,6 +4023,210 @@ status: } } +func TestReconcileWithCELWhenExpressionsWithTaskResultsAndParams(t *testing.T) { + ps := []*v1.Pipeline{parse.MustParseV1Pipeline(t, ` +metadata: + name: test-pipeline + namespace: foo +spec: + params: + - name: run + type: string + tasks: + - name: a-task + taskRef: + name: a-task + - name: b-task + taskRef: + name: b-task + when: + - cel: "'$(tasks.a-task.results.aResult)' == 'aResultValue'" + - name: c-task + taskRef: + name: c-task + when: + - cel: "'$(tasks.a-task.results.aResult)' == 'missing'" + - cel: "'$(params.run)'!='yes'" + - name: d-task + runAfter: + - c-task + taskRef: + name: d-task +`)} + prs := []*v1.PipelineRun{parse.MustParseV1PipelineRun(t, ` +metadata: + name: test-pipeline-run-different-service-accs + namespace: foo +spec: + params: + - name: run + value: "yes" + pipelineRef: + name: test-pipeline + taskRunTemplate: + serviceAccountName: test-sa-0 +`)} + ts := []*v1.Task{ + {ObjectMeta: baseObjectMeta("a-task", "foo")}, + {ObjectMeta: baseObjectMeta("b-task", "foo")}, + {ObjectMeta: baseObjectMeta("c-task", "foo")}, + {ObjectMeta: baseObjectMeta("d-task", "foo")}, + } + trs := []*v1.TaskRun{mustParseTaskRunWithObjectMeta(t, + taskRunObjectMeta("test-pipeline-run-different-service-accs-a-task-xxyyy", "foo", "test-pipeline-run-different-service-accs", + "test-pipeline", "a-task", true), + ` +spec: + serviceAccountName: test-sa + taskRef: + name: hello-world + timeout: 1h0m0s +status: + conditions: + - status: "True" + type: Succeeded + results: + - name: aResult + value: aResultValue +`)} + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "enable-cel-in-whenexpression": "true", + }, + }, + } + d := test.Data{ + PipelineRuns: prs, + Pipelines: ps, + Tasks: ts, + TaskRuns: trs, + ConfigMaps: cms, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + + wantEvents := []string{ + "Normal Started", + "Normal Running Tasks Completed: 1 \\(Failed: 0, Cancelled 0\\), Incomplete: 2, Skipped: 1", + } + pipelineRun, clients := prt.reconcileRun("foo", "test-pipeline-run-different-service-accs", wantEvents, false) + + expectedTaskRunName := "test-pipeline-run-different-service-accs-b-task" + expectedTaskRun := mustParseTaskRunWithObjectMeta(t, + taskRunObjectMeta(expectedTaskRunName, "foo", "test-pipeline-run-different-service-accs", "test-pipeline", "b-task", false), + ` +spec: + serviceAccountName: test-sa-0 + taskRef: + name: b-task + kind: Task +`) + // Check that the expected TaskRun was created + actual, err := clients.Pipeline.TektonV1().TaskRuns("foo").List(prt.TestAssets.Ctx, metav1.ListOptions{ + LabelSelector: "tekton.dev/pipelineTask=b-task,tekton.dev/pipelineRun=test-pipeline-run-different-service-accs", + Limit: 1, + }) + + if err != nil { + t.Fatalf("Failure to list TaskRun's %s", err) + } + if len(actual.Items) != 1 { + t.Fatalf("Expected 1 TaskRuns got %d", len(actual.Items)) + } + actualTaskRun := actual.Items[0] + if d := cmp.Diff(expectedTaskRun, &actualTaskRun, ignoreResourceVersion, ignoreTypeMeta); d != "" { + t.Errorf("expected to see TaskRun %v created. Diff %s", expectedTaskRunName, diff.PrintWantGot(d)) + } + + expectedWhenExpressionsInTaskRun := []v1.WhenExpression{{ + CEL: "'aResultValue' == 'aResultValue'", + }} + verifyTaskRunStatusesWhenExpressions(t, pipelineRun.Status, expectedTaskRunName, expectedWhenExpressionsInTaskRun) + + actualSkippedTasks := pipelineRun.Status.SkippedTasks + expectedSkippedTasks := []v1.SkippedTask{{ + Name: "c-task", + Reason: v1.WhenExpressionsSkip, + WhenExpressions: v1.WhenExpressions{{ + CEL: "'aResultValue' == 'missing'", + }, { + CEL: "'yes'!='yes'", + }}, + }} + if d := cmp.Diff(expectedSkippedTasks, actualSkippedTasks); d != "" { + t.Errorf("expected to find Skipped Tasks %v. Diff %s", expectedSkippedTasks, diff.PrintWantGot(d)) + } + + skippedTasks := []string{"c-task"} + for _, skippedTask := range skippedTasks { + labelSelector := fmt.Sprintf("tekton.dev/pipelineTask=%s,tekton.dev/pipelineRun=test-pipeline-run-different-service-accs", skippedTask) + actualSkippedTask, err := clients.Pipeline.TektonV1().TaskRuns("foo").List(prt.TestAssets.Ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + Limit: 1, + }) + if err != nil { + t.Fatalf("Failure to list TaskRun's %s", err) + } + if len(actualSkippedTask.Items) != 0 { + t.Fatalf("Expected 0 TaskRuns got %d", len(actualSkippedTask.Items)) + } + } +} + +func TestReconcile_InvalidCELWhenExpressions(t *testing.T) { + ps := []*v1.Pipeline{parse.MustParseV1Pipeline(t, ` +metadata: + name: test-pipeline + namespace: foo +spec: + params: + - name: run + type: string + tasks: + - name: a-task + taskRef: + name: a-task + when: + - cel: "{'blue': '0x000080', 'red': '0xFF0000'}['red']" +`)} + prs := []*v1.PipelineRun{parse.MustParseV1PipelineRun(t, ` +metadata: + name: test-pipeline-run-different-service-accs + namespace: foo +spec: + params: + - name: run + value: "yes" + pipelineRef: + name: test-pipeline + taskRunTemplate: + serviceAccountName: test-sa-0 +`)} + ts := []*v1.Task{ + {ObjectMeta: baseObjectMeta("a-task", "foo")}, + } + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "enable-cel-in-whenexpression": "true", + }, + }, + } + d := test.Data{ + PipelineRuns: prs, + Pipelines: ps, + Tasks: ts, + ConfigMaps: cms, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + pipelineRun, _ := prt.reconcileRun("foo", "test-pipeline-run-different-service-accs", []string{}, true) + checkPipelineRunConditionStatusAndReason(t, pipelineRun, corev1.ConditionFalse, string(v1.PipelineRunReasonCELEvaluationFailed)) +} + // TestReconcileWithAffinityAssistantStatefulSet tests that given a pipelineRun with workspaces, // an Affinity Assistant StatefulSet is created for each PVC workspace and // that the Affinity Assistant names is propagated to TaskRuns. diff --git a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go index 09ea5f8ccf6..3de5edd6b41 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go @@ -22,6 +22,7 @@ import ( "fmt" "sort" + "github.com/google/cel-go/cel" "github.com/tektoncd/pipeline/pkg/apis/config" "github.com/tektoncd/pipeline/pkg/apis/pipeline" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" @@ -66,6 +67,51 @@ type ResolvedPipelineTask struct { PipelineTask *v1.PipelineTask ResolvedTask *resources.ResolvedTask ResultsCache map[string][]string + // EvaluatedCEL is used to store the results of evaluated CEL expression + EvaluatedCEL map[string]bool +} + +// EvaluateCEL evaluate the CEL expressions, and store the evaluated results in EvaluatedCEL +func (t *ResolvedPipelineTask) EvaluateCEL() error { + if t.PipelineTask != nil { + if len(t.EvaluatedCEL) == 0 { + t.EvaluatedCEL = make(map[string]bool) + } + for _, we := range t.PipelineTask.When { + if we.CEL == "" { + continue + } + _, ok := t.EvaluatedCEL[we.CEL] + if !ok { + // Create a program environment configured with the standard library of CEL functions and macros + // The error is omitted because not environment declarations are passed in. + env, _ := cel.NewEnv() + // Parse and Check the CEL to get the Abstract Syntax Tree + ast, iss := env.Compile(we.CEL) + if iss.Err() != nil { + return iss.Err() + } + // Generate an evaluable instance of the Ast within the environment + prg, err := env.Program(ast) + if err != nil { + return err + } + // Evaluate the CEL expression + out, _, err := prg.Eval(map[string]interface{}{}) + if err != nil { + return err + } + + b, ok := out.Value().(bool) + if ok { + t.EvaluatedCEL[we.CEL] = b + } else { + return fmt.Errorf("The CEL expression %s is not evaluated to a boolean", we.CEL) + } + } + } + } + return nil } // isDone returns true only if the task is skipped, succeeded or failed @@ -309,7 +355,7 @@ func (t *ResolvedPipelineTask) Skip(facts *PipelineRunFacts) TaskSkipStatus { // it returns true if any of the when expressions evaluate to false func (t *ResolvedPipelineTask) skipBecauseWhenExpressionsEvaluatedToFalse(facts *PipelineRunFacts) bool { if t.checkParentsDone(facts) { - if !t.PipelineTask.When.AllowsExecution() { + if !t.PipelineTask.When.AllowsExecution(t.EvaluatedCEL) { return true } } diff --git a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go index 70415f16c8f..cc6b38e5c5e 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go @@ -4812,3 +4812,190 @@ func TestCreateResultsCacheMatrixedTaskRuns(t *testing.T) { }) } } + +func TestEvaluateCEL_valid(t *testing.T) { + for _, tc := range []struct { + name string + rpt *ResolvedPipelineTask + want map[string]bool + }{{ + name: "empty CEL", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "", + }}, + }, + }, + want: map[string]bool{}, + }, { + name: "equal", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'foo'=='foo'", + }}, + }, + }, + want: map[string]bool{ + "'foo'=='foo'": true, + }, + }, { + name: "not equal", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'bar'!='foo'", + }}, + }, + }, + want: map[string]bool{ + "'bar'!='foo'": true, + }, + }, { + name: "in", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'foo' in ['foo', 'bar']", + }}, + }, + }, + want: map[string]bool{ + "'foo' in ['foo', 'bar']": true, + }, + }, { + name: "not in", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "!('duh' in ['foo', 'bar'])", + }}, + }, + }, + want: map[string]bool{ + "!('duh' in ['foo', 'bar'])": true, + }, + }, { + name: "greater than", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'0.95'>'0.9'", + }}, + }, + }, + want: map[string]bool{ + "'0.95'>'0.9'": true, + }, + }, { + name: "less than", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'0.85'<'0.9'", + }}, + }, + }, + want: map[string]bool{ + "'0.85'<'0.9'": true, + }, + }, { + name: "or", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'foo'=='foo'||false", + }}, + }, + }, + want: map[string]bool{ + "'foo'=='foo'||false": true, + }, + }, { + name: "and", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'foo'=='foo'&&true", + }}, + }, + }, + want: map[string]bool{ + "'foo'=='foo'&&true": true, + }, + }, { + name: "regex", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'release/v1'.matches('release/.*')", + }}, + }, + }, + want: map[string]bool{ + "'release/v1'.matches('release/.*')": true, + }, + }, { + name: "multiple CEL when expressions", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "'foo'=='foo'", + }, { + CEL: "'foo'!='foo'", + }, { + CEL: "'foo'!='bar'", + }}, + }, + }, + want: map[string]bool{ + "'foo'!='bar'": true, + "'foo'!='foo'": false, + "'foo'=='foo'": true, + }, + }} { + t.Run(tc.name, func(t *testing.T) { + err := tc.rpt.EvaluateCEL() + if err != nil { + t.Fatalf("Got unexpected err:%v", err) + } + if !cmp.Equal(tc.want, tc.rpt.EvaluatedCEL) { + t.Errorf("Did not get the expected EvaluatedCEL want %v, got: %v", tc.want, tc.rpt.EvaluatedCEL) + } + }) + } +} + +func TestEvaluateCEL_invalid(t *testing.T) { + for _, tc := range []struct { + name string + rpt *ResolvedPipelineTask + }{{ + name: "compile error - token unrecogniezd", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "$(params.foo)=='foo'", + }}, + }, + }, + }, { + name: "CEL result is not true or false", + rpt: &ResolvedPipelineTask{ + PipelineTask: &v1.PipelineTask{ + When: v1.WhenExpressions{{ + CEL: "{'blue': '0x000080', 'red': '0xFF0000'}['red']", + }}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.rpt.EvaluateCEL() + if err == nil { + t.Fatalf("Expected err but got nil") + } + }) + } +} diff --git a/test/cel_in_whenexpression_test.go b/test/cel_in_whenexpression_test.go new file mode 100644 index 00000000000..f2db9f1ec4d --- /dev/null +++ b/test/cel_in_whenexpression_test.go @@ -0,0 +1,220 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "context" + "fmt" + "testing" + + "github.com/tektoncd/pipeline/test/parse" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" + knativetest "knative.dev/pkg/test" + "knative.dev/pkg/test/helpers" +) + + +func TestCELinWhenExpression(t *testing.T) { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + c, namespace := setup(ctx, t, requireAlphaFeatureFlags) + knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf) + defer tearDown(ctx, t, c, namespace) + + pr := parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +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 +`, helpers.ObjectNameForTest(t), namespace)) + + t.Logf("Creating PipelineRun %s", pr.Name) + if _, err := c.V1PipelineRunClient.Create(ctx, pr, metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create PipelineRun `%s`: %s", pr.Name, err) + } + + t.Logf("Waiting for PipelineRun in namespace %s to succeed", namespace) + if err := WaitForPipelineRunState(ctx, c, pr.Name, timeout, PipelineRunSucceed(pr.Name), "PipelineRunSucceed", v1Version); err != nil { + t.Errorf("Error waiting for PipelineRun to finish: %s", err) + } + + pr, err := c.V1PipelineRunClient.Get(ctx, pr.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Couldn't get expected PipelineRun %s: %s", pr.Name, err) + } + + if pr.Status.GetCondition(apis.ConditionSucceeded).IsFalse() { + t.Errorf("Expected PipelineRun to succeed but instead found condition: %s", pr.Status.GetCondition(apis.ConditionSucceeded)) + } + + actualSkippedTasks := pr.Status.SkippedTasks + fmt.Println("actualSkippedTasks",actualSkippedTasks) +} diff --git a/test/custom_task_test.go b/test/custom_task_test.go index f3b7e93b9b4..ba972b6abaf 100644 --- a/test/custom_task_test.go +++ b/test/custom_task_test.go @@ -729,10 +729,11 @@ func resetConfigMap(ctx context.Context, t *testing.T, c *clients, namespace, co func getFeatureFlagsBaseOnAPIFlag(t *testing.T) *config.FeatureFlags { t.Helper() alphaFeatureFlags, err := config.NewFeatureFlagsFromMap(map[string]string{ - "enable-api-fields": "alpha", - "results-from": "sidecar-logs", - "enable-tekton-oci-bundles": "true", - "enable-step-actions": "true", + "enable-api-fields": "alpha", + "results-from": "sidecar-logs", + "enable-tekton-oci-bundles": "true", + "enable-step-actions": "true", + "enable-cel-in-whenexpression": "true", }) if err != nil { t.Fatalf("error creating alpha feature flags configmap: %v", err) diff --git a/test/e2e-tests-kind-prow-alpha.env b/test/e2e-tests-kind-prow-alpha.env index ee5f4c964bc..3b5bc6a771b 100644 --- a/test/e2e-tests-kind-prow-alpha.env +++ b/test/e2e-tests-kind-prow-alpha.env @@ -5,3 +5,4 @@ KO_DOCKER_REPO=registry.local:5000 E2E_GO_TEST_TIMEOUT=40m RESULTS_FROM=sidecar-logs ENABLE_STEP_ACTIONS=true +ENABLE_CEL_IN_WHENEXPRESSION=true diff --git a/test/e2e-tests.sh b/test/e2e-tests.sh index 298741b9eca..5d7923df00b 100755 --- a/test/e2e-tests.sh +++ b/test/e2e-tests.sh @@ -28,6 +28,7 @@ SKIP_GO_E2E_TESTS=${SKIP_GO_E2E_TESTS:="false"} E2E_GO_TEST_TIMEOUT=${E2E_GO_TEST_TIMEOUT:="20m"} RESULTS_FROM=${RESULTS_FROM:-termination-message} ENABLE_STEP_ACTIONS=${ENABLE_STEP_ACTIONS:="false"} +ENABLE_CEL_IN_WHENEXPRESSION=${ENABLE_CEL_IN_WHENEXPRESSION:="false"} failed=0 # Script entry point. @@ -90,6 +91,17 @@ function set_enable_step_actions() { kubectl patch configmap feature-flags -n tekton-pipelines -p "$jsonpatch" } +function set_cel_in_whenexpression() { + local method="$1" + if [ "$method" != "false" ] && [ "$method" != "true" ]; then + printf "Invalid value for enable-step-actions %s\n" ${method} + exit 255 + fi + jsonpatch=$(printf "{\"data\": {\"enable-cel-in-whenexpression\": \"%s\"}}" $1) + echo "feature-flags ConfigMap patch: ${jsonpatch}" + kubectl patch configmap feature-flags -n tekton-pipelines -p "$jsonpatch" +} + function run_e2e() { # Run the integration tests header "Running Go e2e tests" @@ -110,6 +122,7 @@ add_spire "$PIPELINE_FEATURE_GATE" set_feature_gate "$PIPELINE_FEATURE_GATE" set_result_extraction_method "$RESULTS_FROM" set_enable_step_actions "$ENABLE_STEP_ACTIONS" +set_cel_in_whenexpression "$ENABLE_CEL_IN_WHENEXPRESSION" run_e2e (( failed )) && fail_test