From 3f44dbd58b82161243b9929bc1563606a22b5615 Mon Sep 17 00:00:00 2001 From: Jerop Date: Tue, 25 Aug 2020 17:03:41 -0400 Subject: [PATCH] Implement When Expressions (Conditions Beta) Adding `WhenExpressions` used to efficiently specify guarded execution of `Tasks`, without spinning up new pods. We use `WhenExpressions` to avoid adding an opinionated and complex expression language to the Tekton API to ensure Tekton can be supported by as many systems as possible. Further details about the design are in [Conditions Beta TEP](https://github.com/tektoncd/community/blob/master/teps/0007-conditions-beta.md). The components of `WhenExpressions` are `Input`, `Operator` and `Values`: - `Input` is the input for the `Guard` checking which can be static inputs or variables, such as `Parameters` or `Results`. - `Operator` represents an `Input`'s relationship to a set of `Values`. `Operators` we will use in `WhenExpressions` are `In` and `NotIn`. - `Values` is an array of string values. The `Values` array must be non-empty. It can contain static values or variables (`Parameters` or `Results`). The declared `WhenExpressions` are evaluated before the `Task` is run. If all the `WhenExpressions` evaluate to `True`, the `Task` is run. If any of the `WhenExpressions` evaluate to `False`, the `Task` is skipped. Some key parts of this PR are: - `Expressions` package, which can be reused beyond `WhenExpressions` - `WhenExpressions` type in the `PipelineTask` - Variable substitution for `Results` and `Parameters` used in `WhenExpressions`, either in `Input` or in `Values` -- can be extended to use other variables. - Resource dependency mapping when resources (e.g. `Results`) are used from other `Tasks` - Validation of `WhenExpressions`, variable substitution, among others - Evaluation of `WhenExpressions` and updates to `Task` execution based on the evaluation results - `WhenExpressionsStatus` that exposes the evaluation inputs and results - A practical example of using `WhenExpression`, tests and documentation --- docs/pipelines.md | 53 +++ .../pipelinerun-with-when-expressions.yaml | 239 ++++++++++++ internal/builder/v1beta1/pipeline.go | 15 + internal/builder/v1beta1/pipeline_test.go | 11 +- pkg/apis/pipeline/v1beta1/pipeline_types.go | 14 + .../pipeline/v1beta1/pipeline_validation.go | 64 +++- .../v1beta1/pipeline_validation_test.go | 249 +++++++++++++ .../pipeline/v1beta1/pipelinerun_types.go | 3 + pkg/apis/pipeline/v1beta1/resultref_test.go | 341 +++++++++++++++++ pkg/apis/pipeline/v1beta1/when_types.go | 139 +++++++ pkg/apis/pipeline/v1beta1/when_types_test.go | 323 +++++++++++++++++ pkg/apis/pipeline/v1beta1/when_validation.go | 110 ++++++ .../pipeline/v1beta1/when_validation_test.go | 107 ++++++ .../pipeline/v1beta1/zz_generated.deepcopy.go | 103 ++++++ pkg/reconciler/pipelinerun/pipelinerun.go | 18 +- .../pipelinerun/pipelinerun_test.go | 67 ++++ pkg/reconciler/pipelinerun/resources/apply.go | 4 +- .../pipelinerun/resources/apply_test.go | 118 +++++- .../resources/pipelinerunresolution.go | 27 +- .../resources/pipelinerunresolution_test.go | 119 +++++- .../resources/resultrefresolution.go | 43 ++- .../resources/resultrefresolution_test.go | 343 +++++++++++++++++- 22 files changed, 2473 insertions(+), 37 deletions(-) create mode 100644 examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml create mode 100644 pkg/apis/pipeline/v1beta1/when_types.go create mode 100644 pkg/apis/pipeline/v1beta1/when_types_test.go create mode 100644 pkg/apis/pipeline/v1beta1/when_validation.go create mode 100644 pkg/apis/pipeline/v1beta1/when_validation_test.go diff --git a/docs/pipelines.md b/docs/pipelines.md index d4b32bce308..72d9eca664a 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -15,6 +15,7 @@ weight: 3 - [Using the `from` parameter](#using-the-from-parameter) - [Using the `runAfter` parameter](#using-the-runafter-parameter) - [Using the `retries` parameter](#using-the-retries-parameter) + - [Guard `Task` execution using `When Expressions`](#guard-task-execution-using-whenexpressions) - [Guard `Task` execution using `Conditions`](#guard-task-execution-using-conditions) - [Configuring the failure timeout](#configuring-the-failure-timeout) - [Using `Results`](#using-results) @@ -316,8 +317,60 @@ tasks: name: build-push ``` +### Guard `Task` execution using `WhenExpressions` + +To run a `Task` only when certain conditions are met, it is possible to _guard_ task execution using +the `when` field. The `when` field allows you to list a series of references to `WhenExpressions`. + +There are a lot of scenarios where `WhenExpressions` can be really useful. Some of these are: +- Checking if the name of a git branch matches +- Checking if the `Result` of a previous `Task` is as expected +- Checking if a git file has changed in the previous commits +- Checking if an image exists in the registry +- Checking if the name of a CI job matches + +The components of `WhenExpressions` are `Input`, `Operator` and `Values`: + +- `Input` is the input for the `WhenExpression` which can be static inputs or variables (`Parameters` or `Results`). +- `Operator` represents an `Input`'s relationship to a set of `Values`. `Operators` we will use in `WhenExpressions` are `in` and `notin`. +- `Values` is an array of string values. The `Values` array must be non-empty. It can contain static values or variables (`Parameters` or `Results`. + +The declared `WhenExpressions` are evaluated before the `Task` is run. If all the `WhenExpressions` +evaluate to `True`, the `Task` is run. If any of the `WhenExpressions` evaluate to `False`, the `Task` is +not run and the `TaskRun` status field `ConditionSucceeded` is set to `False` with the reason set to +`WhenExpressionsEvaluatedToFalse`. + +In these examples, `create-readme-file` task will only be executed if the `path` parameter is `README.md` and `echo-file-exists` task will only be executed if the `status` result from `check-file` task is `exists`. + +```yaml +tasks: + - name: first-create-file + when: + - input: "$(params.path)" + operator: in + values: ["README.md"] + taskRef: + name: create-readme-file +--- +tasks: + - name: echo-file-exists + when: + - input: "$(tasks.check-file.results.status)" + operator: in + values: ["exists"] + taskRef: + name: echo-file-exists +``` + +For an end-to-end example, see [PipelineRun with WhenExpressions](../examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml). + +When `WhenExpressions` are specified in a `Task`, [`Conditions`](#guard-task-execution-using-conditions) should not be speficied in the same `Task`. +The `Pipeline` will be rejected as invalid if both `WhenExpressions` and `Conditions` are included. + ### Guard `Task` execution using `Conditions` +**Note:** `Conditions` are deprecated, use [`WhenExpressions`](#guard-task-execution-using-whenexpressions) instead. + To run a `Task` only when certain conditions are met, it is possible to _guard_ task execution using the `conditions` field. The `conditions` field allows you to list a series of references to [`Condition`](./conditions.md) resources. The declared `Conditions` are run before the `Task` is run. diff --git a/examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml b/examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml new file mode 100644 index 00000000000..8b3f6a78a52 --- /dev/null +++ b/examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml @@ -0,0 +1,239 @@ +# Copied from https://github.com/tektoncd/catalog/blob/v1beta1/git/git-clone.yaml :( +# This can be deleted after we add support to refer to the remote Task in a registry (Issue #1839) or +# add support for referencing task in git directly (issue #2298) +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: git-clone-from-catalog +spec: + workspaces: + - name: output + description: The git repo will be cloned onto the volume backing this workspace + params: + - name: url + description: git url to clone + type: string + - name: revision + description: git revision to checkout (branch, tag, sha, ref…) + type: string + default: master + - name: refspec + description: (optional) git refspec to fetch before checking out revision + default: "" + - name: submodules + description: defines if the resource should initialize and fetch the submodules + type: string + default: "true" + - name: depth + description: performs a shallow clone where only the most recent commit(s) will be fetched + type: string + default: "1" + - name: sslVerify + description: defines if http.sslVerify should be set to true or false in the global git config + type: string + default: "true" + - name: subdirectory + description: subdirectory inside the "output" workspace to clone the git repo into + type: string + default: "" + - name: deleteExisting + description: clean out the contents of the repo's destination directory (if it already exists) before trying to clone the repo there + type: string + default: "false" + - name: httpProxy + description: git HTTP proxy server for non-SSL requests + type: string + default: "" + - name: httpsProxy + description: git HTTPS proxy server for SSL requests + type: string + default: "" + - name: noProxy + description: git no proxy - opt out of proxying HTTP/HTTPS requests + type: string + default: "" + results: + - name: commit + description: The precise commit SHA that was fetched by this Task + steps: + - name: clone + image: gcr.io/tekton-releases/github.com/tektoncd/pipeline/cmd/git-init:v0.12.1 + script: | + CHECKOUT_DIR="$(workspaces.output.path)/$(params.subdirectory)" + + cleandir() { + # Delete any existing contents of the repo directory if it exists. + # + # We don't just "rm -rf $CHECKOUT_DIR" because $CHECKOUT_DIR might be "/" + # or the root of a mounted volume. + if [[ -d "$CHECKOUT_DIR" ]] ; then + # Delete non-hidden files and directories + rm -rf "$CHECKOUT_DIR"/* + # Delete files and directories starting with . but excluding .. + rm -rf "$CHECKOUT_DIR"/.[!.]* + # Delete files and directories starting with .. plus any other character + rm -rf "$CHECKOUT_DIR"/..?* + fi + } + + if [[ "$(params.deleteExisting)" == "true" ]] ; then + cleandir + fi + + test -z "$(params.httpProxy)" || export HTTP_PROXY=$(params.httpProxy) + test -z "$(params.httpsProxy)" || export HTTPS_PROXY=$(params.httpsProxy) + test -z "$(params.noProxy)" || export NO_PROXY=$(params.noProxy) + + /ko-app/git-init \ + -url "$(params.url)" \ + -revision "$(params.revision)" \ + -refspec "$(params.refspec)" \ + -path "$CHECKOUT_DIR" \ + -sslVerify="$(params.sslVerify)" \ + -submodules="$(params.submodules)" \ + -depth "$(params.depth)" + cd "$CHECKOUT_DIR" + RESULT_SHA="$(git rev-parse HEAD | tr -d '\n')" + EXIT_CODE="$?" + if [ "$EXIT_CODE" != 0 ] + then + exit $EXIT_CODE + fi + # Make sure we don't add a trailing newline to the result! + echo -n "$RESULT_SHA" > $(results.commit.path) +--- +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: check-file +spec: + params: + - name: path + workspaces: + - name: source + description: The workspace to check for the file. + results: + - name: status + description: indicating whether the file exists + steps: + - name: check-file + image: alpine + script: | + if test -f $(workspaces.source.path)/$(params.path); then + printf exists | tee /tekton/results/status + else + printf missing | tee /tekton/results/status + fi +--- +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: guarded-pipeline +spec: + params: + - name: path + type: string + description: The path of the file to be created. + default: "README.md" + - name: repo-url + type: string + description: The git repository URL to clone from. + - name: branch-name + type: string + description: The git branch to clone. + workspaces: + - name: source-repo + description: | + This workspace will receive the cloned git repo and be passed + to the next Task to create a file. + tasks: + - name: fetch-repo + taskRef: + name: git-clone-from-catalog + params: + - name: url + value: $(params.repo-url) + - name: revision + value: $(params.branch-name) + workspaces: + - name: output + workspace: source-repo + - name: create-file + when: + - input: "$(params.path)" + operator: in + values: ["README.md"] + workspaces: + - name: source + workspace: source-repo + runAfter: + - fetch-repo + 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 + when: + - input: "foo" + operator: in + values: ["foo", "bar"] + params: + - name: path + value: "$(params.path)" + workspaces: + - name: source + workspace: source-repo + taskRef: + name: check-file + runAfter: + - create-file + - name: echo-file-exists + when: + - input: "$(tasks.check-file.results.status)" + operator: in + values: ["exists"] + taskSpec: + steps: + - name: echo + image: ubuntu + script: 'echo file exists' + - name: task-should-be-skipped + when: + - input: "foo" + operator: notin + values: ["foo"] + - input: "foo" + operator: in + values: ["bar"] + taskSpec: + steps: + - name: echo + image: ubuntu + script: exit 1 +--- +apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: guarded-pr +spec: + serviceAccountName: 'default' + pipelineRef: + name: guarded-pipeline + params: + - name: repo-url + value: https://github.com/tektoncd/pipeline.git + - name: branch-name + value: master + workspaces: + - name: source-repo + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/internal/builder/v1beta1/pipeline.go b/internal/builder/v1beta1/pipeline.go index 68a764a120c..93af5973d66 100644 --- a/internal/builder/v1beta1/pipeline.go +++ b/internal/builder/v1beta1/pipeline.go @@ -24,6 +24,7 @@ import ( resource "github.com/tektoncd/pipeline/pkg/apis/resource/v1alpha1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "knative.dev/pkg/apis" ) @@ -54,6 +55,9 @@ type PipelineRunStatusOp func(*v1beta1.PipelineRunStatus) // PipelineTaskConditionOp is an operation which modifies a PipelineTaskCondition type PipelineTaskConditionOp func(condition *v1beta1.PipelineTaskCondition) +// PipelineTaskWhenExpressionOp is an operation which modifies a WhenExpression. +type PipelineTaskWhenExpressionOp func(*v1beta1.WhenExpression) + // Pipeline creates a Pipeline with default values. // Any number of Pipeline modifier can be passed to transform it. func Pipeline(name string, ops ...PipelineOp) *v1beta1.Pipeline { @@ -332,6 +336,17 @@ func PipelineTaskConditionResource(name, resource string, from ...string) Pipeli } } +// PipelineTaskWhenExpression adds a WhenExpression with the specified input, operator and values. +func PipelineTaskWhenExpression(input string, operator selection.Operator, values []string) PipelineTaskOp { + return func(pt *v1beta1.PipelineTask) { + pt.WhenExpressions = append(pt.WhenExpressions, v1beta1.WhenExpression{ + Input: input, + Operator: operator, + Values: values, + }) + } +} + // PipelineTaskWorkspaceBinding adds a workspace with the specified name, workspace and subpath on a PipelineTask. func PipelineTaskWorkspaceBinding(name, workspace, subPath string) PipelineTaskOp { return func(pt *v1beta1.PipelineTask) { diff --git a/internal/builder/v1beta1/pipeline_test.go b/internal/builder/v1beta1/pipeline_test.go index cc40e365b5b..3647ec36ad6 100644 --- a/internal/builder/v1beta1/pipeline_test.go +++ b/internal/builder/v1beta1/pipeline_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-cmp/cmp" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "knative.dev/pkg/apis" duckv1beta1 "knative.dev/pkg/apis/duck/v1beta1" @@ -54,6 +55,7 @@ func TestPipeline(t *testing.T) { tb.PipelineTaskOutputResource("some-image", "my-only-image-resource"), ), tb.PipelineTask("never-gonna", "give-you-up", + tb.PipelineTaskWhenExpression("foo", selection.In, []string{"foo", "bar"}), tb.RunAfter("foo"), tb.PipelineTaskTimeout(5*time.Second), ), @@ -133,10 +135,11 @@ func TestPipeline(t *testing.T) { }}, }, }, { - Name: "never-gonna", - TaskRef: &v1beta1.TaskRef{Name: "give-you-up"}, - RunAfter: []string{"foo"}, - Timeout: &metav1.Duration{Duration: 5 * time.Second}, + Name: "never-gonna", + TaskRef: &v1beta1.TaskRef{Name: "give-you-up"}, + WhenExpressions: []v1beta1.WhenExpression{{Input: "foo", Operator: selection.In, Values: []string{"foo", "bar"}}}, + RunAfter: []string{"foo"}, + Timeout: &metav1.Duration{Duration: 5 * time.Second}, }, { Name: "foo", TaskSpec: &v1beta1.EmbeddedTask{ diff --git a/pkg/apis/pipeline/v1beta1/pipeline_types.go b/pkg/apis/pipeline/v1beta1/pipeline_types.go index 002e8b45896..0013fd7e583 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_types.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_types.go @@ -127,6 +127,10 @@ type PipelineTask struct { // +optional Conditions []PipelineTaskCondition `json:"conditions,omitempty"` + // WhenExpressions is a list of when expressions that need to be true for the task to run + // +optional + WhenExpressions WhenExpressions `json:"when,omitempty"` + // Retries represents how many times this task should be retried in case of task failure: ConditionSucceeded set to False // +optional Retries int `json:"retries,omitempty"` @@ -197,6 +201,16 @@ func (pt PipelineTask) Deps() []string { } } } + // Add any dependents from when expressions + for _, whenExpression := range pt.WhenExpressions { + expressions, ok := whenExpression.GetVarSubstitutionExpressionsForWhenExpression() + if ok { + resultRefs := NewResultRefs(expressions) + for _, resultRef := range resultRefs { + deps = append(deps, resultRef.PipelineTask) + } + } + } return deps } diff --git a/pkg/apis/pipeline/v1beta1/pipeline_validation.go b/pkg/apis/pipeline/v1beta1/pipeline_validation.go index a82544ec1cc..62391082aa9 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_validation.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_validation.go @@ -212,6 +212,10 @@ func (ps *PipelineSpec) Validate(ctx context.Context) *apis.FieldError { return err } + if err := validateWhenExpressions(ps.Tasks); err != nil { + return err + } + return nil } @@ -356,30 +360,47 @@ func validatePipelineParameterVariables(tasks []PipelineTask, params []ParamSpec } } - return validatePipelineVariables(tasks, "params", parameterNames, arrayParameterNames) + return validatePipelineParametersVariables(tasks, "params", parameterNames, arrayParameterNames) } -func validatePipelineVariables(tasks []PipelineTask, prefix string, paramNames sets.String, arrayParamNames sets.String) *apis.FieldError { +func validatePipelineParametersVariables(tasks []PipelineTask, prefix string, paramNames sets.String, arrayParamNames sets.String) *apis.FieldError { for _, task := range tasks { for _, param := range task.Params { if param.Value.Type == ParamTypeString { - if err := validatePipelineVariable(fmt.Sprintf("param[%s]", param.Name), param.Value.StringVal, prefix, paramNames); err != nil { - return err - } - if err := validatePipelineNoArrayReferenced(fmt.Sprintf("param[%s]", param.Name), param.Value.StringVal, prefix, arrayParamNames); err != nil { + if err := validatePipelineStringVariable(fmt.Sprintf("param[%s]", param.Name), param.Value.StringVal, prefix, paramNames, arrayParamNames); err != nil { return err } } else { for _, arrayElement := range param.Value.ArrayVal { - if err := validatePipelineVariable(fmt.Sprintf("param[%s]", param.Name), arrayElement, prefix, paramNames); err != nil { - return err - } - if err := validatePipelineArraysIsolated(fmt.Sprintf("param[%s]", param.Name), arrayElement, prefix, arrayParamNames); err != nil { + if err := validatePipelineArrayVariable(fmt.Sprintf("param[%s]", param.Name), arrayElement, prefix, paramNames, arrayParamNames); err != nil { return err } } } } + if err := task.WhenExpressions.validateParametersVariables(prefix, paramNames, arrayParamNames); err != nil { + return err + } + } + return nil +} + +func validatePipelineStringVariable(name, value, prefix string, stringVars sets.String, arrayVars sets.String) *apis.FieldError { + if err := validatePipelineVariable(name, value, prefix, stringVars); err != nil { + return err + } + if err := validatePipelineNoArrayReferenced(name, value, prefix, arrayVars); err != nil { + return err + } + return nil +} + +func validatePipelineArrayVariable(name, value, prefix string, stringVars sets.String, arrayVars sets.String) *apis.FieldError { + if err := validatePipelineVariable(name, value, prefix, stringVars); err != nil { + return err + } + if err := validatePipelineArraysIsolated(name, value, prefix, arrayVars); err != nil { + return err } return nil } @@ -488,6 +509,9 @@ func validateFinalTasks(finalTasks []PipelineTask) *apis.FieldError { if len(f.Conditions) != 0 { return apis.ErrInvalidValue(fmt.Sprintf("no conditions allowed under spec.finally, final task %s has conditions specified", f.Name), "spec.finally") } + if len(f.WhenExpressions) != 0 { + return apis.ErrInvalidValue(fmt.Sprintf("no when expressions allowed under spec.finally, final task %s has when expressions specified", f.Name), "spec.finally") + } } if err := validateTaskResultReferenceNotUsed(finalTasks); err != nil { @@ -531,3 +555,23 @@ func validateTasksInputFrom(tasks []PipelineTask) *apis.FieldError { } return nil } + +func validateWhenExpressions(tasks []PipelineTask) *apis.FieldError { + for i, t := range tasks { + if err := validateOneOfWhenExpressionsOrConditions(i, t); err != nil { + return err + } + if err := t.WhenExpressions.validate(); err != nil { + return err + } + } + return nil +} + +func validateOneOfWhenExpressionsOrConditions(i int, t PipelineTask) *apis.FieldError { + prefix := "spec.tasks" + if t.WhenExpressions != nil && t.Conditions != nil { + return apis.ErrMultipleOneOf(fmt.Sprintf(fmt.Sprintf(prefix+"[%d].when", i), fmt.Sprintf(prefix+"[%d].conditions", i))) + } + return nil +} diff --git a/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go b/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go index 9615a56a07a..becd842c458 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go @@ -23,6 +23,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" ) func TestPipeline_Validate_Success(t *testing.T) { @@ -157,6 +158,43 @@ func TestPipelineSpec_Validate_Failure(t *testing.T) { TaskSpec: &EmbeddedTask{TaskSpec: getTaskSpec()}, }}, }, + }, { + name: "invalid pipeline with one pipeline task having both conditions and when expressions", + ps: &PipelineSpec{ + Description: "this is an invalid pipeline with invalid pipeline task", + Tasks: []PipelineTask{{ + Name: "valid-pipeline-task", + TaskRef: &TaskRef{Name: "foo-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.resultTypo.bResult)", + Operator: selection.In, + Values: []string{"bar"}, + }}, + Conditions: []PipelineTaskCondition{{ + ConditionRef: "some-condition", + Resources: []PipelineTaskInputResource{{ + Name: "some-workspace", Resource: "missing-great-resource", + }}, + }}, + }}, + }, + }, { + name: "invalid pipeline with one pipeline task having when expression with misconfigured result reference", + ps: &PipelineSpec{ + Description: "this is an invalid pipeline with invalid pipeline task", + Tasks: []PipelineTask{{ + Name: "valid-pipeline-task", + TaskRef: &TaskRef{Name: "foo-task"}, + }, { + Name: "invalid-pipeline-task", + TaskRef: &TaskRef{Name: "foo-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.resultTypo.bResult)", + Operator: selection.In, + Values: []string{"bar"}, + }}, + }}, + }, }, { name: "invalid pipeline with pipeline task having reference to resources which does not exist", ps: &PipelineSpec{ @@ -738,6 +776,26 @@ func TestValidatePipelineParameterVariables_Success(t *testing.T) { Name: "a-param", Value: ArrayOrString{StringVal: "$(baz) and $(foo-is-baz)"}, }}, }}, + }, { + name: "valid string parameter variables in when expression", + params: []ParamSpec{{ + Name: "baz", Type: ParamTypeString, + }, { + Name: "foo-is-baz", Type: ParamTypeString, + }}, + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(params.baz)", + Operator: selection.In, + Values: []string{"foo"}, + }, { + Input: "$(params.foo-is-baz)", + Operator: selection.In, + Values: []string{"baz"}, + }}, + }}, }, { name: "valid array parameter variables", params: []ParamSpec{{ @@ -803,6 +861,56 @@ func TestValidatePipelineParameterVariables_Failure(t *testing.T) { Name: "a-param", Value: ArrayOrString{Type: ParamTypeString, StringVal: "$(params.does-not-exist)"}, }}, }}, + }, { + name: "invalid string parameter variables in when expression, missing input param from the param declarations", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(params.baz)", + Operator: selection.In, + Values: []string{"foo"}, + }}, + }}, + }, { + name: "invalid string parameter variables in when expression, missing values param from the param declarations", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "bax", + Operator: selection.In, + Values: []string{"$(params.foo-is-baz)"}, + }}, + }}, + }, { + name: "invalid string parameter variables in when expression, array reference in input", + params: []ParamSpec{{ + Name: "foo", Type: ParamTypeArray, Default: &ArrayOrString{Type: ParamTypeArray, ArrayVal: []string{"anarray", "elements"}}, + }}, + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(params.foo)", + Operator: selection.In, + Values: []string{"foo"}, + }}, + }}, + }, { + name: "invalid string parameter variables in when expression, array reference in values", + params: []ParamSpec{{ + Name: "foo", Type: ParamTypeArray, Default: &ArrayOrString{Type: ParamTypeArray, ArrayVal: []string{"anarray", "elements"}}, + }}, + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "bax", + Operator: selection.In, + Values: []string{"$(params.foo)"}, + }}, + }}, }, { name: "invalid pipeline task with a parameter combined with missing param from the param declarations", params: []ParamSpec{{ @@ -1361,6 +1469,17 @@ func TestValidateFinalTasks_Failure(t *testing.T) { Name: "param1", Value: ArrayOrString{Type: ParamTypeString, StringVal: "$(tasks.a-task.results.output)"}, }}, }}, + }, { + name: "invalid pipeline with final task specifying when expressions", + finalTasks: []PipelineTask{{ + Name: "final-task", + TaskRef: &TaskRef{Name: "final-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }}, + }}, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1479,3 +1598,133 @@ func getTaskSpec() *TaskSpec { }}, } } + +func TestWhenExpressionsValid(t *testing.T) { + tests := []struct { + name string + tasks []PipelineTask + }{{ + name: "valid operator - In - and values", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo"}, + }}, + }}, + }, { + name: "valid operator - NotIn - and values", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"bar"}, + }}, + }}, + }, { + name: "valid variable", + tasks: []PipelineTask{{ + Name: "a-task", + TaskSpec: &EmbeddedTask{TaskSpec: getTaskSpec()}, + }, { + Name: "foo", + TaskRef: &TaskRef{Name: "foo-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.results.output)", Operator: selection.In, Values: []string{"bar"}, + }}, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateWhenExpressions(tt.tasks); err != nil { + t.Errorf("Pipeline.validateWhenExpressions() returned an error for valid when expressions: %s, %s", tt.name, tt.tasks[0].WhenExpressions) + } + }) + } +} + +func TestWhenExpressionsInvalid(t *testing.T) { + tests := []struct { + name string + tasks []PipelineTask + }{{ + name: "invalid operator - exists", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.Exists, + Values: []string{"foo"}, + }}, + }}, + }, { + name: "invalid values - empty", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{}, + }}, + }}, + }, { + name: "missing Operator", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Values: []string{"foo"}, + }}, + }}, + }, { + name: "missing Values", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + }}, + }}, + }, { + name: "contains both when expressions and conditions", + tasks: []PipelineTask{{ + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"bar"}, + }}, + Conditions: []PipelineTaskCondition{{ + ConditionRef: "some-condition", + }}, + }}, + }, { + name: "invalid variable", + tasks: []PipelineTask{{ + Name: "a-task", + TaskSpec: &EmbeddedTask{TaskSpec: getTaskSpec()}, + }, { + Name: "foo", + TaskRef: &TaskRef{Name: "foo-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.resultsTypo.output)", Operator: selection.In, Values: []string{"bar"}, + }}, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateWhenExpressions(tt.tasks); err == nil { + t.Errorf("Pipeline.validateWhenExpressions() did not return error for invalid when expressions: %s, %s, %s", tt.name, tt.tasks[0].WhenExpressions, err) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/pipelinerun_types.go b/pkg/apis/pipeline/v1beta1/pipelinerun_types.go index 267f5e19491..43676e9a090 100644 --- a/pkg/apis/pipeline/v1beta1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1beta1/pipelinerun_types.go @@ -352,6 +352,9 @@ type PipelineRunTaskRunStatus struct { // ConditionChecks maps the name of a condition check to its Status // +optional ConditionChecks map[string]*PipelineRunConditionCheckStatus `json:"conditionChecks,omitempty"` + // WhenExpressionsStatus is the results for the PipelineTask's when expressions + // +optional + WhenExpressionsStatus *WhenExpressionsEvaluationStatus `json:"whenExpressionsStatus,omitempty"` } // PipelineRunConditionCheckStatus returns the condition check status diff --git a/pkg/apis/pipeline/v1beta1/resultref_test.go b/pkg/apis/pipeline/v1beta1/resultref_test.go index a01a3b1f6b4..88d53fac309 100644 --- a/pkg/apis/pipeline/v1beta1/resultref_test.go +++ b/pkg/apis/pipeline/v1beta1/resultref_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "github.com/tektoncd/pipeline/test/diff" + "k8s.io/apimachinery/pkg/selection" ) func TestNewResultReference(t *testing.T) { @@ -433,3 +434,343 @@ func TestLooksLikeResultRef(t *testing.T) { }) } } + +func TestNewResultReferenceWhenExpressions(t *testing.T) { + type args struct { + whenExpression v1beta1.WhenExpression + } + tests := []struct { + name string + args args + want []*v1beta1.ResultRef + }{ + { + name: "Test valid expression", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTask.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask", + Result: "sumResult", + }, + }, + }, { + name: "substitution within string", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "sum-will-go-here -> $(tasks.sumTask.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask", + Result: "sumResult", + }, + }, + }, { + name: "multiple substitution", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTask1.results.sumResult) and another $(tasks.sumTask2.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask1", + Result: "sumResult", + }, { + PipelineTask: "sumTask2", + Result: "sumResult", + }, + }, + }, { + name: "multiple substitution with param", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(params.param) $(tasks.sumTask1.results.sumResult) and another $(tasks.sumTask2.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask1", + Result: "sumResult", + }, { + PipelineTask: "sumTask2", + Result: "sumResult", + }, + }, + }, { + name: "first separator typo", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(task.sumTasks.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: nil, + }, { + name: "third separator typo", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTasks.result.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: nil, + }, { + name: "param substitution shouldn't be considered result ref", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(params.paramName)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: nil, + }, { + name: "One bad and good result substitution", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "good -> $(tasks.sumTask1.results.sumResult) bad-> $(task.sumTask2.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask1", + Result: "sumResult", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expressions, ok := tt.args.whenExpression.GetVarSubstitutionExpressionsForWhenExpression() + if !ok && tt.want != nil { + t.Fatalf("expected to find expressions but didn't find any") + } else { + got := v1beta1.NewResultRefs(expressions) + if d := cmp.Diff(tt.want, got); d != "" { + t.Errorf("TestNewResultReference/%s %s", tt.name, diff.PrintWantGot(d)) + } + } + }) + } +} + +func TestHasResultReferenceWhenExpression(t *testing.T) { + type args struct { + whenExpression v1beta1.WhenExpression + } + tests := []struct { + name string + args args + wantRef []*v1beta1.ResultRef + }{ + { + name: "Test valid expression", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "sumResult", + Operator: selection.In, + Values: []string{"$(tasks.sumTask.results.sumResult)"}, + }, + }, + wantRef: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask", + Result: "sumResult", + }, + }, + }, { + name: "Test valid expression with dashes", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sum-task.results.sum-result)", + Operator: selection.In, + Values: []string{"sum-result"}, + }, + }, + wantRef: []*v1beta1.ResultRef{ + { + PipelineTask: "sum-task", + Result: "sum-result", + }, + }, + }, { + name: "Test valid expression with underscores", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sum-task.results.sum_result)", + Operator: selection.In, + Values: []string{"sum-result"}, + }, + }, + wantRef: []*v1beta1.ResultRef{ + { + PipelineTask: "sum-task", + Result: "sum_result", + }, + }, + }, { + name: "Test invalid expression: param substitution shouldn't be considered result ref", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(params.paramName)", + Operator: selection.In, + Values: []string{"sum-result"}, + }, + }, + wantRef: nil, + }, { + name: "Test valid expression in array", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$sumResult", + Operator: selection.In, + Values: []string{"$(tasks.sumTask.results.sumResult)", "$(tasks.sumTask2.results.sumResult2)"}, + }, + }, + wantRef: []*v1beta1.ResultRef{ + { + PipelineTask: "sumTask", + Result: "sumResult", + }, + { + PipelineTask: "sumTask2", + Result: "sumResult2", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expressions, ok := tt.args.whenExpression.GetVarSubstitutionExpressionsForWhenExpression() + if !ok { + t.Fatalf("expected to find expressions but didn't find any") + } + got := v1beta1.NewResultRefs(expressions) + sort.Slice(got, func(i, j int) bool { + if got[i].PipelineTask > got[j].PipelineTask { + return false + } + if got[i].Result > got[j].Result { + return false + } + return true + }) + if d := cmp.Diff(tt.wantRef, got); d != "" { + t.Errorf("TestHasResultReference/%s %s", tt.name, diff.PrintWantGot(d)) + } + }) + } +} + +func TestLooksLikeResultRefWhenExpression(t *testing.T) { + type args struct { + whenExpression v1beta1.WhenExpression + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "test expression that is a result ref", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTasks.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: true, + }, { + name: "test expression: looks like result ref, but typo in 'task' separator", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(task.sumTasks.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: true, + }, { + name: "test expression: looks like result ref, but typo in 'results' separator", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTasks.result.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: true, + }, { + name: "test expression: missing 'task' separator", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(sumTasks.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: false, + }, { + name: "test expression: missing variable substitution", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "tasks.sumTasks.results.sumResult", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: false, + }, { + name: "test expression: param substitution shouldn't be considered result ref", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(params.someParam)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: false, + }, { + name: "test expression: one good ref, one bad one should return true", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTasks.results.sumResult) $(task.sumTasks.results.sumResult)", + Operator: selection.In, + Values: []string{"foo"}, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expressions, ok := tt.args.whenExpression.GetVarSubstitutionExpressionsForWhenExpression() + if ok { + if got := v1beta1.LooksLikeContainsResultRefs(expressions); got != tt.want { + t.Errorf("LooksLikeContainsResultRefs() = %v, want %v", got, tt.want) + } + } else if tt.want { + t.Errorf("LooksLikeContainsResultRefs() = %v, want %v", false, tt.want) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/when_types.go b/pkg/apis/pipeline/v1beta1/when_types.go new file mode 100644 index 00000000000..e49c5a0861a --- /dev/null +++ b/pkg/apis/pipeline/v1beta1/when_types.go @@ -0,0 +1,139 @@ +/* +Copyright 2020 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 v1beta1 + +import ( + "k8s.io/apimachinery/pkg/selection" +) + +// WhenExpression allows a PipelineTask to declare expressions to be evaluated before the Task is run +// to determine whether the Task should be executed or skipped +type WhenExpression struct { + // Input is the string for guard checking which can be a static input or an output from a parent Task + Input string `json:"input,omitempty"` + // Operator that represents an Input's relationship to the values + Operator selection.Operator `json:"operator,omitempty"` + // Values is an array of strings, which is compared against the input, for guard checking + // It must be non-empty + Values []string `json:"values,omitempty"` +} + +// GetVarSubstitutionExpressionsForWhenExpression extracts all the values between "$(" and ")"" for a when expression +func (we *WhenExpression) GetVarSubstitutionExpressionsForWhenExpression() ([]string, bool) { + var allExpressions []string + allExpressions = append(allExpressions, validateString(we.Input)...) + for _, value := range we.Values { + allExpressions = append(allExpressions, validateString(value)...) + } + return allExpressions, len(allExpressions) != 0 +} + +func (we *WhenExpression) valuesContainsInput() bool { + for i := range we.Values { + if we.Values[i] == we.Input { + return true + } + } + return false +} + +func (we *WhenExpression) isTrue() bool { + if we.Operator == selection.In { + return we.valuesContainsInput() + } + return !we.valuesContainsInput() +} + +func (we *WhenExpression) hasVariable() bool { + if _, hasVariable := we.GetVarSubstitutionExpressionsForWhenExpression(); hasVariable { + return true + } + return false +} + +func (we *WhenExpression) applyReplacements(replacements map[string]string) WhenExpression { + replacedInput := ApplyReplacements(we.Input, replacements) + + var replacedValues []string + for _, val := range we.Values { + replacedValues = append(replacedValues, ApplyReplacements(val, replacements)) + } + + return WhenExpression{Input: replacedInput, Operator: we.Operator, Values: replacedValues} +} + +// WhenExpressions is an array of When Expressions which are used to specify whether a Task should be executed or skipped. +type WhenExpressions []WhenExpression + +// AreTrue indicates whether the When Expression are true. +func (wes WhenExpressions) AreTrue() bool { + for _, we := range wes { + if isTrue := we.isTrue(); !isTrue { + return false + } + } + return true +} + +// HaveVariables indicates whether When Expressions have variables in the Inputs or Values. +func (wes WhenExpressions) HaveVariables() bool { + for _, we := range wes { + if hasVariable := we.hasVariable(); !hasVariable { + return false + } + } + return true +} + +// ReplaceWhenExpressionsVariables interpolates variables in When Expressions. +func (wes WhenExpressions) ReplaceWhenExpressionsVariables(replacements map[string]string) WhenExpressions { + var replaced []WhenExpression + for _, we := range wes { + replaced = append(replaced, we.applyReplacements(replacements)) + } + return replaced +} + +// WhenExpressionEvaluationResult is the result from evaluating a When Expression. +type WhenExpressionEvaluationResult struct { + // Expression is the expression, made up of Input, Operator and Values. + Expression *WhenExpression `json:"expression,omitempty"` + // IsTrue is a boolean from evaluating the Expression. + IsTrue bool `json:"result,omitempty"` +} + +// WhenExpressionsEvaluationStatus are the results from evaluating When Expressions. +type WhenExpressionsEvaluationStatus struct { + // EvaluationResults are the results from evaluating When Expressions. + EvaluationResults []*WhenExpressionEvaluationResult `json:"evaluationResults,omitempty"` + // Executed is a boolean that represents an AND of all results from evaluating When Expressions. + Executed bool `json:"execute,omitempty"` +} + +// GetWhenExpressionsStatus evaluates When Expressions and produces Evaluation Results. +func (wes WhenExpressions) GetWhenExpressionsStatus() WhenExpressionsEvaluationStatus { + var evaluationResults []*WhenExpressionEvaluationResult + + executed := true + for i := range wes { + isTrue := wes[i].isTrue() + evaluationResults = append(evaluationResults, &WhenExpressionEvaluationResult{Expression: &wes[i], IsTrue: isTrue}) + executed = executed && isTrue + } + + return WhenExpressionsEvaluationStatus{EvaluationResults: evaluationResults, Executed: executed} +} diff --git a/pkg/apis/pipeline/v1beta1/when_types_test.go b/pkg/apis/pipeline/v1beta1/when_types_test.go new file mode 100644 index 00000000000..e0186684b64 --- /dev/null +++ b/pkg/apis/pipeline/v1beta1/when_types_test.go @@ -0,0 +1,323 @@ +/* +Copyright 2020 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 v1beta1 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/test/diff" + "k8s.io/apimachinery/pkg/selection" +) + +func TestAreTrue(t *testing.T) { + tests := []struct { + name string + whenExpressions WhenExpressions + expected bool + }{{ + name: "in expression", + whenExpressions: WhenExpressions{ + { + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }, + }, + expected: true, + }, { + name: "notin expression", + whenExpressions: WhenExpressions{ + { + Input: "foobar", + Operator: selection.NotIn, + Values: []string{"foobar"}, + }, + }, + expected: false, + }, { + name: "multiple expressions", + whenExpressions: WhenExpressions{ + { + Input: "foobar", + Operator: selection.In, + Values: []string{"foobar"}, + }, { + Input: "foo", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + expected: false, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.whenExpressions.AreTrue() + if d := cmp.Diff(tc.expected, got); d != "" { + t.Errorf("Error evaluating AreTrue() for When Expressions in test case %s: %s", tc.name, diff.PrintWantGot(d)) + } + }) + } +} + +func TestHaveVariables(t *testing.T) { + tests := []struct { + name string + whenExpressions WhenExpressions + expected bool + }{{ + name: "doesn't have variable", + whenExpressions: WhenExpressions{ + { + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }, + }, + expected: false, + }, { + name: "have variable - input", + whenExpressions: WhenExpressions{ + { + Input: "$(params.foobar)", + Operator: selection.NotIn, + Values: []string{"foobar"}, + }, + }, + expected: true, + }, { + name: "have variable - values", + whenExpressions: WhenExpressions{ + { + Input: "foobar", + Operator: selection.In, + Values: []string{"$(params.foobar)"}, + }, + }, + expected: true, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.whenExpressions.HaveVariables() + if d := cmp.Diff(tc.expected, got); d != "" { + t.Errorf("Error evaluating HaveVariables() for When Expressions in test case %s: %s", tc.name, diff.PrintWantGot(d)) + } + }) + } +} + +func TestGetWhenExpressionsStatus(t *testing.T) { + tests := []struct { + name string + whenExpressions WhenExpressions + expected WhenExpressionsEvaluationStatus + }{{ + name: "in expression", + whenExpressions: WhenExpressions{ + { + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }, + }, + expected: WhenExpressionsEvaluationStatus{ + EvaluationResults: []*WhenExpressionEvaluationResult{ + { + Expression: &WhenExpression{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }, + IsTrue: true, + }, + }, + Executed: true, + }, + }, { + name: "notin expression", + whenExpressions: WhenExpressions{ + { + Input: "foobar", + Operator: selection.NotIn, + Values: []string{"foobar"}, + }, + }, + expected: WhenExpressionsEvaluationStatus{ + EvaluationResults: []*WhenExpressionEvaluationResult{ + { + Expression: &WhenExpression{ + Input: "foobar", + Operator: selection.NotIn, + Values: []string{"foobar"}, + }, + IsTrue: false, + }, + }, + Executed: false, + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.whenExpressions.GetWhenExpressionsStatus() + if d := cmp.Diff(tc.expected, got); d != "" { + t.Errorf("Error evaluating When Expressions in test case %s: %s", tc.name, diff.PrintWantGot(d)) + } + }) + } +} + +func TestReplaceWhenExpressionsVariables(t *testing.T) { + tests := []struct { + name string + whenExpressions WhenExpressions + replacements map[string]string + expected WhenExpressions + }{{ + name: "params replacement in input", + whenExpressions: WhenExpressions{ + { + Input: "$(params.foo)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + replacements: map[string]string{ + "params.foo": "bar", + }, + expected: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + }, { + name: "params replacement in values", + whenExpressions: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"$(params.foo)"}, + }, + }, + replacements: map[string]string{ + "params.foo": "bar", + }, + expected: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + }, { + name: "results replacement in input", + whenExpressions: WhenExpressions{ + { + Input: "$(tasks.aTask.results.foo)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + replacements: map[string]string{ + "tasks.aTask.results.foo": "bar", + }, + expected: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + }, { + name: "results replacement in values", + whenExpressions: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"$(tasks.aTask.results.foo)"}, + }, + }, + replacements: map[string]string{ + "tasks.aTask.results.foo": "bar", + }, + expected: WhenExpressions{ + { + Input: "bar", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.whenExpressions.ReplaceWhenExpressionsVariables(tc.replacements) + if d := cmp.Diff(tc.expected, got); d != "" { + t.Errorf("Error evaluating When Expressions in test case %s: %s", tc.name, diff.PrintWantGot(d)) + } + }) + } +} + +func TestApplyReplacements(t *testing.T) { + tests := []struct { + name string + original *WhenExpression + replacements map[string]string + expected *WhenExpression + }{{ + name: "replace parameters variables", + original: &WhenExpression{ + Input: "$(params.path)", + Operator: selection.In, + Values: []string{"$(params.branch)"}, + }, + replacements: map[string]string{ + "params.path": "readme.md", + "params.branch": "staging", + }, + expected: &WhenExpression{ + Input: "readme.md", + Operator: selection.In, + Values: []string{"staging"}, + }, + }, { + name: "replace results variables", + original: &WhenExpression{ + Input: "$(tasks.foo.results.bar)", + Operator: selection.In, + Values: []string{"$(tasks.aTask.results.aResult)"}, + }, + replacements: map[string]string{ + "tasks.foo.results.bar": "foobar", + "tasks.aTask.results.aResult": "barfoo", + }, + expected: &WhenExpression{ + Input: "foobar", + Operator: selection.In, + Values: []string{"barfoo"}, + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.original.applyReplacements(tc.replacements) + if d := cmp.Diff(tc.expected, &got); d != "" { + t.Errorf("Error applying replacements for When Expressions: %s", diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/when_validation.go b/pkg/apis/pipeline/v1beta1/when_validation.go new file mode 100644 index 00000000000..1b9a9c4fa2d --- /dev/null +++ b/pkg/apis/pipeline/v1beta1/when_validation.go @@ -0,0 +1,110 @@ +/* +Copyright 2020 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 v1beta1 + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/sets" + "knative.dev/pkg/apis" +) + +var validWhenOperators = []string{ + string(selection.In), + string(selection.NotIn), +} + +func (wes WhenExpressions) validate() *apis.FieldError { + if err := wes.validateWhenExpressionsFields(); err != nil { + return err + } + if err := wes.validateTaskResultsVariables(); err != nil { + return err + } + return nil +} + +func (wes WhenExpressions) validateWhenExpressionsFields() *apis.FieldError { + for _, we := range wes { + if err := we.validateWhenExpressionFields(); err != nil { + return err + } + } + return nil +} + +func (we *WhenExpression) validateWhenExpressionFields() *apis.FieldError { + if equality.Semantic.DeepEqual(we, &WhenExpression{}) || we == nil { + return apis.ErrMissingField(apis.CurrentField) + } + if err := validateWhenExpressionOperator(&we.Operator); err != nil { + return err + } + if err := validateWhenExpressionValues(&we.Values); err != nil { + return err + } + return nil +} + +func validateWhenExpressionOperator(operator *selection.Operator) *apis.FieldError { + if !sets.NewString(validWhenOperators...).Has(string(*operator)) { + message := fmt.Sprintf("operator %q is not recognized. valid operators: %s", *operator, strings.Join(validWhenOperators, ",")) + return apis.ErrInvalidValue(message, "spec.task.when") + } + return nil +} + +func validateWhenExpressionValues(values *[]string) *apis.FieldError { + if len(*values) == 0 { + return apis.ErrInvalidValue("expecting non-empty values field", "spec.task.when") + } + return nil +} + +func (wes WhenExpressions) validateTaskResultsVariables() *apis.FieldError { + for _, we := range wes { + expressions, ok := we.GetVarSubstitutionExpressionsForWhenExpression() + if ok { + if LooksLikeContainsResultRefs(expressions) { + expressions = filter(expressions, looksLikeResultRef) + resultRefs := NewResultRefs(expressions) + if len(expressions) != len(resultRefs) { + message := fmt.Sprintf("expected all of the expressions %v to be result expressions but only %v were", expressions, resultRefs) + return apis.ErrInvalidValue(message, "spec.tasks.when") + } + } + } + } + return nil +} + +func (wes WhenExpressions) validateParametersVariables(prefix string, paramNames sets.String, arrayParamNames sets.String) *apis.FieldError { + for _, whenExpression := range wes { + if err := validatePipelineStringVariable(fmt.Sprintf("whenInput[%s]", whenExpression.Input), whenExpression.Input, prefix, paramNames, arrayParamNames); err != nil { + return err + } + for _, whenExpressionValue := range whenExpression.Values { + if err := validatePipelineStringVariable(fmt.Sprintf("whenValue[%s]", whenExpressionValue), whenExpressionValue, prefix, paramNames, arrayParamNames); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/apis/pipeline/v1beta1/when_validation_test.go b/pkg/apis/pipeline/v1beta1/when_validation_test.go new file mode 100644 index 00000000000..6c3c1fa8325 --- /dev/null +++ b/pkg/apis/pipeline/v1beta1/when_validation_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2020 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 v1beta1 + +import ( + "testing" + + "k8s.io/apimachinery/pkg/selection" +) + +func TestWhenExpressions_Valid(t *testing.T) { + tests := []struct { + name string + wes WhenExpressions + }{{ + name: "valid operator - In - and values", + wes: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo"}, + }}, + }, { + name: "valid operator - NotIn - and values", + wes: []WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"bar"}, + }}, + }, { + wes: []WhenExpression{{ + Input: "$(tasks.a-task.results.output)", + Operator: selection.In, + Values: []string{"bar"}, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.wes.validate(); err != nil { + t.Errorf("WhenExpressions.validate() returned an error for valid when expressions: %s, %s", tt.name, tt.wes) + } + }) + } +} + +func TestWhenExpressions_Invalid(t *testing.T) { + tests := []struct { + name string + wes WhenExpressions + }{{ + name: "invalid operator - exists", + wes: []WhenExpression{{ + Input: "foo", + Operator: selection.Exists, + Values: []string{"foo"}, + }}, + }, { + name: "invalid values - empty", + wes: []WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{}, + }}, + }, { + name: "missing Operator", + wes: []WhenExpression{{ + Input: "foo", + Values: []string{"foo"}, + }}, + }, { + name: "missing Values", + wes: []WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + }}, + }, { + name: "invalid result", + wes: []WhenExpression{{ + Input: "$(tasks.a-task.resultsTypo.output)", + Operator: selection.In, + Values: []string{"bar"}, + }}, + }, { + name: "missing when expression", + wes: []WhenExpression{{}}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.wes.validate(); err == nil { + t.Errorf("WhenExpressions.validate() did not return error for invalid when expressions: %s, %s, %s", tt.name, tt.wes, err) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go index 37733e74976..f645c9ccd4d 100644 --- a/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go @@ -762,6 +762,11 @@ func (in *PipelineRunTaskRunStatus) DeepCopyInto(out *PipelineRunTaskRunStatus) (*out)[key] = outVal } } + if in.WhenExpressionsStatus != nil { + in, out := &in.WhenExpressionsStatus, &out.WhenExpressionsStatus + *out = new(WhenExpressionsEvaluationStatus) + (*in).DeepCopyInto(*out) + } return } @@ -847,6 +852,13 @@ func (in *PipelineTask) DeepCopyInto(out *PipelineTask) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.WhenExpressions != nil { + in, out := &in.WhenExpressions, &out.WhenExpressions + *out = make(WhenExpressions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.RunAfter != nil { in, out := &in.RunAfter, &out.RunAfter *out = make([]string, len(*in)) @@ -1708,6 +1720,97 @@ func (in *TaskSpec) DeepCopy() *TaskSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WhenExpression) DeepCopyInto(out *WhenExpression) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WhenExpression. +func (in *WhenExpression) DeepCopy() *WhenExpression { + if in == nil { + return nil + } + out := new(WhenExpression) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WhenExpressionEvaluationResult) DeepCopyInto(out *WhenExpressionEvaluationResult) { + *out = *in + if in.Expression != nil { + in, out := &in.Expression, &out.Expression + *out = new(WhenExpression) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WhenExpressionEvaluationResult. +func (in *WhenExpressionEvaluationResult) DeepCopy() *WhenExpressionEvaluationResult { + if in == nil { + return nil + } + out := new(WhenExpressionEvaluationResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in WhenExpressions) DeepCopyInto(out *WhenExpressions) { + { + in := &in + *out = make(WhenExpressions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + return + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WhenExpressions. +func (in WhenExpressions) DeepCopy() WhenExpressions { + if in == nil { + return nil + } + out := new(WhenExpressions) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WhenExpressionsEvaluationStatus) DeepCopyInto(out *WhenExpressionsEvaluationStatus) { + *out = *in + if in.EvaluationResults != nil { + in, out := &in.EvaluationResults, &out.EvaluationResults + *out = make([]*WhenExpressionEvaluationResult, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(WhenExpressionEvaluationResult) + (*in).DeepCopyInto(*out) + } + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WhenExpressionsEvaluationStatus. +func (in *WhenExpressionsEvaluationStatus) DeepCopy() *WhenExpressionsEvaluationStatus { + if in == nil { + return nil + } + out := new(WhenExpressionsEvaluationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceBinding) DeepCopyInto(out *WorkspaceBinding) { *out = *in diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index 47d14c68273..0946d5083a0 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -567,7 +567,7 @@ func getPipelineRunResults(pipelineSpec *v1beta1.PipelineSpec, resolvedResultRef func getTaskRunsStatus(pr *v1beta1.PipelineRun, state []*resources.ResolvedPipelineRunTask) map[string]*v1beta1.PipelineRunTaskRunStatus { status := make(map[string]*v1beta1.PipelineRunTaskRunStatus) for _, rprt := range state { - if rprt.TaskRun == nil && rprt.ResolvedConditionChecks == nil { + if rprt.TaskRun == nil && rprt.ResolvedConditionChecks == nil && rprt.PipelineTask.WhenExpressions == nil { continue } @@ -585,6 +585,22 @@ func getTaskRunsStatus(pr *v1beta1.PipelineRun, state []*resources.ResolvedPipel prtrs.Status = &rprt.TaskRun.Status } + if len(rprt.PipelineTask.WhenExpressions) > 0 { + if prtrs.Status == nil { + prtrs.Status = &v1beta1.TaskRunStatus{} + } + status := rprt.PipelineTask.WhenExpressions.GetWhenExpressionsStatus() + prtrs.WhenExpressionsStatus = &status + if !status.Executed { + prtrs.Status.SetCondition(&apis.Condition{ + Type: apis.ConditionSucceeded, + Status: corev1.ConditionFalse, + Reason: resources.ReasonWhenExpressionsEvaluatedToFalse, + Message: fmt.Sprintf("When Expressions for Task %s in PipelineRun %s evaluated to False", rprt.TaskRunName, pr.Name), + }) + } + } + if len(rprt.ResolvedConditionChecks) > 0 { cStatus := make(map[string]*v1beta1.PipelineRunConditionCheckStatus) for _, c := range rprt.ResolvedConditionChecks { diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index 4f9246cbd4d..0d1fb26cd69 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -47,6 +47,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/types" ktesting "k8s.io/client-go/testing" "k8s.io/client-go/tools/record" @@ -835,6 +836,72 @@ func TestUpdateTaskRunsState(t *testing.T) { } +func TestUpdateTaskRunsWithWhenExpressions(t *testing.T) { + // TestUpdateTaskRunsWithWhenExpressions runs "getTaskRunsStatus" and verifies how it updates a PipelineRun status + // from a TaskRun with When Expressionsassociated to the PipelineRun + pr := tb.PipelineRun("test-pipeline-run", tb.PipelineRunNamespace("foo"), tb.PipelineRunSpec("test-pipeline")) + pipelineTask := v1beta1.PipelineTask{ + Name: "unit-test-1", + TaskRef: &v1beta1.TaskRef{Name: "unit-test-task"}, + WhenExpressions: []v1beta1.WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"foo", "bar"}, + }}, + } + task := tb.Task("unit-test-task", tb.TaskSpec( + tb.TaskResources(tb.TaskResourcesInput("workspace", resourcev1alpha1.PipelineResourceTypeGit)), + ), tb.TaskNamespace("foo")) + + expectedWhenExpressionsResults := &v1beta1.WhenExpressionsEvaluationStatus{ + EvaluationResults: []*v1beta1.WhenExpressionEvaluationResult{{ + Expression: &v1beta1.WhenExpression{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"foo", "bar"}, + }, + IsTrue: false, + }}, + Executed: false, + } + + expectedTaskRunsStatus := make(map[string]*v1beta1.PipelineRunTaskRunStatus) + expectedTaskRunsStatus["test-pipeline-run-success-unit-test-1"] = &v1beta1.PipelineRunTaskRunStatus{ + PipelineTaskName: "unit-test-1", + Status: &v1beta1.TaskRunStatus{ + Status: duckv1beta1.Status{ + Conditions: []apis.Condition{{ + Type: apis.ConditionSucceeded, + Status: "False", + Reason: "WhenExpressionsEvaluatedToFalse", + Message: "When Expressions for Task test-pipeline-run-success-unit-test-1 in PipelineRun test-pipeline-run evaluated to False", + }}, + }, + }, + WhenExpressionsStatus: expectedWhenExpressionsResults, + } + + expectedPipelineRunStatus := v1beta1.PipelineRunStatus{ + PipelineRunStatusFields: v1beta1.PipelineRunStatusFields{ + TaskRuns: expectedTaskRunsStatus, + }, + } + + pipelineRunState := resources.PipelineRunState{{ + PipelineTask: &pipelineTask, + TaskRunName: "test-pipeline-run-success-unit-test-1", + TaskRun: nil, + ResolvedTaskResources: &taskrunresources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }} + pr.Status.InitializeConditions() + status := getTaskRunsStatus(pr, pipelineRunState) + if d := cmp.Diff(expectedPipelineRunStatus.TaskRuns, status, ignoreLastTransitionTime); d != "" { + t.Fatalf("Expected PipelineRun status to match TaskRun(s) status, but got a mismatch: %s", diff.PrintWantGot(d)) + } +} + func TestUpdateTaskRunStateWithConditionChecks(t *testing.T) { // TestUpdateTaskRunsState runs "getTaskRunsStatus" and verifies how it updates a PipelineRun status // from several different TaskRun with Conditions associated to the PipelineRun diff --git a/pkg/reconciler/pipelinerun/resources/apply.go b/pkg/reconciler/pipelinerun/resources/apply.go index dd12c092151..f6821b767d4 100644 --- a/pkg/reconciler/pipelinerun/resources/apply.go +++ b/pkg/reconciler/pipelinerun/resources/apply.go @@ -65,7 +65,7 @@ func ApplyContexts(spec *v1beta1.PipelineSpec, pipelineName string, pr *v1beta1. return ApplyReplacements(spec, replacements, map[string][]string{}) } -// ApplyTaskResults applies the ResolvedResultRef to each PipelineTask.Params in targets +// ApplyTaskResults applies the ResolvedResultRef to each PipelineTask.Params and Pipeline.WhenExpressions in targets func ApplyTaskResults(targets PipelineRunState, resolvedResultRefs ResolvedResultRefs) { stringReplacements := map[string]string{} @@ -84,6 +84,7 @@ func ApplyTaskResults(targets PipelineRunState, resolvedResultRefs ResolvedResul if resolvedPipelineRunTask.PipelineTask != nil { pipelineTask := resolvedPipelineRunTask.PipelineTask.DeepCopy() pipelineTask.Params = replaceParamValues(pipelineTask.Params, stringReplacements, nil) + pipelineTask.WhenExpressions = pipelineTask.WhenExpressions.ReplaceWhenExpressionsVariables(stringReplacements) resolvedPipelineRunTask.PipelineTask = pipelineTask } } @@ -102,6 +103,7 @@ func ApplyReplacements(p *v1beta1.PipelineSpec, replacements map[string]string, c := tasks[i].Conditions[j] c.Params = replaceParamValues(c.Params, replacements, arrayReplacements) } + tasks[i].WhenExpressions = tasks[i].WhenExpressions.ReplaceWhenExpressionsVariables(replacements) } return p diff --git a/pkg/reconciler/pipelinerun/resources/apply_test.go b/pkg/reconciler/pipelinerun/resources/apply_test.go index 3be1f59fa05..eb1a6a36226 100644 --- a/pkg/reconciler/pipelinerun/resources/apply_test.go +++ b/pkg/reconciler/pipelinerun/resources/apply_test.go @@ -22,6 +22,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" tb "github.com/tektoncd/pipeline/internal/builder/v1beta1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" @@ -59,6 +60,25 @@ func TestApplyParameters(t *testing.T) { tb.PipelineTaskParam("first-task-second-param", "second-value"), tb.PipelineTaskParam("first-task-third-param", "static value"), ))), + }, { + name: "single parameter with when expression", + original: tb.Pipeline("test-pipeline", + tb.PipelineSpec( + tb.PipelineParamSpec("first-param", v1beta1.ParamTypeString, tb.ParamSpecDefault("default-value")), + tb.PipelineParamSpec("second-param", v1beta1.ParamTypeString), + tb.PipelineTask("first-task-1", "first-task", + tb.PipelineTaskWhenExpression("$(params.first-param)", selection.In, []string{"$(params.second-param)"}), + ))), + run: tb.PipelineRun("test-pipeline-run", + tb.PipelineRunSpec("test-pipeline", + tb.PipelineRunParam("second-param", "second-value"))), + expected: tb.Pipeline("test-pipeline", + tb.PipelineSpec( + tb.PipelineParamSpec("first-param", v1beta1.ParamTypeString, tb.ParamSpecDefault("default-value")), + tb.PipelineParamSpec("second-param", v1beta1.ParamTypeString), + tb.PipelineTask("first-task-1", "first-task", + tb.PipelineTaskWhenExpression("default-value", selection.In, []string{"second-value"}), + ))), }, { name: "pipeline parameter nested inside task parameter", original: tb.Pipeline("test-pipeline", @@ -177,7 +197,7 @@ func TestApplyTaskResults_MinimalExpression(t *testing.T) { want PipelineRunState }{ { - name: "Test result substitution on minimal variable substitution expression", + name: "Test result substitution on minimal variable substitution expression - params", args: args{ resolvedResultRefs: ResolvedResultRefs{ { @@ -227,6 +247,53 @@ func TestApplyTaskResults_MinimalExpression(t *testing.T) { }, }, }, + }, { + name: "Test result substitution on minimal variable substitution expression - when expressions", + args: args{ + resolvedResultRefs: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, + }, + targets: PipelineRunState{ + { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"$(tasks.aTask.results.aResult)"}, + }, + }, + }, + }, + }, + }, + want: PipelineRunState{ + { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "aResultValue", + Operator: selection.In, + Values: []string{"aResultValue"}, + }, + }, + }, + }, + }, }, } for _, tt := range tests { @@ -250,7 +317,7 @@ func TestApplyTaskResults_EmbeddedExpression(t *testing.T) { want PipelineRunState }{ { - name: "Test result substitution on embedded variable substitution expression", + name: "Test result substitution on embedded variable substitution expression - params", args: args{ resolvedResultRefs: ResolvedResultRefs{ { @@ -300,6 +367,53 @@ func TestApplyTaskResults_EmbeddedExpression(t *testing.T) { }, }, }, + }, { + name: "Test result substitution on embedded variable substitution expression - when expressions", + args: args{ + resolvedResultRefs: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, + }, + targets: PipelineRunState{ + { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "Result value --> $(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"Result value --> $(tasks.aTask.results.aResult)"}, + }, + }, + }, + }, + }, + }, + want: PipelineRunState{ + { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "Result value --> aResultValue", + Operator: selection.In, + Values: []string{"Result value --> aResultValue"}, + }, + }, + }, + }, + }, }, } for _, tt := range tests { diff --git a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go index f528d05103e..99ec3022898 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go @@ -41,6 +41,8 @@ const ( // ReasonConditionCheckFailed indicates that the reason for the failure status is that the // condition check associated to the pipeline task evaluated to false ReasonConditionCheckFailed = "ConditionCheckFailed" + + ReasonWhenExpressionsEvaluatedToFalse = "WhenExpressionsEvaluatedToFalse" ) // TaskNotFoundError indicates that the resolution failed because a referenced Task couldn't be retrieved @@ -141,12 +143,12 @@ func (t ResolvedPipelineRunTask) IsStarted() bool { return true } -// IsSkipped returns true if a PipelineTask will not be run because -// (1) its Condition Checks failed or +// Skip returns true if a PipelineTask will not be run because +// (1) its Condition Checks failed or its When Expressions evaluated to False // (2) one of the parent task's conditions failed or // (3) Pipeline is in stopping state (one of the PipelineTasks failed) -// Note that this means IsSkipped returns false if a conditionCheck is in progress -func (t ResolvedPipelineRunTask) IsSkipped(state PipelineRunState, d *dag.Graph) bool { +// Note that this means Skip returns false if a conditionCheck is in progress +func (t ResolvedPipelineRunTask) Skip(state PipelineRunState, d *dag.Graph) bool { // it already has TaskRun associated with it - PipelineTask not skipped if t.IsStarted() { return false @@ -159,6 +161,15 @@ func (t ResolvedPipelineRunTask) IsSkipped(state PipelineRunState, d *dag.Graph) } } + // Check if the when expressions have been + if len(t.PipelineTask.WhenExpressions) > 0 { + if haveVariables := t.PipelineTask.WhenExpressions.HaveVariables(); !haveVariables { + if areTrue := t.PipelineTask.WhenExpressions.AreTrue(); !areTrue { + return true + } + } + } + // Skip the PipelineTask if pipeline is in stopping state if isTaskInGraph(t.PipelineTask.Name, d) && state.IsStopping(d) { return true @@ -170,7 +181,7 @@ func (t ResolvedPipelineRunTask) IsSkipped(state PipelineRunState, d *dag.Graph) node := d.Nodes[t.PipelineTask.Name] if isTaskInGraph(t.PipelineTask.Name, d) { for _, p := range node.Prev { - if stateMap[p.Task.HashKey()].IsSkipped(state, d) { + if stateMap[p.Task.HashKey()].Skip(state, d) { return true } } @@ -253,7 +264,7 @@ func (state PipelineRunState) SuccessfulOrSkippedDAGTasks(d *dag.Graph) []string tasks := []string{} for _, t := range state { if isTaskInGraph(t.PipelineTask.Name, d) { - if t.IsSuccessful() || t.IsSkipped(state, d) { + if t.IsSuccessful() || t.Skip(state, d) { tasks = append(tasks, t.PipelineTask.Name) } } @@ -270,7 +281,7 @@ func (state PipelineRunState) checkTasksDone(d *dag.Graph) bool { // this task might have skipped if taskRun is nil // continue and ignore if this task was skipped // skipped task is considered part of done - if t.IsSkipped(state, d) { + if t.Skip(state, d) { continue } return false @@ -554,7 +565,7 @@ func GetPipelineConditionStatus(pr *v1beta1.PipelineRun, state PipelineRunState, switch { case rprt.IsSuccessful(): withStatusTasks = append(withStatusTasks, rprt.PipelineTask.Name) - case rprt.IsSkipped(state, dag): + case rprt.Skip(state, dag): skipTasks++ withStatusTasks = append(withStatusTasks, rprt.PipelineTask.Name) // At least one is skipped and no failure yet, mark as completed diff --git a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go index a1b4dbc0fea..86c4928ade0 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go @@ -38,6 +38,7 @@ import ( corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/util/sets" "knative.dev/pkg/apis" duckv1beta1 "knative.dev/pkg/apis/duck/v1beta1" @@ -78,6 +79,22 @@ var pts = []v1beta1.PipelineTask{{ Name: "mytask9", TaskRef: &v1beta1.TaskRef{Name: "taskHasParentWithRunAfter"}, RunAfter: []string{"mytask8"}, +}, { + Name: "mytask10", + TaskRef: &v1beta1.TaskRef{Name: "taskWithWhenExpressions"}, + WhenExpressions: []v1beta1.WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }}, +}, { + Name: "mytask11", + TaskRef: &v1beta1.TaskRef{Name: "taskWithWhenExpressions"}, + WhenExpressions: []v1beta1.WhenExpression{{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"foo", "bar"}, + }}, }} var p = &v1beta1.Pipeline{ @@ -1134,6 +1151,30 @@ func TestIsSkipped(t *testing.T) { }, }}, expected: true, + }, { + name: "tasks-when-expressions-passed", + taskName: "mytask10", + state: PipelineRunState{{ + PipelineTask: &pts[9], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }}, + expected: false, + }, { + name: "tasks-when-expression-failed", + taskName: "mytask11", + state: PipelineRunState{{ + PipelineTask: &pts[10], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }}, + expected: true, }} for _, tc := range tcs { @@ -1147,7 +1188,7 @@ func TestIsSkipped(t *testing.T) { if rprt == nil { t.Fatalf("Could not get task %s from the state: %v", tc.taskName, tc.state) } - isSkipped := rprt.IsSkipped(tc.state, dag) + isSkipped := rprt.Skip(tc.state, dag) if d := cmp.Diff(isSkipped, tc.expected); d != "" { t.Errorf("Didn't get expected isSkipped %s", diff.PrintWantGot(d)) } @@ -2660,3 +2701,79 @@ func TestPipelineRunState_GetFinalTasks(t *testing.T) { }) } } + +func TestResolvePipeline_WhenExpressions(t *testing.T) { + names.TestingSeed() + tName1 := "pipelinerun-mytask1-9l9zj-always-true-mz4c7" + tName2 := "pipelinerun-mytask1-9l9zj-always-true-mssqb" + + t1 := &v1beta1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: tName1, + }, + } + + t2 := &v1beta1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: tName2, + }, + } + + ptwe1 := v1beta1.WhenExpression{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo"}, + } + + ptwe2 := v1beta1.WhenExpression{ + Input: "foo", + Operator: selection.NotIn, + Values: []string{"bar"}, + } + + pts := []v1beta1.PipelineTask{{ + Name: "mytask1", + TaskRef: &v1beta1.TaskRef{Name: "task"}, + WhenExpressions: []v1beta1.WhenExpression{ptwe1, ptwe2}, + }} + + providedResources := map[string]*resourcev1alpha1.PipelineResource{} + + getTask := func(name string) (v1beta1.TaskInterface, error) { return task, nil } + getClusterTask := func(name string) (v1beta1.TaskInterface, error) { return nil, errors.New("should not get called") } + getCondition := func(name string) (*v1alpha1.Condition, error) { return &condition, nil } + pr := v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pipelinerun", + }, + } + + tcs := []struct { + name string + getTaskRun resources.GetTaskRun + }{ + { + name: "When Expressions exist", + getTaskRun: func(name string) (*v1beta1.TaskRun, error) { + switch name { + case "pipelinerun-mytask1-9l9zj-always-true-0-mz4c7": + return t1, nil + case "pipelinerun-mytask1-9l9zj": + return &trs[0], nil + case "pipelinerun-mytask1-9l9zj-always-true-1-mssqb": + return t2, nil + } + return nil, fmt.Errorf("getTaskRun called with unexpected name %s", name) + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + _, err := ResolvePipelineRun(context.Background(), pr, getTask, tc.getTaskRun, getClusterTask, getCondition, pts, providedResources) + if err != nil { + t.Fatalf("Did not expect error when resolving PipelineRun: %v", err) + } + }) + } +} diff --git a/pkg/reconciler/pipelinerun/resources/resultrefresolution.go b/pkg/reconciler/pipelinerun/resources/resultrefresolution.go index c2062eca4cb..9c4130e261f 100644 --- a/pkg/reconciler/pipelinerun/resources/resultrefresolution.go +++ b/pkg/reconciler/pipelinerun/resources/resultrefresolution.go @@ -40,7 +40,7 @@ type ResolvedResultRef struct { func ResolveResultRefs(pipelineRunState PipelineRunState, targets PipelineRunState) (ResolvedResultRefs, error) { var allResolvedResultRefs ResolvedResultRefs for _, target := range targets { - resolvedResultRefs, err := convertParamsToResultRefs(pipelineRunState, target) + resolvedResultRefs, err := convertToResultRefs(pipelineRunState, target) if err != nil { return nil, err } @@ -72,6 +72,14 @@ func extractResultRefsForParam(pipelineRunState PipelineRunState, param v1beta1. return nil, nil } +func extractResultRefsForWhenExpression(pipelineRunState PipelineRunState, whenExpression v1beta1.WhenExpression) (ResolvedResultRefs, error) { + expressions, ok := whenExpression.GetVarSubstitutionExpressionsForWhenExpression() + if ok { + return extractResultRefs(expressions, pipelineRunState) + } + return nil, nil +} + // extractResultRefs resolves any ResultReference that are found in param or pipeline result // Returns nil if none are found func extractResultRefsForPipelineResult(pipelineStatus v1beta1.PipelineRunStatus, result v1beta1.PipelineResult) (ResolvedResultRefs, error) { @@ -139,24 +147,30 @@ func removeDup(refs ResolvedResultRefs) ResolvedResultRefs { return deduped } -// convertParamsToResultRefs converts all params of the resolved pipeline run task -func convertParamsToResultRefs(pipelineRunState PipelineRunState, target *ResolvedPipelineRunTask) (ResolvedResultRefs, error) { - var resolvedParams ResolvedResultRefs +// convertToResultRefs converts all params and when expressions of the resolved pipeline run task +func convertToResultRefs(pipelineRunState PipelineRunState, target *ResolvedPipelineRunTask) (ResolvedResultRefs, error) { + var resolvedResultRefs ResolvedResultRefs for _, condition := range target.PipelineTask.Conditions { condRefs, err := convertParams(condition.Params, pipelineRunState, condition.ConditionRef) if err != nil { return nil, err } - resolvedParams = append(resolvedParams, condRefs...) + resolvedResultRefs = append(resolvedResultRefs, condRefs...) } taskParamsRefs, err := convertParams(target.PipelineTask.Params, pipelineRunState, target.PipelineTask.Name) if err != nil { return nil, err } - resolvedParams = append(resolvedParams, taskParamsRefs...) + resolvedResultRefs = append(resolvedResultRefs, taskParamsRefs...) - return resolvedParams, nil + taskWhenExpressionsRefs, err := convertWhenExpressions(target.PipelineTask.WhenExpressions, pipelineRunState, target.PipelineTask.Name) + if err != nil { + return nil, err + } + resolvedResultRefs = append(resolvedResultRefs, taskWhenExpressionsRefs...) + + return resolvedResultRefs, nil } func convertParams(params []v1beta1.Param, pipelineRunState PipelineRunState, name string) (ResolvedResultRefs, error) { @@ -173,6 +187,21 @@ func convertParams(params []v1beta1.Param, pipelineRunState PipelineRunState, na return resolvedParams, nil } +// convertWhenExpressions converts all When Expressions of the resolved pipeline run task +func convertWhenExpressions(whenExpressions []v1beta1.WhenExpression, pipelineRunState PipelineRunState, name string) (ResolvedResultRefs, error) { + var resolvedWhenExpressions ResolvedResultRefs + for _, whenExpression := range whenExpressions { + resolvedResultRefs, err := extractResultRefsForWhenExpression(pipelineRunState, whenExpression) + if err != nil { + return nil, fmt.Errorf("unable to find result referenced by when expression with input %q in task %q: %w", whenExpression.Input, name, err) + } + if resolvedResultRefs != nil { + resolvedWhenExpressions = append(resolvedWhenExpressions, resolvedResultRefs...) + } + } + return resolvedWhenExpressions, nil +} + // convertPipelineResultToResultRefs converts all params of the resolved pipeline run task func convertPipelineResultToResultRefs(pipelineStatus v1beta1.PipelineRunStatus, pipelineResult v1beta1.PipelineResult) ResolvedResultRefs { resolvedResultRefs, err := extractResultRefsForPipelineResult(pipelineStatus, pipelineResult) diff --git a/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go b/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go index 518379d9962..c5edc3cec01 100644 --- a/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go +++ b/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go @@ -11,6 +11,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "github.com/tektoncd/pipeline/test/diff" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/selection" "knative.dev/pkg/apis" duckv1beta1 "knative.dev/pkg/apis/duck/v1beta1" ) @@ -294,6 +295,263 @@ func resolvedSliceAsString(rs []*ResolvedResultRef) string { return fmt.Sprintf("[\n%s\n]", strings.Join(s, ",\n")) } +func TestTaskWhenExpressionsResolver_ResolveResultRefs(t *testing.T) { + type fields struct { + pipelineRunState PipelineRunState + } + type args struct { + we v1beta1.WhenExpression + } + tests := []struct { + name string + fields fields + args args + want ResolvedResultRefs + wantErr bool + }{ + { + name: "successful resolution: when expression not using result reference", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + TaskRunName: "aTaskRun", + TaskRun: tb.TaskRun("aTaskRun"), + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "explicitValueNoResultReference", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: nil, + wantErr: false, + }, + { + name: "successful resolution: using result reference", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + TaskRunName: "aTaskRun", + TaskRun: tb.TaskRun("aTaskRun", tb.TaskRunStatus( + tb.TaskRunResult("aResult", "aResultValue"), + )), + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, + }, + wantErr: false, + }, { + name: "successful resolution: using multiple result reference", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + TaskRunName: "aTaskRun", + TaskRun: tb.TaskRun("aTaskRun", tb.TaskRunStatus( + tb.TaskRunResult("aResult", "aResultValue"), + )), + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, { + TaskRunName: "bTaskRun", + TaskRun: tb.TaskRun("bTaskRun", tb.TaskRunStatus( + tb.TaskRunResult("bResult", "bResultValue"), + )), + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult) $(tasks.bTask.results.bResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "bResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "bTask", + Result: "bResult", + }, + FromTaskRun: "bTaskRun", + }, + }, + wantErr: false, + }, { + name: "successful resolution: duplicate result references", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + TaskRunName: "aTaskRun", + TaskRun: tb.TaskRun("aTaskRun", tb.TaskRunStatus( + tb.TaskRunResult("aResult", "aResultValue"), + )), + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult) $(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, + }, + wantErr: false, + }, { + name: "unsuccessful resolution: referenced result doesn't exist in referenced task", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + TaskRunName: "aTaskRun", + TaskRun: tb.TaskRun("aTaskRun"), + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: nil, + wantErr: true, + }, { + name: "unsuccessful resolution: pipeline task missing", + fields: fields{ + pipelineRunState: PipelineRunState{}, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: nil, + wantErr: true, + }, { + name: "unsuccessful resolution: task run missing", + fields: fields{ + pipelineRunState: PipelineRunState{ + { + PipelineTask: &v1beta1.PipelineTask{ + Name: "aTask", + TaskRef: &v1beta1.TaskRef{Name: "aTask"}, + }, + }, + }, + }, + args: args{ + we: v1beta1.WhenExpression{ + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"bar"}, + }, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("test name: %s\n", tt.name) + got, err := extractResultRefsForWhenExpression(tt.fields.pipelineRunState, tt.args.we) + // sort result ref based on task name to guarantee an certain order + sort.SliceStable(got, func(i, j int) bool { + return strings.Compare(got[i].FromTaskRun, got[j].FromTaskRun) < 0 + }) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveResultRef() error = %v, wantErr %v", err, tt.wantErr) + } + if len(tt.want) != len(got) { + t.Fatalf("incorrect number of refs, want %d, got %d", len(tt.want), len(got)) + } + for _, rGot := range got { + foundMatch := false + for _, rWant := range tt.want { + if d := cmp.Diff(rGot, rWant); d == "" { + foundMatch = true + } + } + if !foundMatch { + t.Fatalf("Expected resolved refs:\n%s\n\nbut received:\n%s\n", resolvedSliceAsString(tt.want), resolvedSliceAsString(got)) + } + } + }) + } +} + func TestResolveResultRefs(t *testing.T) { type args struct { pipelineRunState PipelineRunState @@ -323,6 +581,44 @@ func TestResolveResultRefs(t *testing.T) { }, }, }, + }, { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "$(tasks.aTask.results.aResult)", + Operator: selection.In, + Values: []string{"$(tasks.aTask.results.aResult)"}, + }, + }, + }, + }, { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "$(tasks.aTask.results.missingResult)", + Operator: selection.In, + Values: []string{"$(tasks.aTask.results.missingResult)"}, + }, + }, + }, + }, { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + Params: []v1beta1.Param{ + { + Name: "bParam", + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "$(tasks.aTask.results.missingResult)", + }, + }, + }, + }, }, } @@ -333,7 +629,7 @@ func TestResolveResultRefs(t *testing.T) { wantErr bool }{ { - name: "Test successful result references resolution", + name: "Test successful result references resolution - params", args: args{ pipelineRunState: pipelineRunState, targets: PipelineRunState{ @@ -354,8 +650,29 @@ func TestResolveResultRefs(t *testing.T) { }, }, wantErr: false, - }, - { + }, { + name: "Test successful result references resolution - when expressions", + args: args{ + pipelineRunState: pipelineRunState, + targets: PipelineRunState{ + pipelineRunState[2], + }, + }, + want: ResolvedResultRefs{ + { + Value: v1beta1.ArrayOrString{ + Type: v1beta1.ParamTypeString, + StringVal: "aResultValue", + }, + ResultReference: v1beta1.ResultRef{ + PipelineTask: "aTask", + Result: "aResult", + }, + FromTaskRun: "aTaskRun", + }, + }, + wantErr: false, + }, { name: "Test successful result references resolution non result references", args: args{ pipelineRunState: pipelineRunState, @@ -365,6 +682,26 @@ func TestResolveResultRefs(t *testing.T) { }, want: nil, wantErr: false, + }, { + name: "Test unsuccessful result references resolution - when expression", + args: args{ + pipelineRunState: pipelineRunState, + targets: PipelineRunState{ + pipelineRunState[3], + }, + }, + want: nil, + wantErr: true, + }, { + name: "Test unsuccessful result references resolution - params", + args: args{ + pipelineRunState: pipelineRunState, + targets: PipelineRunState{ + pipelineRunState[4], + }, + }, + want: nil, + wantErr: true, }, } for _, tt := range tests {