diff --git a/docs/pipeline-api.md b/docs/pipeline-api.md index d2f6a4d93b7..7496f17ad45 100644 --- a/docs/pipeline-api.md +++ b/docs/pipeline-api.md @@ -1997,6 +1997,9 @@ associated Pipeline is an invalid graph (a.k.a wrong order, cycle, …)

"InvalidMatrixParameterTypes"

ReasonInvalidMatrixParameterTypes indicates a matrix contains invalid parameter types

+

"InvalidParamValue"

+

PipelineRunReasonInvalidParamValue indicates that the PipelineRun Param input value is not allowed.

+

"InvalidTaskResultReference"

ReasonInvalidTaskResultReference indicates a task result was declared but was not initialized by that task

diff --git a/docs/pipelineruns.md b/docs/pipelineruns.md index a8ab9cf2187..9b2d4cac632 100644 --- a/docs/pipelineruns.md +++ b/docs/pipelineruns.md @@ -271,6 +271,20 @@ case is when your CI system autogenerates `PipelineRuns` and it has `Parameters` provide to all `PipelineRuns`. Because you can pass in extra `Parameters`, you don't have to go through the complexity of checking each `Pipeline` and providing only the required params. +#### Parameter Enums + +> :seedling: **Specifying `enum` is an [alpha](additional-configs.md#alpha-features) feature.** The `enable-param-enum` feature flag must be set to `"true"` to enable this feature. + +> :seedling: This feature is WIP and not yet supported/implemented. Documentation to be completed. + +If a `Parameter` is guarded by `Enum` in the `Pipeline`, you can only provide `Parameter` values in the `PipelineRun` that are predefined in the `Param.Enum` in the `Pipeline`. The `PipelineRun` will fail with reason `InvalidParamValue` otherwise. + +Tekton will also the validate the `param` values passed to any referenced `Tasks` (vis `taskRef`) if `Enum` is specified for the `Task`. The `PipelineRun` will fail with reason `InvalidParamValue` if `Enum` validation is failed for any of the `PipelineTask`. + +You can also specify `Enum` for `PipelineRun` with an embedded `Pipeline`. The same param validation will be executed in this scenario. + +See more details in [Param.Enum](./pipelines.md#param-enum). + #### Propagated Parameters When using an inlined spec, parameters from the parent `PipelineRun` will be diff --git a/docs/pipelines.md b/docs/pipelines.md index e734ed1de73..c253480e8d2 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -276,7 +276,70 @@ spec: > :seedling: This feature is WIP and not yet supported/implemented. Documentation to be completed. -Parameter declarations can include `enum` which is a predefine set of valid values that can be accepted by the `Pipeline`. +Parameter declarations can include `enum` which is a predefine set of valid values that can be accepted by the `Pipeline` `Param`. For example, the valid/allowed values for `Param` "message" is bounded to `v1` and `v2`: + +``` yaml +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: pipeline-param-enum +spec: + params: + - name: message + enum: ["v1", "v2"] + default: "v1" + tasks: + - name: task1 + params: + - name: message + value: $(params.message) + steps: + - name: build + image: bash:3.2 + script: | + echo "$(params.message)" +``` + +If the `Param` value passed in by `PipelineRun` is **NOT** in the predefined `enum` list, the `PipelineRun` will fail with reason `InvalidParamValue`. + +If a `PipelineTask` references a `Task` with `enum`, Tekton validates the **intersection** of enum specified in the referenced `Task` and the enum specified in the Pipeline `spec.params`. In the example below, the referenced `Task` accepts `v1` and `v2` as valid values, and the `Pipeline` accepts `v2` and `v3` as valid values. Only passing `v2` in the `PipelineRun` will lead to a sucessful execution. + +``` yaml +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: param-enum-demo +spec: + params: + - name: message + type: string + enum: ["v1", "v2"] + steps: + - name: build + image: bash:latest + script: | + echo "$(params.message)" +``` + +``` yaml +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: pipeline-param-enum +spec: + params: + - name: message + enum: ["v2", "v3"] + tasks: + - name: task1 + params: + - name: message + value: $(params.message) + taskRef: + name: param-enum-demo +``` + +See usage in this [example](../examples/v1/pipelineruns/alpha/param-enum.yaml) ## Adding `Tasks` to the `Pipeline` diff --git a/examples/v1/pipelineruns/alpha/param-enum.yaml b/examples/v1/pipelineruns/alpha/param-enum.yaml new file mode 100644 index 00000000000..58d5fda4281 --- /dev/null +++ b/examples/v1/pipelineruns/alpha/param-enum.yaml @@ -0,0 +1,33 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: pipeline-param-enum +spec: + params: + - name: message + enum: ["v1", "v2", "v3"] + default: "v1" + tasks: + - name: task1 + params: + - name: message + value: $(params.message) + taskSpec: + params: + - name: message + steps: + - name: build + image: bash:3.2 + script: | + echo "$(params.message)" +--- +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: pipelinerun-param-enum +spec: + pipelineRef: + name: pipeline-param-enum + params: + - name: message + value: "v2" \ No newline at end of file diff --git a/pkg/apis/pipeline/v1/pipelinerun_types.go b/pkg/apis/pipeline/v1/pipelinerun_types.go index 88aad636b89..724b47130a7 100644 --- a/pkg/apis/pipeline/v1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1/pipelinerun_types.go @@ -409,6 +409,8 @@ const ( PipelineRunReasonCreateRunFailed PipelineRunReason = "CreateRunFailed" // ReasonCELEvaluationFailed indicates the pipeline fails the CEL evaluation PipelineRunReasonCELEvaluationFailed PipelineRunReason = "CELEvaluationFailed" + // PipelineRunReasonInvalidParamValue indicates that the PipelineRun Param input value is not allowed. + PipelineRunReasonInvalidParamValue PipelineRunReason = "InvalidParamValue" ) func (t PipelineRunReason) String() string { diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index aac9fba25ba..edf34e3082f 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -387,6 +387,17 @@ func (c *Reconciler) resolvePipelineState( return nil, controller.NewPermanentError(err) } } + + if config.FromContextOrDefaults(ctx).FeatureFlags.EnableParamEnum { + if len(resolvedTask.TaskRuns) > 0 && len(resolvedTask.TaskRuns[0].Status.Conditions) > 0 { + cond := resolvedTask.TaskRuns[0].Status.Conditions[0] + if cond.Status == corev1.ConditionFalse && cond.Reason == v1.TaskRunReasonInvalidParamValue { + pr.Status.MarkFailed(v1.PipelineRunReasonInvalidParamValue.String(), + "Invalid param value in the referenced Task from PipelineTask \"%s\": %s", resolvedTask.PipelineTask.Name, cond.Message) + return nil, controller.NewPermanentError(err) + } + } + } pst = append(pst, resolvedTask) } return pst, nil @@ -487,6 +498,16 @@ func (c *Reconciler) reconcile(ctx context.Context, pr *v1.PipelineRun, getPipel return controller.NewPermanentError(err) } + if config.FromContextOrDefaults(ctx).FeatureFlags.EnableParamEnum { + if err := taskrun.ValidateEnumParam(ctx, pr.Spec.Params, pipelineSpec.Params); err != nil { + logger.Errorf("PipelineRun %q Param Enum validation failed: %v", pr.Name, err) + pr.Status.MarkFailed(v1.PipelineRunReasonInvalidParamValue.String(), + "PipelineRun %s/%s parameters have invalid value: %s", + pr.Namespace, pr.Name, err) + return controller.NewPermanentError(err) + } + } + // Ensure that the keys of an object param declared in PipelineSpec are not missed in the PipelineRunSpec if err = resources.ValidateObjectParamRequiredKeys(pipelineSpec.Params, pr.Spec.Params); err != nil { // This Run has failed, so we need to mark it as failed and stop reconciling it diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index e9e50b529a4..9a86f1b2f52 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -4278,6 +4278,188 @@ spec: checkPipelineRunConditionStatusAndReason(t, pipelineRun, corev1.ConditionFalse, string(v1.PipelineRunReasonCELEvaluationFailed)) } +func TestReconcile_Pipeline_Level_Enum_Pass(t *testing.T) { + ps := []*v1.Pipeline{parse.MustParseV1Pipeline(t, ` +metadata: + name: test-pipeline-level-enum + namespace: foo +spec: + params: + - name: version + type: string + enum: ["v1", "v2"] + - name: tag + type: string + tasks: + - name: a-task + params: + - name: version + value: $(params.version) + - name: tag + value: $(params.tag) + taskSpec: + name: a-task + params: + - name: version + - name: tag + steps: + - name: s1 + image: alpine + script: | + echo $(params.version) + $(params.tag) +`)} + prs := []*v1.PipelineRun{parse.MustParseV1PipelineRun(t, ` +metadata: + name: test-pipeline-level-enum-run + namespace: foo +spec: + params: + - name: version + value: "v1" + - name: tag + value: "t1" + pipelineRef: + name: test-pipeline-level-enum +`)} + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "enable-param-enum": "true", + }, + }, + } + d := test.Data{ + PipelineRuns: prs, + Pipelines: ps, + ConfigMaps: cms, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + pipelineRun, _ := prt.reconcileRun("foo", "test-pipeline-level-enum-run", []string{}, false) + // PipelineRun in running status indicates the param enum has passed validation + checkPipelineRunConditionStatusAndReason(t, pipelineRun, corev1.ConditionUnknown, v1.PipelineRunReasonRunning.String()) +} + +func TestReconcile_Pipeline_Level_Enum_Failed(t *testing.T) { + ps := []*v1.Pipeline{parse.MustParseV1Pipeline(t, ` +metadata: + name: test-pipeline-level-enum + namespace: foo +spec: + params: + - name: version + type: string + enum: ["v1", "v2"] + tasks: + - name: a-task + taskSpec: + name: a-task + params: + - name: version + steps: + - name: s1 + image: alpine + script: | + echo $(params.version) +`)} + prs := []*v1.PipelineRun{parse.MustParseV1PipelineRun(t, ` +metadata: + name: test-pipeline-level-enum-run + namespace: foo +spec: + params: + - name: version + value: "v3" + pipelineRef: + name: test-pipeline-level-enum +`)} + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "enable-param-enum": "true", + }, + }, + } + d := test.Data{ + PipelineRuns: prs, + Pipelines: ps, + ConfigMaps: cms, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + pipelineRun, _ := prt.reconcileRun("foo", "test-pipeline-level-enum-run", []string{}, true) + checkPipelineRunConditionStatusAndReason(t, pipelineRun, corev1.ConditionFalse, string(v1.PipelineRunReasonInvalidParamValue)) +} + +func TestReconcile_PipelineTask_Level_Enum_Failed(t *testing.T) { + ps := []*v1.Pipeline{parse.MustParseV1Pipeline(t, ` +metadata: + name: test-pipelineTask-level-enum + namespace: foo +spec: + params: + - name: version + type: string + tasks: + - name: a-task + params: + - name: version + value: $(params.version) + taskSpec: + name: a-task + params: + - name: version + enum: ["v1", "v2"] + steps: + - name: s1 + image: alpine + script: | + echo $(params.version) +`)} + prs := []*v1.PipelineRun{parse.MustParseV1PipelineRun(t, ` +metadata: + name: test-pipelineTask-level-enum-run + namespace: foo +spec: + params: + - name: version + value: "v3" + pipelineRef: + name: test-pipelineTask-level-enum +`)} + + trs := []*v1.TaskRun{mustParseTaskRunWithObjectMeta(t, + taskRunObjectMeta("test-pipelineTask-level-enum-a-task", "foo", + "test-pipelineTask-level-enum-run", "test-pipelineTask-level-enum", "a-task", true), + ` +status: + conditions: + - status: "False" + type: Succeeded + reason: "InvalidParamValue" +`)} + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "enable-param-enum": "true", + }, + }, + } + d := test.Data{ + PipelineRuns: prs, + Pipelines: ps, + ConfigMaps: cms, + TaskRuns: trs, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + pipelineRun, _ := prt.reconcileRun("foo", "test-pipelineTask-level-enum-run", []string{}, true) + checkPipelineRunConditionStatusAndReason(t, pipelineRun, corev1.ConditionFalse, string(v1.PipelineRunReasonInvalidParamValue)) +} + // TestReconcileWithAffinityAssistantStatefulSet tests that given a pipelineRun with workspaces, // an Affinity Assistant StatefulSet is created for each PVC workspace and // that the Affinity Assistant names is propagated to TaskRuns. diff --git a/test/pipelinerun_test.go b/test/pipelinerun_test.go index 9b69c966df4..94f4dfe2fdc 100644 --- a/test/pipelinerun_test.go +++ b/test/pipelinerun_test.go @@ -27,6 +27,7 @@ import ( "testing" "time" + "github.com/tektoncd/pipeline/pkg/apis/config" "github.com/tektoncd/pipeline/pkg/apis/pipeline" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/test/parse" @@ -35,6 +36,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "knative.dev/pkg/apis" + "knative.dev/pkg/system" knativetest "knative.dev/pkg/test" "knative.dev/pkg/test/helpers" ) @@ -1037,3 +1039,145 @@ spec: t.Errorf("Expected PipelineRun to fail with condition message: %s but got: %s", expectedMessage, pipelineRun.Status.GetCondition(apis.ConditionSucceeded).Message) } } + +// TestPipelineRunParamEnum_Failed tests a PipelineRun which passes the Pipeline-level param validation, but failed at PipelineTask-level param validation. +// It tests the overall pipeline to be failed with reason "InvalidParamValue" +func TestPipelineRunParamEnum_Failed(t *testing.T) { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + c, namespace := setup(ctx, t) + + knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf) + defer resetEnumFeatureFlagAndCleanup(ctx, t, c, namespace) + + taskWithEnumName := "task-with-enum" + taskWithoutEnumName := "task-without-enum" + pipelineName := helpers.ObjectNameForTest(t) + prName := helpers.ObjectNameForTest(t) + + // update feature flag + configMapData := map[string]string{ + "enable-param-enum": "true", + } + if err := updateConfigMap(ctx, c.KubeClient, system.Namespace(), config.GetFeatureFlagsConfigName(), configMapData); err != nil { + t.Fatal(err) + } + + t.Logf("Creating Task, Pipeline, and PipelineRun %s in namespace %s", prName, namespace) + + // Create Tasks + if _, err := c.V1TaskClient.Create(ctx, parse.MustParseV1Task(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + params: + - name: version + steps: + - image: ubuntu + command: ['/bin/bash'] + args: ['-c', 'echo $(params.version)'] +`, taskWithoutEnumName, namespace)), metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create Task `%s`: %s", taskWithoutEnumName, err) + } + + if _, err := c.V1TaskClient.Create(ctx, parse.MustParseV1Task(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + params: + - name: version + enum: ["v1", "v2"] + steps: + - image: ubuntu + command: ['/bin/bash'] + args: ['-c', 'echo $(params.version)'] +`, taskWithEnumName, namespace)), metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create Task `%s`: %s", taskWithEnumName, err) + } + + // Create Pipeline + if _, err := c.V1PipelineClient.Create(ctx, parse.MustParseV1Pipeline(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + params: + - name: version + enum: ["v1", "v2", "v3"] + tasks: + - name: task1 + params: + - name: version + value: $(params.version) + taskRef: + name: %s + - name: task2 + params: + - name: version + value: $(params.version) + taskRef: + name: %s +`, pipelineName, namespace, taskWithoutEnumName, taskWithEnumName)), metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create Pipeline `%s`: %s", pipelineName, err) + } + + // Create PipelineRun with Param value "v3", which passes pipeline-level validation, but fails at pipelineTask-level validation + pipelineRun, err := c.V1PipelineRunClient.Create(ctx, parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + params: + - name: version + value: v3 + pipelineRef: + name: %s +`, prName, namespace, pipelineName)), metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create PipelineRun `%s`: %s", prName, err) + } + + // Wait for the PipelineRun to fail. + if err := WaitForPipelineRunState(ctx, c, prName, timeout, PipelineRunFailed(prName), "PipelineRunFailed", v1beta1Version); err != nil { + t.Fatalf("Waiting for PipelineRun to fail: %v", err) + } + + // Validate PipelineRun status + pipelineRun, err = c.V1PipelineRunClient.Get(ctx, prName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Error getting PipelineRun %s: %s", prName, err) + } + if pipelineRun.Status.GetCondition(apis.ConditionSucceeded).IsTrue() { + t.Errorf("Expected PipelineRun to fail but found condition: %s", pipelineRun.Status.GetCondition(apis.ConditionSucceeded)) + } + if pipelineRun.Status.GetCondition(apis.ConditionSucceeded).Reason != v1.PipelineRunReasonInvalidParamValue.String() { + t.Errorf("Expected PipelineRun to fail with reason: %s but got: %s", v1.PipelineRunReasonInvalidParamValue, pipelineRun.Status.GetCondition(apis.ConditionSucceeded).Reason) + } + + // Validate TaskRun status + taskWithoutEnum, err := c.V1TaskRunClient.Get(ctx, fmt.Sprintf("%s-task1", prName), metav1.GetOptions{}) + taskWithEnum, err := c.V1TaskRunClient.Get(ctx, fmt.Sprintf("%s-task2", prName), metav1.GetOptions{}) + if taskWithoutEnum.Status.GetCondition(apis.ConditionSucceeded).IsFalse() { + t.Errorf("Expected Task %s to pass but not: %s, reason: %s", taskWithoutEnum.Name, taskWithoutEnum.Status.GetCondition(apis.ConditionSucceeded), taskWithoutEnum.Status.GetCondition(apis.ConditionSucceeded).Reason) + } + if taskWithEnum.Status.GetCondition(apis.ConditionSucceeded).IsTrue() { + t.Errorf("Expected Task %s to fail but not: %s, reason: %s", taskWithEnum.Name, taskWithEnum.Status.GetCondition(apis.ConditionSucceeded), taskWithEnum.Status.GetCondition(apis.ConditionSucceeded).Reason) + } + if taskWithEnum.Status.GetCondition(apis.ConditionSucceeded).Reason != v1.PipelineRunReasonInvalidParamValue.String() { + t.Errorf("Expected taskWithEnum to fail with reason: %s but got: %s", v1.TaskRunReasonInvalidParamValue, taskWithEnum.Status.GetCondition(apis.ConditionSucceeded).Reason) + } +} + +func resetEnumFeatureFlagAndCleanup(ctx context.Context, t *testing.T, c *clients, namespace string) { + t.Helper() + configMapData := map[string]string{ + "enable-param-enum": "false", + } + if err := updateConfigMap(ctx, c.KubeClient, system.Namespace(), config.GetFeatureFlagsConfigName(), configMapData); err != nil { + t.Fatal(err) + } + tearDown(ctx, t, c, namespace) +}