diff --git a/docs/pipelines.md b/docs/pipelines.md index d4b32bce308..562acd27c3f 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,6 +317,47 @@ 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`. + +The components of `WhenExpressions` are `Input`, `Operator` and `Values`: + +- `Input` is the input for the `Guard` checking which can be static inputs or variables (`Parameters` or `Results`). +- `Values` is an array of string values. The `Values` array must be non-empty. It can contain static values +or variables (`Parameters` or `Results`). +- `Operator` represents an `Input`'s relationship to a set of `Values`. `Operators` we will use in `Guards` +are `In` and `NotIn`. + +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. + +When `WhenExpressions` are specified in a `Task`, `Conditions` should not be speficied in the same `Task`. + +```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). + ### Guard `Task` execution using `Conditions` To run a `Task` only when certain conditions are met, it is possible to _guard_ task execution using 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..27241a54a04 --- /dev/null +++ b/examples/v1beta1/pipelineruns/pipelinerun-with-when-expressions.yaml @@ -0,0 +1,123 @@ +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: create-readme-file +spec: + resources: + outputs: + - name: workspace + type: git + steps: + - name: write-new-stuff + image: ubuntu + script: 'touch $(resources.outputs.workspace.path)/README.md' +--- +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: check-file +spec: + params: + - name: path + resources: + inputs: + - name: workspace + type: git + results: + - name: status + description: indicating whether the file exists + steps: + - name: check-file + image: alpine + script: | + if test -f $(resources.inputs.workspace.path)/$(params.path); then + printf exists | tee /tekton/results/status + else + printf missing | tee /tekton/results/status + fi +--- +apiVersion: tekton.dev/v1alpha1 +kind: PipelineResource +metadata: + name: pipeline-git +spec: + type: git + params: + - name: revision + value: master + - name: url + value: https://github.com/tektoncd/pipeline +--- +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: echo-file-status +spec: + params: + - name: status + steps: + - name: echo + image: ubuntu + script: 'echo file $(params.status)' +--- +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: guarded-pipeline +spec: + resources: + - name: source-repo + type: git + params: + - name: path + default: "README.md" + tasks: + - name: first-create-file + when: + - input: "$(params.path)" + operator: in + values: ["README.md"] + taskRef: + name: create-readme-file + resources: + outputs: + - name: workspace + resource: source-repo + - name: check-file + when: + - input: "foo" + operator: in + values: ["foo", "bar"] + taskRef: + name: check-file + params: + - name: path + value: "$(params.path)" + resources: + inputs: + - name: workspace + resource: source-repo + from: [first-create-file] + - name: echo-file-exists + params: + - name: status + value: "$(tasks.check-file.results.status)" + when: + - input: "$(tasks.check-file.results.status)" + operator: in + values: ["exists"] + taskRef: + name: echo-file-status +--- +apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: guarded-pr +spec: + pipelineRef: + name: guarded-pipeline + serviceAccountName: 'default' + resources: + - name: source-repo + resourceRef: + name: pipeline-git diff --git a/internal/builder/v1beta1/pipeline.go b/internal/builder/v1beta1/pipeline.go index 54f0a1ca50c..7a435549480 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,8 @@ type PipelineRunStatusOp func(*v1beta1.PipelineRunStatus) // PipelineTaskConditionOp is an operation which modifies a PipelineTaskCondition type PipelineTaskConditionOp func(condition *v1beta1.PipelineTaskCondition) +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 { @@ -319,6 +322,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 c2637f247bf..683c38da525 100644 --- a/internal/builder/v1beta1/pipeline_test.go +++ b/internal/builder/v1beta1/pipeline_test.go @@ -54,6 +54,7 @@ func TestPipeline(t *testing.T) { tb.PipelineTaskOutputResource("some-image", "my-only-image-resource"), ), tb.PipelineTask("never-gonna", "give-you-up", + tb.PipelineTaskWhenExpression("foo", "in", []string{"foo", "bar"}), tb.RunAfter("foo"), tb.PipelineTaskTimeout(5*time.Second), ), @@ -131,10 +132,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: "in", Values: []string{"foo", "bar"}}}, + RunAfter: []string{"foo"}, + Timeout: &metav1.Duration{Duration: 5 * time.Second}, }, { Name: "foo", TaskSpec: &v1beta1.TaskSpec{ diff --git a/pkg/apis/pipeline/v1beta1/pipeline_types.go b/pkg/apis/pipeline/v1beta1/pipeline_types.go index 74cfe3dc5fe..e513869e327 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_types.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_types.go @@ -19,6 +19,7 @@ package v1beta1 import ( "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" ) // +genclient @@ -110,6 +111,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 []WhenExpression `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"` @@ -176,6 +181,16 @@ func (pt PipelineTask) Deps() []string { } } } + // Add any dependents from when expressions + for _, whenExpression := range pt.WhenExpressions { + expressions, ok := GetVarSubstitutionExpressionsForWhenExpression(whenExpression) + if ok { + resultRefs := NewResultRefs(expressions) + for _, resultRef := range resultRefs { + deps = append(deps, resultRef.PipelineTask) + } + } + } return deps } @@ -189,6 +204,16 @@ func (l PipelineTaskList) Items() []dag.Task { return tasks } +// ApplyReplacements applies replacements for variables in When Expressions in a Task. +func (whenExpression *WhenExpression) ApplyReplacements(stringReplacements map[string]string) { + whenExpression.Input = ApplyReplacements(whenExpression.Input, stringReplacements) + var newValues []string + for _, val := range whenExpression.Values { + newValues = append(newValues, ApplyReplacements(val, stringReplacements)) + } + whenExpression.Values = newValues +} + // PipelineTaskParam is used to provide arbitrary string parameters to a Task. type PipelineTaskParam struct { Name string `json:"name"` @@ -209,6 +234,18 @@ type PipelineTaskCondition struct { Resources []PipelineTaskInputResource `json:"resources,omitempty"` } +// 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"` +} + // PipelineDeclaredResource is used by a Pipeline to declare the types of the // PipelineResources that it will required to run and names which can be used to // refer to these PipelineResources in PipelineTaskResourceBindings. diff --git a/pkg/apis/pipeline/v1beta1/pipeline_validation.go b/pkg/apis/pipeline/v1beta1/pipeline_validation.go index a82544ec1cc..52d08e7fbb1 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_validation.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_validation.go @@ -26,6 +26,7 @@ import ( "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" "github.com/tektoncd/pipeline/pkg/substitution" "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation" "knative.dev/pkg/apis" @@ -181,6 +182,10 @@ func (ps *PipelineSpec) Validate(ctx context.Context) *apis.FieldError { return apis.ErrInvalidValue(err.Error(), "spec.tasks.params.value") } + if err := validateWhenExpressionsReferencesToTaskResults(ps.Tasks); err != nil { + return apis.ErrInvalidValue(err.Error(), "spec.tasks.when") + } + // The parameter variables should be valid if err := validatePipelineParameterVariables(ps.Tasks, ps.Params); err != nil { return err @@ -194,6 +199,10 @@ func (ps *PipelineSpec) Validate(ctx context.Context) *apis.FieldError { return err } + if err := validateWhenExpressions(ps.Tasks); err != nil { + return err + } + // Validate the pipeline's workspaces. if err := validatePipelineWorkspaces(ps.Workspaces, ps.Tasks, ps.Finally); err != nil { return err @@ -446,6 +455,25 @@ func validateParamResults(tasks []PipelineTask) error { return nil } +// validateWhenExpressionsReferencesToTaskResults ensures that task result variables are properly configured +func validateWhenExpressionsReferencesToTaskResults(tasks []PipelineTask) error { + for _, task := range tasks { + for _, we := range task.WhenExpressions { + expressions, ok := GetVarSubstitutionExpressionsForWhenExpression(we) + if ok { + if LooksLikeContainsResultRefs(expressions) { + expressions = filter(expressions, looksLikeResultRef) + resultRefs := NewResultRefs(expressions) + if len(expressions) != len(resultRefs) { + return fmt.Errorf("expected all of the expressions %v to be result expressions but only %v were", expressions, resultRefs) + } + } + } + } + } + return nil +} + func filter(arr []string, cond func(string) bool) []string { result := []string{} for i := range arr { @@ -488,6 +516,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 +562,23 @@ func validateTasksInputFrom(tasks []PipelineTask) *apis.FieldError { } return nil } + +func validateWhenExpressions(tasks []PipelineTask) *apis.FieldError { + for i, t := range tasks { + // can't have both WheExpressions and Conditions at the same time + 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))) + } + + for _, whenExpression := range t.WhenExpressions { + if whenExpression.Operator != selection.In && whenExpression.Operator != selection.NotIn { + return apis.ErrInvalidValue(fmt.Sprintf("operator '%v' is not recognized", whenExpression.Operator), "spec.task.when") + } + if len(whenExpression.Values) == 0 { + return apis.ErrInvalidValue("expecting non-empty values field", "spec.task.when") + } + } + } + return nil +} diff --git a/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go b/pkg/apis/pipeline/v1beta1/pipeline_validation_test.go index c8b4c92f1df..0a9cdf88545 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) { @@ -701,6 +702,53 @@ func TestValidateParamResults_Failure(t *testing.T) { }) } +func TestValidateWhenExpressionsResults_Success(t *testing.T) { + desc := "valid pipeline task referencing task result along with parameter variable" + tasks := []PipelineTask{{ + Name: "a-task", + TaskSpec: &TaskSpec{ + Results: []TaskResult{{ + Name: "output", + }}, + Steps: []Step{{ + Container: corev1.Container{Name: "foo", Image: "bar"}, + }}, + }, + }, { + Name: "foo", + TaskRef: &TaskRef{Name: "foo-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.results.output)", Operator: "in", Values: []string{"bar"}, + }}, + }} + t.Run(desc, func(t *testing.T) { + err := validateWhenExpressionsReferencesToTaskResults(tasks) + if err != nil { + t.Errorf("Pipeline.validateWhenExpressionsResults() returned error for valid pipeline: %s: %v", desc, err) + } + }) +} + +func TestValidateWhenExpressionsResults_Failure(t *testing.T) { + desc := "invalid pipeline task referencing task results with malformed variable substitution expression" + tasks := []PipelineTask{{ + Name: "a-task", + TaskRef: &TaskRef{Name: "a-task"}, + }, { + Name: "bar", + TaskRef: &TaskRef{Name: "bar-task"}, + WhenExpressions: []WhenExpression{{ + Input: "$(tasks.a-task.resultTypo.bResult)", Operator: "in", Values: []string{"bar"}, + }}, + }} + t.Run(desc, func(t *testing.T) { + err := validateWhenExpressionsReferencesToTaskResults(tasks) + if err == nil { + t.Errorf("Pipeline.validateWhenExpressionsResults() did not return error for invalid pipeline: %s", desc) + } + }) +} + func TestValidatePipelineResults_Success(t *testing.T) { desc := "valid pipeline with valid pipeline results syntax" results := []PipelineResult{{ @@ -1381,6 +1429,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: "in", + Values: []string{"foo", "bar"}, + }}, + }}, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1491,3 +1550,89 @@ func TestContextInvalid(t *testing.T) { }) } } + +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"}, + }}, + }}, + }} + 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: "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", + }}, + }}, + }} + 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..893497701e4 100644 --- a/pkg/apis/pipeline/v1beta1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1beta1/pipelinerun_types.go @@ -352,6 +352,17 @@ 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 []*WhenExpressionStatus `json:"whenExpressionsStatus,omitempty"` +} + +// WhenExpressionStatus is the observed state of When Expressions +type WhenExpressionStatus struct { + Input string `json:"input,omitempty"` + Operator string `json:"operator,omitempty"` + Values []string `json:"values,omitempty"` + Result string `json:"whenResult,omitempty"` } // PipelineRunConditionCheckStatus returns the condition check status diff --git a/pkg/apis/pipeline/v1beta1/resultref.go b/pkg/apis/pipeline/v1beta1/resultref.go index 6e25baceb87..85bcc2f867b 100644 --- a/pkg/apis/pipeline/v1beta1/resultref.go +++ b/pkg/apis/pipeline/v1beta1/resultref.go @@ -106,6 +106,16 @@ func GetVarSubstitutionExpressionsForPipelineResult(result PipelineResult) ([]st return allExpressions, len(allExpressions) != 0 } +// GetVarSubstitutionExpressionsForWhenExpression extracts all the values between "$(" and ")"" for a when expression +func GetVarSubstitutionExpressionsForWhenExpression(whenExpression WhenExpression) ([]string, bool) { + var allExpressions []string + allExpressions = append(allExpressions, validateString(whenExpression.Input)...) + for _, value := range whenExpression.Values { + allExpressions = append(allExpressions, validateString(value)...) + } + return allExpressions, len(allExpressions) != 0 +} + func validateString(value string) []string { expressions := variableSubstitutionRegex.FindAllString(value, -1) if expressions == nil { diff --git a/pkg/apis/pipeline/v1beta1/resultref_test.go b/pkg/apis/pipeline/v1beta1/resultref_test.go index a01a3b1f6b4..d7ebfe885f6 100644 --- a/pkg/apis/pipeline/v1beta1/resultref_test.go +++ b/pkg/apis/pipeline/v1beta1/resultref_test.go @@ -433,3 +433,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: "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: "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: "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: "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: "in", + Values: []string{"foo"}, + }, + }, + want: nil, + }, { + name: "third separator typo", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(tasks.sumTasks.result.sumResult)", + Operator: "in", + Values: []string{"foo"}, + }, + }, + want: nil, + }, { + name: "param substitution shouldn't be considered result ref", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(params.paramName)", + Operator: "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: "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 := v1beta1.GetVarSubstitutionExpressionsForWhenExpression(tt.args.whenExpression) + 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: "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: "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: "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: "in", + Values: []string{"sum-result"}, + }, + }, + wantRef: nil, + }, { + name: "Test valid expression in array", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$sumResult", + Operator: "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 := v1beta1.GetVarSubstitutionExpressionsForWhenExpression(tt.args.whenExpression) + 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: "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: "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: "in", + Values: []string{"foo"}, + }, + }, + want: true, + }, { + name: "test expression: missing 'task' separator", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "$(sumTasks.results.sumResult)", + Operator: "in", + Values: []string{"foo"}, + }, + }, + want: false, + }, { + name: "test expression: missing variable substitution", + args: args{ + whenExpression: v1beta1.WhenExpression{ + Input: "tasks.sumTasks.results.sumResult", + Operator: "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: "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: "in", + Values: []string{"foo"}, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expressions, ok := v1beta1.GetVarSubstitutionExpressionsForWhenExpression(tt.args.whenExpression) + 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/zz_generated.deepcopy.go b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go index cc27813f798..a0f6dea98fd 100644 --- a/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go @@ -740,6 +740,17 @@ func (in *PipelineRunTaskRunStatus) DeepCopyInto(out *PipelineRunTaskRunStatus) (*out)[key] = outVal } } + if in.WhenExpressionsStatus != nil { + in, out := &in.WhenExpressionsStatus, &out.WhenExpressionsStatus + *out = make([]*WhenExpressionStatus, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(WhenExpressionStatus) + (*in).DeepCopyInto(*out) + } + } + } return } @@ -825,6 +836,13 @@ func (in *PipelineTask) DeepCopyInto(out *PipelineTask) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.WhenExpressions != nil { + in, out := &in.WhenExpressions, &out.WhenExpressions + *out = make([]WhenExpression, 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)) @@ -1656,6 +1674,48 @@ 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 *WhenExpressionStatus) DeepCopyInto(out *WhenExpressionStatus) { + *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 WhenExpressionStatus. +func (in *WhenExpressionStatus) DeepCopy() *WhenExpressionStatus { + if in == nil { + return nil + } + out := new(WhenExpressionStatus) + 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/expression/expression.go b/pkg/expression/expression.go new file mode 100644 index 00000000000..aff3c1acdbe --- /dev/null +++ b/pkg/expression/expression.go @@ -0,0 +1,128 @@ +/* +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 expression + +import ( + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/sets" +) + +// Expression is used to specify a relationship between an input and some values. +type Expression struct { + input string + operator selection.Operator + values []string +} + +// Input is the input of the Expression. +func (expression *Expression) Input() string { + return expression.input +} + +// Operator represents an Input's relationship to a set of Values. +func (expression *Expression) Operator() selection.Operator { + return expression.operator +} + +// Values is an array of string values. +func (expression *Expression) Values() sets.String { + vals := sets.String{} + for i := range expression.values { + vals.Insert(expression.values[i]) + } + return vals +} + +// NewExpression creates a new Expression containing input, operator and values. +func NewExpression(input string, op selection.Operator, vals []string) *Expression { + return &Expression{input: input, operator: op, values: vals} +} + +func (expression *Expression) valuesContainsInput() bool { + for i := range expression.values { + if expression.values[i] == expression.input { + return true + } + } + return false +} + +func (expression *Expression) isTrue() bool { + if expression.operator == selection.In { + return expression.valuesContainsInput() + } + // selection.NotIn + return !expression.valuesContainsInput() +} + +// Expressions is an array of expressions made up of input, operator and values. +type Expressions []*Expression + +// Evaluate determines whether the input's relationship (as defined by the operator) with the values is true. +func (expressions Expressions) Evaluate() EvaluationResults { + var results []*EvaluationResult + for _, expression := range expressions { + isTrue := expression.isTrue() + results = append(results, &EvaluationResult{expression: expression, isTrue: isTrue}) + if !isTrue { // AND all the expressions + return EvaluationResults{results: results, isTrue: false} + } + } + return EvaluationResults{results: results, isTrue: true} +} + +// EvaluationResult contains the expression and a boolean indicating whether the expression is true. +type EvaluationResult struct { + expression *Expression + isTrue bool +} + +// Expression returns the expression that produced the Result. +func (evaluationResult *EvaluationResult) Expression() *Expression { + return evaluationResult.expression +} + +// IsTrue indicates whether the input's relationship (as defined by the operator) with the values is true. +func (evaluationResult *EvaluationResult) IsTrue() bool { + return evaluationResult.isTrue +} + +// EvaluationResults contains an array of results from evaluating expressions, and a boolean indicating whether all the expressions are true. +type EvaluationResults struct { + results []*EvaluationResult + isTrue bool +} + +// Results returns the array of results from evaluating the expressions. +func (evaluationResults *EvaluationResults) Results() []*EvaluationResult { + return evaluationResults.results +} + +// IsTrue indicates whether all the results indicate that the expressions are true (AND of all the expression results). +func (evaluationResults *EvaluationResults) IsTrue() bool { + return evaluationResults.isTrue +} + +// NewEvaluationResult is used to specify a Result, used for testing. +func NewEvaluationResult(expression *Expression, isTrue bool) *EvaluationResult { + return &EvaluationResult{expression: expression, isTrue: isTrue} +} + +// NewEvaluationResults is used to specify Results, used for testing. +func NewEvaluationResults(er []*EvaluationResult, isTrue bool) *EvaluationResults { + return &EvaluationResults{results: er, isTrue: isTrue} +} diff --git a/pkg/expression/expression_test.go b/pkg/expression/expression_test.go new file mode 100644 index 00000000000..3f2a787effe --- /dev/null +++ b/pkg/expression/expression_test.go @@ -0,0 +1,230 @@ +/* +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 expression + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/test/diff" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestGetters(t *testing.T) { + tests := []struct { + name string + expression *Expression + input string + operator selection.Operator + values sets.String + }{{ + name: "in-single-value", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz"}}, + input: "baz", + operator: selection.In, + values: sets.String{}.Insert("baz"), + }, { + name: "in-multiple-values", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz", "norf"}}, + input: "baz", + operator: selection.In, + values: sets.String{}.Insert("baz", "norf"), + }, { + name: "not-in-multiple-values", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz", "norf"}}, + input: "baz", + operator: selection.NotIn, + values: sets.String{}.Insert("baz", "norf"), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if d := cmp.Diff(tt.input, tt.expression.Input()); d != "" { + t.Errorf("expected input not found, diff: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(tt.operator, tt.expression.Operator()); d != "" { + t.Errorf("expected operator not found, diff: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(tt.values, tt.expression.Values()); d != "" { + t.Errorf("expected values not found, diff: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestSetters(t *testing.T) { + tests := []struct { + name string + expression *Expression + input string + operator selection.Operator + values sets.String + }{{ + name: "in-single-value", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz"}}, + input: "baz", + operator: selection.In, + values: sets.String{}.Insert("baz"), + }, { + name: "in-multiple-values", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz", "norf"}}, + input: "baz", + operator: selection.In, + values: sets.String{}.Insert("baz", "norf"), + }, { + name: "not-in-multiple-values", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz", "norf"}}, + input: "baz", + operator: selection.NotIn, + values: sets.String{}.Insert("baz", "norf"), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if d := cmp.Diff(tt.input, tt.expression.Input()); d != "" { + t.Errorf("expected input not found, diff: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(tt.operator, tt.expression.Operator()); d != "" { + t.Errorf("expected operator not found, diff: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(tt.values, tt.expression.Values()); d != "" { + t.Errorf("expected values not found, diff: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestValuesContainsInput(t *testing.T) { + tests := []struct { + name string + expression *Expression + want bool + }{{ + name: "in-single-value-true", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz"}}, + want: true, + }, { + name: "in-multiple-values-true", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz", "norf"}}, + want: true, + }, { + name: "not-in-single-value-true", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz"}}, + want: true, + }, { + name: "not-in-multiple-values-true", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz", "norf"}}, + want: true, + }, { + name: "in-single-value-false", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"qux"}}, + want: false, + }, { + name: "in-multiple-values-false", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"", "qux"}}, + want: false, + }, { + name: "not-in-single-value-false", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"qux"}}, + want: false, + }, { + name: "not-in-multiple-values-false", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"", "qux"}}, + want: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if d := cmp.Diff(tt.want, tt.expression.valuesContainsInput()); d != "" { + t.Errorf("expected result not found, diff: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestExpressionIsTrue(t *testing.T) { + tests := []struct { + name string + expression *Expression + want bool + }{{ + name: "input-in-values-true", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"baz"}}, + want: true, + }, { + name: "input-in-values-false", + expression: &Expression{input: "baz", operator: selection.In, values: []string{"norf"}}, + want: false, + }, { + name: "input-not-in-values-true", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"norf"}}, + want: true, + }, { + name: "input-not-in-values-false", + expression: &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz"}}, + want: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if d := cmp.Diff(tt.want, tt.expression.isTrue()); d != "" { + t.Errorf("expected expression result not found, diff: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestEvaluateExpressions(t *testing.T) { + tests := []struct { + name string + expressions Expressions + want bool + }{{ + name: "input-in-values-true", + expressions: Expressions{ + &Expression{input: "baz", operator: selection.In, values: []string{"baz"}}, + &Expression{input: "norf", operator: selection.In, values: []string{"norf"}}, + }, + want: true, + }, { + name: "input-in-values-false", + expressions: Expressions{ + &Expression{input: "baz", operator: selection.In, values: []string{"norf"}}, + &Expression{input: "norf", operator: selection.In, values: []string{"baz"}}, + }, + want: false, + }, { + name: "input-not-in-values-true", + expressions: Expressions{ + &Expression{input: "baz", operator: selection.NotIn, values: []string{"norf"}}, + &Expression{input: "norf", operator: selection.NotIn, values: []string{"baz"}}, + }, + want: true, + }, { + name: "input-not-in-values-false", + expressions: Expressions{ + &Expression{input: "baz", operator: selection.NotIn, values: []string{"baz"}}, + &Expression{input: "norf", operator: selection.NotIn, values: []string{"norf"}}, + }, + want: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wers := tt.expressions.Evaluate() + if d := cmp.Diff(tt.want, wers.IsTrue()); d != "" { + t.Errorf("expected result from evaluating expressions not found, diff: %s", diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index 3fa02f0d4b9..b3ab42fc491 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -38,6 +38,7 @@ import ( listers "github.com/tektoncd/pipeline/pkg/client/listers/pipeline/v1beta1" resourcelisters "github.com/tektoncd/pipeline/pkg/client/resource/listers/resource/v1alpha1" "github.com/tektoncd/pipeline/pkg/contexts" + expressions "github.com/tektoncd/pipeline/pkg/expression" "github.com/tektoncd/pipeline/pkg/reconciler/events" "github.com/tektoncd/pipeline/pkg/reconciler/events/cloudevent" "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" @@ -514,7 +515,7 @@ func (c *Reconciler) runNextSchedulableTask(ctx context.Context, pr *v1beta1.Pip resolvedResultRefs, err := resources.ResolveResultRefs(pipelineState, nextRprts) if err != nil { - logger.Infof("Failed to resolve all task params for %q with error %v", pr.Name, err) + logger.Infof("Failed to resolve all task params and when expressions for %q with error %v", pr.Name, err) pr.Status.MarkFailed(ReasonFailedValidation, err.Error()) return controller.NewPermanentError(err) } @@ -524,7 +525,7 @@ func (c *Reconciler) runNextSchedulableTask(ctx context.Context, pr *v1beta1.Pip if rprt == nil { continue } - if rprt.ResolvedConditionChecks == nil || rprt.ResolvedConditionChecks.IsSuccess() { + if rprt.ShouldCreateTaskRun() { rprt.TaskRun, err = c.createTaskRun(ctx, rprt, pr, as.StorageBasePath(pr)) if err != nil { recorder.Eventf(pr, corev1.EventTypeWarning, "TaskRunCreationFailed", "Failed to create TaskRun %q: %v", rprt.TaskRunName, err) @@ -608,11 +609,29 @@ func getTaskRunsStatus(pr *v1beta1.PipelineRun, state []*resources.ResolvedPipel }) } } + + if rprt.WhenExpressionsResult != nil { + prtrs.WhenExpressionsStatus = getWhenExpressionsStatus(rprt.WhenExpressionsResult) + } + status[rprt.TaskRunName] = prtrs } return status } +func getWhenExpressionsStatus(whenExpressionsEvaluationResults *expressions.EvaluationResults) []*v1beta1.WhenExpressionStatus { + var whenExpressionsStatus []*v1beta1.WhenExpressionStatus + for _, whenExpressionEvaluationResult := range whenExpressionsEvaluationResults.Results() { + whenExpressionsStatus = append(whenExpressionsStatus, &v1beta1.WhenExpressionStatus{ + Input: whenExpressionEvaluationResult.Expression().Input(), + Operator: string(whenExpressionEvaluationResult.Expression().Operator()), + Values: whenExpressionEvaluationResult.Expression().Values().List(), + Result: fmt.Sprintf("%t", whenExpressionsEvaluationResults.IsTrue()), + }) + } + return whenExpressionsStatus +} + func (c *Reconciler) updateTaskRunsStatusDirectly(pr *v1beta1.PipelineRun) error { for taskRunName := range pr.Status.TaskRuns { // TODO(dibyom): Add conditionCheck statuses here diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index 46df130bf2b..98b3d6d2ecb 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -33,6 +33,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" resourcev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/resource/v1alpha1" + expressions "github.com/tektoncd/pipeline/pkg/expression" "github.com/tektoncd/pipeline/pkg/reconciler/events/cloudevent" "github.com/tektoncd/pipeline/pkg/reconciler/pipelinerun/resources" taskrunresources "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" @@ -47,6 +48,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" @@ -784,6 +786,11 @@ func TestUpdateTaskRunsState(t *testing.T) { pipelineTask := v1beta1.PipelineTask{ Name: "unit-test-1", TaskRef: &v1beta1.TaskRef{Name: "unit-test-task"}, + WhenExpressions: []v1beta1.WhenExpression{{ + Input: "foo", + Operator: selection.In, + Values: []string{"foo", "bar"}, + }}, } task := tb.Task("unit-test-task", tb.TaskSpec( tb.TaskResources(tb.TaskResourcesInput("workspace", resourcev1alpha1.PipelineResourceTypeGit)), @@ -798,6 +805,13 @@ func TestUpdateTaskRunsState(t *testing.T) { tb.StepState(tb.StateTerminated(0)), )) + expectedWhenExpressionsStatus := v1beta1.WhenExpressionStatus{ + Input: "foo", + Operator: "in", + Values: []string{"bar", "foo"}, + Result: "true", + } + expectedTaskRunsStatus := make(map[string]*v1beta1.PipelineRunTaskRunStatus) expectedTaskRunsStatus["test-pipeline-run-success-unit-test-1"] = &v1beta1.PipelineRunTaskRunStatus{ PipelineTaskName: "unit-test-1", @@ -812,6 +826,9 @@ func TestUpdateTaskRunsState(t *testing.T) { Conditions: []apis.Condition{{Type: apis.ConditionSucceeded}}, }, }, + WhenExpressionsStatus: []*v1beta1.WhenExpressionStatus{ + &expectedWhenExpressionsStatus, + }, } expectedPipelineRunStatus := v1beta1.PipelineRunStatus{ PipelineRunStatusFields: v1beta1.PipelineRunStatusFields{ @@ -826,6 +843,15 @@ func TestUpdateTaskRunsState(t *testing.T) { ResolvedTaskResources: &taskrunresources.ResolvedTaskResources{ TaskSpec: &task.Spec, }, + WhenExpressionsResult: expressions.NewEvaluationResults( + []*expressions.EvaluationResult{ + expressions.NewEvaluationResult( + expressions.NewExpression("foo", selection.In, []string{"foo", "bar"}), + true, + ), + }, + true, + ), }} pr.Status.InitializeConditions() status := getTaskRunsStatus(pr, state) diff --git a/pkg/reconciler/pipelinerun/resources/apply.go b/pkg/reconciler/pipelinerun/resources/apply.go index dd12c092151..c09d9e943bb 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 = replaceWhenExpressionsVariables(pipelineTask.WhenExpressions, 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 = replaceWhenExpressionsVariables(tasks[i].WhenExpressions, replacements) } return p @@ -113,3 +115,10 @@ func replaceParamValues(params []v1beta1.Param, stringReplacements map[string]st } return params } + +func replaceWhenExpressionsVariables(whenExpressions []v1beta1.WhenExpression, stringReplacements map[string]string) []v1beta1.WhenExpression { + for i := range whenExpressions { + whenExpressions[i].ApplyReplacements(stringReplacements) + } + return whenExpressions +} diff --git a/pkg/reconciler/pipelinerun/resources/apply_test.go b/pkg/reconciler/pipelinerun/resources/apply_test.go index 3be1f59fa05..830396d0777 100644 --- a/pkg/reconciler/pipelinerun/resources/apply_test.go +++ b/pkg/reconciler/pipelinerun/resources/apply_test.go @@ -177,7 +177,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 +227,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: "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: "in", + Values: []string{"aResultValue"}, + }, + }, + }, + }, + }, }, } for _, tt := range tests { @@ -250,7 +297,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 +347,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: "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: "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 4c377879703..c36d190c5d0 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution.go @@ -22,19 +22,19 @@ import ( "reflect" "strconv" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/sets" - "knative.dev/pkg/apis" - "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" resourcev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/resource/v1alpha1" "github.com/tektoncd/pipeline/pkg/contexts" + expressions "github.com/tektoncd/pipeline/pkg/expression" "github.com/tektoncd/pipeline/pkg/list" "github.com/tektoncd/pipeline/pkg/names" "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/sets" + "knative.dev/pkg/apis" ) const ( @@ -72,6 +72,7 @@ type ResolvedPipelineRunTask struct { ResolvedTaskResources *resources.ResolvedTaskResources // ConditionChecks ~~TaskRuns but for evaling conditions ResolvedConditionChecks TaskConditionCheckState // Could also be a TaskRun or maybe just a Pod? + WhenExpressionsResult *expressions.EvaluationResults } // PipelineRunState is a slice of ResolvedPipelineRunTasks the represents the current execution @@ -159,6 +160,12 @@ func (t ResolvedPipelineRunTask) IsSkipped(state PipelineRunState, d *dag.Graph) } } + if t.WhenExpressionsResult != nil { + if !t.WhenExpressionsResult.IsTrue() { + return true + } + } + // Skip the PipelineTask if pipeline is in stopping state if isTaskInGraph(t.PipelineTask.Name, d) && state.IsStopping(d) { return true @@ -175,6 +182,23 @@ func (t ResolvedPipelineRunTask) IsSkipped(state PipelineRunState, d *dag.Graph) } } } + + return false +} + +// ShouldCreateTaskRun determines whether the ResolvedPipelineRunTask is ready to create a TaskRun. +func (t ResolvedPipelineRunTask) ShouldCreateTaskRun() bool { + if t.ResolvedConditionChecks == nil && len(t.PipelineTask.WhenExpressions) == 0 { + return true + } + if t.ResolvedConditionChecks != nil && t.ResolvedConditionChecks.IsSuccess() { + return true + } + if len(t.PipelineTask.WhenExpressions) > 0 { + if execute := t.evaluateWhenExpressions(); execute { + return true + } + } return false } @@ -488,6 +512,17 @@ func ResolvePipelineRun( return state, nil } +func (t ResolvedPipelineRunTask) evaluateWhenExpressions() bool { + var whenExpressions expressions.Expressions + for _, whenExpression := range t.PipelineTask.WhenExpressions { + newExpression := expressions.NewExpression(whenExpression.Input, whenExpression.Operator, whenExpression.Values) + whenExpressions = append(whenExpressions, newExpression) + } + results := whenExpressions.Evaluate() + t.WhenExpressionsResult = &results + return results.IsTrue() +} + // getConditionCheckName should return a unique name for a `ConditionCheck` if one has not already been defined, and the existing one otherwise. func getConditionCheckName(taskRunStatus map[string]*v1beta1.PipelineRunTaskRunStatus, trName, conditionRegisterName string) string { trStatus, ok := taskRunStatus[trName] diff --git a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go index a1b4dbc0fea..2aac19ff081 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelinerunresolution_test.go @@ -30,6 +30,8 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" resourcev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/resource/v1alpha1" + "github.com/tektoncd/pipeline/pkg/expression" + expressions "github.com/tektoncd/pipeline/pkg/expression" "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" "github.com/tektoncd/pipeline/test/diff" @@ -38,6 +40,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 +81,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{ @@ -924,6 +943,48 @@ func TestIsSkipped(t *testing.T) { ResolvedConditionChecks: failedTaskConditionCheckState, }}, 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, + }, + WhenExpressionsResult: expressions.NewEvaluationResults( + []*expression.EvaluationResult{ + expressions.NewEvaluationResult( + expressions.NewExpression("foo", selection.In, []string{"foo", "bar"}), + true, + ), + }, + true, + ), + }}, + expected: false, + }, { + name: "tasks-when-expressions-failed", + taskName: "mytask11", + state: PipelineRunState{{ + PipelineTask: &pts[10], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + WhenExpressionsResult: expressions.NewEvaluationResults( + []*expression.EvaluationResult{ + expressions.NewEvaluationResult( + expressions.NewExpression("foo", selection.NotIn, []string{"foo", "bar"}), + false, + ), + }, + false, + ), + }}, + expected: true, }, { name: "tasks-multiple-conditions-passed-failed", taskName: "mytask1", @@ -1155,6 +1216,131 @@ func TestIsSkipped(t *testing.T) { } } +func TestShouldCreateTaskRun(t *testing.T) { + + tcs := []struct { + name string + taskName string + rprt ResolvedPipelineRunTask + expected bool + }{{ + name: "tasks-no-condition-no-when-expression", + taskName: "mytask1", + rprt: ResolvedPipelineRunTask{ + TaskRunName: "pipelinerun-conditionaltask", + TaskRun: nil, + PipelineTask: &pts[0], + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + ResolvedConditionChecks: nil, + }, + expected: true, + }, { + name: "tasks-condition-passed", + taskName: "mytask1", + rprt: ResolvedPipelineRunTask{ + TaskRunName: "pipelinerun-conditionaltask", + TaskRun: nil, + PipelineTask: &pts[0], + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + ResolvedConditionChecks: successTaskConditionCheckState, + }, + expected: true, + }, { + name: "tasks-condition-failed", + taskName: "mytask1", + rprt: ResolvedPipelineRunTask{ + PipelineTask: &pts[0], + TaskRunName: "pipelinerun-conditionaltask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + ResolvedConditionChecks: failedTaskConditionCheckState, + }, + expected: false, + }, { + name: "tasks-when-expressions-passed", + taskName: "mytask9", + rprt: ResolvedPipelineRunTask{ + PipelineTask: &pts[9], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }, + expected: true, + }, { + name: "tasks-when-expression-failed", + taskName: "mytask10", + rprt: ResolvedPipelineRunTask{ + PipelineTask: &pts[10], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }, + expected: false, + }} + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + execute := tc.rprt.ShouldCreateTaskRun() + if d := cmp.Diff(execute, tc.expected); d != "" { + t.Errorf("Didn't get expected ShouldCreateTaskRun %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestEvaluateWhenExpressions(t *testing.T) { + + tcs := []struct { + name string + taskName string + rprt ResolvedPipelineRunTask + expected bool + }{{ + name: "tasks-when-expressions-passes", + taskName: "mytask9", + rprt: ResolvedPipelineRunTask{ + PipelineTask: &pts[9], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }, + expected: true, + }, { + name: "tasks-when-expression-fails", + taskName: "mytask10", + rprt: ResolvedPipelineRunTask{ + PipelineTask: &pts[10], + TaskRunName: "pipelinerun-guardedtask", + TaskRun: nil, + ResolvedTaskResources: &resources.ResolvedTaskResources{ + TaskSpec: &task.Spec, + }, + }, + expected: false, + }} + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + execute := tc.rprt.evaluateWhenExpressions() + if d := cmp.Diff(execute, tc.expected); d != "" { + t.Errorf("Didn't get expected evaluateWhenExpressions %s", diff.PrintWantGot(d)) + } + }) + } +} + func TestPipelineRunState_SuccessfulOrSkippedDAGTasks(t *testing.T) { tcs := []struct { name string @@ -2418,6 +2604,89 @@ func TestResolvedConditionCheck_WithResources(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 + expectedExpressionResults expressions.EvaluationResults + }{ + { + 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) + }, + expectedExpressionResults: *expressions.NewEvaluationResults( + []*expressions.EvaluationResult{ + expressions.NewEvaluationResult(expressions.NewExpression("foo", selection.In, []string{"foo"}), true), + }, + true, + ), + }, + } + + 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) + } + }) + } +} + func TestValidateResourceBindings(t *testing.T) { p := tb.Pipeline("pipelines", tb.PipelineSpec( tb.PipelineDeclaredResource("git-resource", "git"), diff --git a/pkg/reconciler/pipelinerun/resources/resultrefresolution.go b/pkg/reconciler/pipelinerun/resources/resultrefresolution.go index c2062eca4cb..2a9e0f83ace 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 := v1beta1.GetVarSubstitutionExpressionsForWhenExpression(whenExpression) + 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,20 @@ func convertParams(params []v1beta1.Param, pipelineRunState PipelineRunState, na return resolvedParams, nil } +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 %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..9279995e602 100644 --- a/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go +++ b/pkg/reconciler/pipelinerun/resources/resultrefresolution_test.go @@ -294,6 +294,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: "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: "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: "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: "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: "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: "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: "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 +580,18 @@ func TestResolveResultRefs(t *testing.T) { }, }, }, + }, { + PipelineTask: &v1beta1.PipelineTask{ + Name: "bTask", + TaskRef: &v1beta1.TaskRef{Name: "bTask"}, + WhenExpressions: []v1beta1.WhenExpression{ + { + Input: "$(tasks.aTask.results.aResult)", + Operator: "in", + Values: []string{"$(tasks.aTask.results.aResult)"}, + }, + }, + }, }, } @@ -333,7 +602,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 +623,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,