From 86cc7e7d442ef9aee0089697bf8d4cf8163302c2 Mon Sep 17 00:00:00 2001 From: Stanislav Jakuschevskij Date: Sat, 12 Jul 2025 15:24:25 +0200 Subject: [PATCH] feature: implement PinP reconciliation and tests [TEP-0056]: Third PR of Pipelines-in-Pipelines feature implementation. Child `PipelineRuns` (PinP) are created by the `PipelineRun` reconciler, equal to the `TaskRun/CustomRun` implementations. An event handler for child `PipelineRuns` is registered in controller entrypoint. This will trigger the reconciliation loop when child `PipelineRuns` change their state. Extend `resolvePipelineState` with getter for child `PipelineRuns` using lister and extend `runNextSchedulableTask` with a condition check for a `PipelineTask` which is a `Pipeline` and implement the creation of a new `PipelineRun` from the resolved pipeline state and pipeline facts. Setting the `ChildReferences` was extended for child `PipelineRuns`. Rename label/annotation factory. The unit/e2e test framework was refactored and extended to prepare for future tests. The test setup is a parent pipeline with one or more embedded child/grandchild pipelines using the `PipelineSpec` (alpha) field. It follows the given-when-then test flow arrangement. The pipeline manifests yaml definitions use variables for every field which is validated. Multiple helper functions were created equal to reconciliation unit tests for TaskRuns/CustomRuns. Test data factory functions were put in the `testing` package in the `factory.go` file. The unit tests validates: - the status and condition of the parent PipelineRuns which should trigger the creation of the child PipelineRuns, - the actual created child/grandchild PipelineRuns if they have the correct metadata i.e. name, owner reference, etc. and the embedded pipelines from the `PipelineSpec` fields in the pipeline tasks of the parent pipeline. Similar checks are performed in `TestReconcile` for `TaskRun` and in `TestReconcile_V1Beta1CustomTask` for `CustomTasks`. The e2e tests validate: - parent PipelineRun creation, - child PipelineRun creation, - successful finish of all resources, - correct label and annotation propagation, - amount of events published. Similar checks are performed in `TestPipelineRun|TestPipelineRunStatusSpec|...` for `TaskRun` and in `TestCustomTask` for `CustomTask`. Issues #8760, #7166. Signed-off-by: Stanislav Jakuschevskij --- pkg/reconciler/pipelinerun/controller.go | 7 + pkg/reconciler/pipelinerun/pipelinerun.go | 187 +++++-- .../pipelinerun/pipelinerun_pinp_test.go | 460 ++++++++++++++++++ .../pipelinerun_updatestatus_test.go | 4 +- pkg/reconciler/testing/factory.go | 333 +++++++++++++ pkg/reconciler/testing/status.go | 10 + test/parse/yaml.go | 8 + test/pipelinerun_pinp_test.go | 343 +++++++++++++ test/util.go | 36 ++ 9 files changed, 1354 insertions(+), 34 deletions(-) create mode 100644 pkg/reconciler/pipelinerun/pipelinerun_pinp_test.go create mode 100644 pkg/reconciler/testing/factory.go create mode 100644 test/pipelinerun_pinp_test.go diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index d47ef8d7760..4cae73c1452 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -100,6 +100,13 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex logging.FromContext(ctx).Panicf("Couldn't register PipelineRun informer event handler: %w", err) } + if _, err := pipelineRunInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: controller.FilterController(&v1.PipelineRun{}), + Handler: controller.HandleAll(impl.EnqueueControllerOf), + }); err != nil { + logging.FromContext(ctx).Panicf("Couldn't register PipelineRun informer event handler: %w", err) + } + if _, err := taskRunInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ FilterFunc: controller.FilterController(&v1.PipelineRun{}), Handler: controller.HandleAll(impl.EnqueueControllerOf), diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index ed70b0aa8e9..a73dd9d2a80 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -145,8 +145,9 @@ var ( // "ControllerName" const in describing the type of run, we import these // constants (for consistency) but rename them (for ergonomic semantics). const ( - taskRun = pipeline.TaskRunControllerName - customRun = pipeline.CustomRunControllerName + taskRun = pipeline.TaskRunControllerName + customRun = pipeline.CustomRunControllerName + pipelineRun = pipeline.PipelineRunControllerName ) // Reconciler implements controller.Reconciler for Configuration resources. @@ -359,6 +360,10 @@ func (c *Reconciler) resolvePipelineState( return nil, fmt.Errorf("failed to list VerificationPolicies from namespace %s with error %w", pr.Namespace, err) } + getChildPipelineRunFunc := func(name string) (*v1.PipelineRun, error) { + return c.pipelineRunLister.PipelineRuns(pr.Namespace).Get(name) + } + getTaskFunc := tresources.GetTaskFunc( ctx, c.KubeClientSet, @@ -386,6 +391,7 @@ func (c *Reconciler) resolvePipelineState( resolvedTask, err := resources.ResolvePipelineTask(ctx, *pr, + getChildPipelineRunFunc, getTaskFunc, getTaskRunFunc, getCustomRunFunc, @@ -679,7 +685,8 @@ func (c *Reconciler) reconcile(ctx context.Context, pr *v1.PipelineRun, getPipel } for i, rpt := range pipelineRunFacts.State { - if !rpt.IsCustomTask() { + // Task? + if !rpt.IsCustomTask() && !rpt.IsChildPipeline() { err := taskrun.ValidateResolvedTask(ctx, rpt.PipelineTask.Params, rpt.PipelineTask.Matrix, rpt.ResolvedTask) if err != nil { logger.Errorf("Failed to validate pipelinerun %s with error %w", pr.Name, err) @@ -953,14 +960,22 @@ func (c *Reconciler) runNextSchedulableTask(ctx context.Context, pr *v1.Pipeline } } - if rpt.IsCustomTask() { + switch { + case rpt.IsChildPipeline(): + rpt.ChildPipelineRuns, err = c.createChildPipelineRuns(ctx, rpt, pr, pipelineRunFacts) + if err != nil { + recorder.Eventf(pr, corev1.EventTypeWarning, "ChildPipelineRunsCreationFailed", "Failed to create child (PIP) PipelineRuns %q: %v", rpt.ChildPipelineRunNames, err) + err = fmt.Errorf("error creating child PipelineRuns called %s for PipelineTask %s from PipelineRun %s: %w", rpt.ChildPipelineRunNames, rpt.PipelineTask.Name, pr.Name, err) + return err + } + case rpt.IsCustomTask(): rpt.CustomRuns, err = c.createCustomRuns(ctx, rpt, pr, pipelineRunFacts) if err != nil { recorder.Eventf(pr, corev1.EventTypeWarning, "RunsCreationFailed", "Failed to create CustomRuns %q: %v", rpt.CustomRunNames, err) err = fmt.Errorf("error creating CustomRuns called %s for PipelineTask %s from PipelineRun %s: %w", rpt.CustomRunNames, rpt.PipelineTask.Name, pr.Name, err) return err } - } else { + default: rpt.TaskRuns, err = c.createTaskRuns(ctx, rpt, pr, pipelineRunFacts) if err != nil { recorder.Eventf(pr, corev1.EventTypeWarning, "TaskRunsCreationFailed", "Failed to create TaskRuns %q: %v", rpt.TaskRunNames, err) @@ -983,6 +998,67 @@ func (c *Reconciler) setFinallyStartedTimeIfNeeded(pr *v1.PipelineRun, facts *re } } +func (c *Reconciler) createChildPipelineRuns( + ctx context.Context, + rpt *resources.ResolvedPipelineTask, + pr *v1.PipelineRun, + facts *resources.PipelineRunFacts, +) ([]*v1.PipelineRun, error) { + ctx, span := c.tracerProvider.Tracer(TracerName).Start(ctx, "createChildPipelineRuns") + defer span.End() + + var childPipelineRuns []*v1.PipelineRun + for _, childPipelineRunName := range rpt.ChildPipelineRunNames { + var params v1.Params + childPipelineRun, err := c.createChildPipelineRun(ctx, childPipelineRunName, params, rpt, pr, facts) + if err != nil { + err := c.handleRunCreationError(pr, err) + return nil, err + } + childPipelineRuns = append(childPipelineRuns, childPipelineRun) + } + + return childPipelineRuns, nil +} + +func (c *Reconciler) createChildPipelineRun( + ctx context.Context, + childPipelineRunName string, + params v1.Params, + rpt *resources.ResolvedPipelineTask, + pr *v1.PipelineRun, + facts *resources.PipelineRunFacts, +) (*v1.PipelineRun, error) { + ctx, span := c.tracerProvider.Tracer(TracerName).Start(ctx, "createChildPipelineRun") + defer span.End() + + logger := logging.FromContext(ctx) + rpt.PipelineTask = resources.ApplyPipelineTaskContexts(rpt.PipelineTask, pr.Status, facts) + + newChildPipelineRun := &v1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: childPipelineRunName, + Namespace: pr.Namespace, + OwnerReferences: []metav1.OwnerReference{*kmeta.NewControllerRef(pr)}, + Labels: createChildResourceLabels(pr, rpt.PipelineTask.Name, true), + Annotations: createChildResourceAnnotations(pr), + }, + Spec: v1.PipelineRunSpec{ + PipelineSpec: rpt.PipelineTask.PipelineSpec, + }, + } + + logger.Infof( + "Creating a new child (PIP) PipelineRun object %s for pipeline task %s", + childPipelineRunName, + rpt.PipelineTask.Name, + ) + + return c.PipelineClientSet.TektonV1(). + PipelineRuns(pr.Namespace). + Create(ctx, newChildPipelineRun, metav1.CreateOptions{}) +} + func (c *Reconciler) createTaskRuns(ctx context.Context, rpt *resources.ResolvedPipelineTask, pr *v1.PipelineRun, facts *resources.PipelineRunFacts) ([]*v1.TaskRun, error) { ctx, span := c.tracerProvider.Tracer(TracerName).Start(ctx, "createTaskRuns") defer span.End() @@ -1017,7 +1093,7 @@ func (c *Reconciler) createTaskRuns(ctx context.Context, rpt *resources.Resolved } taskRun, err := c.createTaskRun(ctx, taskRunName, params, rpt, pr, facts) if err != nil { - err := c.handleRunCreationError(ctx, pr, err) + err := c.handleRunCreationError(pr, err) return nil, err } taskRuns = append(taskRuns, taskRun) @@ -1092,12 +1168,13 @@ func (c *Reconciler) createTaskRun(ctx context.Context, taskRunName string, para } // handleRunCreationError marks the PipelineRun as failed and returns a permanent error if the run creation error is not retryable -func (c *Reconciler) handleRunCreationError(ctx context.Context, pr *v1.PipelineRun, err error) error { +func (c *Reconciler) handleRunCreationError(pr *v1.PipelineRun, err error) error { if controller.IsPermanentError(err) { pr.Status.MarkFailed(v1.PipelineRunReasonCreateRunFailed.String(), err.Error()) return err } - // This is not a complete list of permanent errors. Any permanent error with TaskRun/CustomRun creation can be added here. + // This is not a complete list of permanent errors. Any permanent error with child (PinP) + // PipelinRun/TaskRun/CustomRun creation can be added here. if apierrors.IsInvalid(err) || apierrors.IsBadRequest(err) { pr.Status.MarkFailed(v1.PipelineRunReasonCreateRunFailed.String(), err.Error()) return controller.NewPermanentError(err) @@ -1121,7 +1198,7 @@ func (c *Reconciler) createCustomRuns(ctx context.Context, rpt *resources.Resolv } customRun, err := c.createCustomRun(ctx, customRunName, params, rpt, pr, facts) if err != nil { - err := c.handleRunCreationError(ctx, pr, err) + err := c.handleRunCreationError(pr, err) return nil, err } customRuns = append(customRuns, customRun) @@ -1150,8 +1227,8 @@ func (c *Reconciler) createCustomRun(ctx context.Context, runName string, params Name: runName, Namespace: pr.Namespace, OwnerReferences: []metav1.OwnerReference{*kmeta.NewControllerRef(pr)}, - Labels: getTaskrunLabels(pr, rpt.PipelineTask.Name, true), - Annotations: getTaskrunAnnotations(pr), + Labels: createChildResourceLabels(pr, rpt.PipelineTask.Name, true), + Annotations: createChildResourceAnnotations(pr), } // TaskRef, Params and Workspaces are converted to v1beta1 since CustomRuns @@ -1349,8 +1426,8 @@ func combinedSubPath(workspaceSubPath string, pipelineTaskSubPath string) string return filepath.Join(workspaceSubPath, pipelineTaskSubPath) } -func getTaskrunAnnotations(pr *v1.PipelineRun) map[string]string { - // Propagate annotations from PipelineRun to TaskRun. +func createChildResourceAnnotations(pr *v1.PipelineRun) map[string]string { + // propagate annotations from PipelineRun to child (PinP) PipelineRun/TaskRun/CustomRun annotations := make(map[string]string, len(pr.ObjectMeta.Annotations)+1) for key, val := range pr.ObjectMeta.Annotations { annotations[key] = val @@ -1396,10 +1473,10 @@ func propagatePipelineNameLabelToPipelineRun(pr *v1.PipelineRun) error { return nil } -func getTaskrunLabels(pr *v1.PipelineRun, pipelineTaskName string, includePipelineLabels bool) map[string]string { - // Propagate labels from PipelineRun to TaskRun. +func createChildResourceLabels(pr *v1.PipelineRun, pipelineTaskName string, includePipelineRunLabels bool) map[string]string { + // propagate labels from PipelineRun to child (PinP) PipelineRun/TaskRun/CustomRun labels := make(map[string]string, len(pr.ObjectMeta.Labels)+1) - if includePipelineLabels { + if includePipelineRunLabels { for key, val := range pr.ObjectMeta.Labels { labels[key] = val } @@ -1436,7 +1513,7 @@ func combineTaskRunAndTaskSpecLabels(pr *v1.PipelineRun, pipelineTask *v1.Pipeli addMetadataByPrecedence(labels, taskRunSpec.Metadata.Labels) } - addMetadataByPrecedence(labels, getTaskrunLabels(pr, pipelineTask.Name, true)) + addMetadataByPrecedence(labels, createChildResourceLabels(pr, pipelineTask.Name, true)) if pipelineTask.TaskSpec != nil { addMetadataByPrecedence(labels, pipelineTask.TaskSpecMetadata().Labels) @@ -1453,7 +1530,7 @@ func combineTaskRunAndTaskSpecAnnotations(pr *v1.PipelineRun, pipelineTask *v1.P addMetadataByPrecedence(annotations, taskRunSpec.Metadata.Annotations) } - addMetadataByPrecedence(annotations, getTaskrunAnnotations(pr)) + addMetadataByPrecedence(annotations, createChildResourceAnnotations(pr)) if pipelineTask.TaskSpec != nil { addMetadataByPrecedence(annotations, pipelineTask.TaskSpecMetadata().Annotations) @@ -1533,10 +1610,16 @@ func (c *Reconciler) updatePipelineRunStatusFromInformer(ctx context.Context, pr defer span.End() logger := logging.FromContext(ctx) - // Get the pipelineRun label that is set on each TaskRun. Do not include the propagated labels from the - // Pipeline and PipelineRun. The user could change them during the lifetime of the PipelineRun so the + // Get the parent PipelineRun label that is set on each child (PinP) PipelineRun/TaskRun/CustomRun. Do not include the propagated labels from the + // Pipeline and PipelineRun. The user could change them during the lifetime of the PipelineRun so the // current labels may not be set on the previously created TaskRuns. - pipelineRunLabels := getTaskrunLabels(pr, "", false) + pipelineRunLabels := createChildResourceLabels(pr, "", false) + childPipelineRuns, err := c.pipelineRunLister.PipelineRuns(pr.Namespace).List(k8slabels.SelectorFromSet(pipelineRunLabels)) + if err != nil { + logger.Errorf("Could not list PipelineRuns %#v", err) + return err + } + taskRuns, err := c.taskRunLister.TaskRuns(pr.Namespace).List(k8slabels.SelectorFromSet(pipelineRunLabels)) if err != nil { logger.Errorf("Could not list TaskRuns %#v", err) @@ -1548,11 +1631,12 @@ func (c *Reconciler) updatePipelineRunStatusFromInformer(ctx context.Context, pr logger.Errorf("Could not list CustomRuns %#v", err) return err } - return updatePipelineRunStatusFromChildObjects(ctx, logger, pr, taskRuns, customRuns) + + return updatePipelineRunStatusFromChildObjects(ctx, logger, pr, childPipelineRuns, taskRuns, customRuns) } -func updatePipelineRunStatusFromChildObjects(ctx context.Context, logger *zap.SugaredLogger, pr *v1.PipelineRun, taskRuns []*v1.TaskRun, customRuns []*v1beta1.CustomRun) error { - updatePipelineRunStatusFromChildRefs(logger, pr, taskRuns, customRuns) +func updatePipelineRunStatusFromChildObjects(ctx context.Context, logger *zap.SugaredLogger, pr *v1.PipelineRun, childPipelineRuns []*v1.PipelineRun, taskRuns []*v1.TaskRun, customRuns []*v1beta1.CustomRun) error { + updatePipelineRunStatusFromChildRefs(logger, pr, childPipelineRuns, taskRuns, customRuns) return validateChildObjectsInPipelineRunStatus(ctx, pr.Status) } @@ -1562,7 +1646,7 @@ func validateChildObjectsInPipelineRunStatus(ctx context.Context, prs v1.Pipelin for _, cr := range prs.ChildReferences { switch cr.Kind { - case taskRun, customRun: + case taskRun, customRun, pipelineRun: continue default: err = errors.Join(err, fmt.Errorf("child with name %s has unknown kind %s", cr.Name, cr.Kind)) @@ -1572,7 +1656,23 @@ func validateChildObjectsInPipelineRunStatus(ctx context.Context, prs v1.Pipelin return err } -// filterTaskRunsForPipelineRunStatus returns TaskRuns owned by the PipelineRun. +// filterChildPipelineRunsForParentPipelineRunStatus returns child (PinP) PipelineRuns owned by the parent PipelineRun. +func filterChildPipelineRunsForParentPipelineRunStatus(logger *zap.SugaredLogger, pr *v1.PipelineRun, childPipelineRuns []*v1.PipelineRun) []*v1.PipelineRun { + var owned []*v1.PipelineRun + + for _, child := range childPipelineRuns { + // Only process child (PinP) PipelineRuns that are owned by this parent PipelineRun. + // This skips PipelineRuns that are indirectly created by the PipelineRun (e.g. by custom tasks). + if len(child.OwnerReferences) == 0 || child.OwnerReferences[0].UID != pr.ObjectMeta.UID { + logger.Debugf("Found a child (PIP) PipelineRun %s that is not owned by this parent PipelineRun", child.Name) + continue + } + owned = append(owned, child) + } + + return owned +} + func filterTaskRunsForPipelineRunStatus(logger *zap.SugaredLogger, pr *v1.PipelineRun, trs []*v1.TaskRun) []*v1.TaskRun { var ownedTaskRuns []*v1.TaskRun @@ -1618,22 +1718,46 @@ func filterCustomRunsForPipelineRunStatus(logger *zap.SugaredLogger, pr *v1.Pipe return names, taskLabels, gvks, statuses } -func updatePipelineRunStatusFromChildRefs(logger *zap.SugaredLogger, pr *v1.PipelineRun, trs []*v1.TaskRun, customRuns []*v1beta1.CustomRun) { - // If no TaskRun or CustomRun was found, nothing to be done. We never remove child references from the status. +func updatePipelineRunStatusFromChildRefs(logger *zap.SugaredLogger, pr *v1.PipelineRun, childPipelineRuns []*v1.PipelineRun, trs []*v1.TaskRun, customRuns []*v1beta1.CustomRun) { + // If no child (PinP) PipelineRun, TaskRun or CustomRun was found, nothing to be done. We never remove child references from the status. // We do still return an empty map of TaskRun/Run names keyed by PipelineTask name for later functions. - if len(trs) == 0 && len(customRuns) == 0 { + if len(childPipelineRuns) == 0 && len(trs) == 0 && len(customRuns) == 0 { return } - // Map PipelineTask names to TaskRun child references that were already in the status + // Map PipelineTask names to child (PinP) PipelineRun, TaskRun or CustomRun child references that were already in the status childRefByName := make(map[string]*v1.ChildStatusReference) for i := range pr.Status.ChildReferences { childRefByName[pr.Status.ChildReferences[i].Name] = &pr.Status.ChildReferences[i] } - taskRuns := filterTaskRunsForPipelineRunStatus(logger, pr, trs) + filteredChildPipelineRuns := filterChildPipelineRunsForParentPipelineRunStatus(logger, pr, childPipelineRuns) + + // Loop over all the child (PinP) PipelineRuns associated to the parent PipelineRun + for _, fcpr := range filteredChildPipelineRuns { + labels := fcpr.GetLabels() + pipelineTaskName := labels[pipeline.PipelineTaskLabelKey] + + // this child pipeline run is already in the status + if _, ok := childRefByName[fcpr.Name]; ok { + continue + } + + logger.Infof("Found a child (PinP) PipelineRun %s that was missing from the parent PipelineRun status", fcpr.Name) + // Since this was recovered now, add it to the map, or it might be overwritten + childRefByName[fcpr.Name] = &v1.ChildStatusReference{ + TypeMeta: runtime.TypeMeta{ + APIVersion: v1.SchemeGroupVersion.String(), + Kind: pipelineRun, + }, + Name: fcpr.Name, + PipelineTaskName: pipelineTaskName, + } + } + + taskRuns := filterTaskRunsForPipelineRunStatus(logger, pr, trs) // Loop over all the TaskRuns associated to Tasks for _, tr := range taskRuns { lbls := tr.GetLabels() @@ -1658,7 +1782,6 @@ func updatePipelineRunStatusFromChildRefs(logger *zap.SugaredLogger, pr *v1.Pipe // Get the names, their task label values, and their group/version/kind info for all CustomRuns or Runs associated with the PipelineRun names, taskLabels, gvks, _ := filterCustomRunsForPipelineRunStatus(logger, pr, customRuns) - // Loop over that data and populate the child references for idx := range names { name := names[idx] diff --git a/pkg/reconciler/pipelinerun/pipelinerun_pinp_test.go b/pkg/reconciler/pipelinerun/pipelinerun_pinp_test.go new file mode 100644 index 00000000000..adf528bc75b --- /dev/null +++ b/pkg/reconciler/pipelinerun/pipelinerun_pinp_test.go @@ -0,0 +1,460 @@ +package pipelinerun + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/pkg/apis/pipeline" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + th "github.com/tektoncd/pipeline/pkg/reconciler/testing" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + "github.com/tektoncd/pipeline/test/names" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ktesting "k8s.io/client-go/testing" +) + +// TestReconcile_ChildPipelineRunPipelineSpec verifies the reconciliation logic for PipelineRuns that create child +// PipelineRuns from PipelineSpecs. It tests scenarios with one or more child PipelineRuns (with mixed TaskSpec and +// TaskRef), ensuring that: +// - The parent PipelineRun is correctly marked as running after reconciliation. +// - The correct number of child PipelineRuns are created and referenced in the parent status. +// - The actual child PipelineRuns match the expected specifications. +// - The expected events are emitted during reconciliation. +func TestReconcile_ChildPipelineRunPipelineSpec(t *testing.T) { + names.TestingSeed() + // GIVEN + namespace := "foo" + parentPipelineRunName := "parent-pipeline-run" + parentPipeline1, + parentPipelineRun1, + expectedChildPipelineRun1 := th.OnePipelineInPipeline(t, namespace, parentPipelineRunName) + _, parentPipeline2, + parentPipelineRun2, + expectedChildPipelineRun1And2 := th.TwoPipelinesInPipelineMixedTasks(t, namespace, parentPipelineRunName) + expectedEvents := []string{ + "Normal Started", + "Normal Running Tasks Completed: 0", + } + testCases := []struct { + name string + parentPipeline *v1.Pipeline + parentPipelineRun *v1.PipelineRun + expectedChildPipelineRuns []*v1.PipelineRun + }{ + { + name: "one child PipelineRun from PipelineSpec", + parentPipeline: parentPipeline1, + parentPipelineRun: parentPipelineRun1, + expectedChildPipelineRuns: []*v1.PipelineRun{expectedChildPipelineRun1}, + }, + { + name: "two child PipelineRuns from PipelineSpecs, one with TaskSpec and one with TaskRef", + parentPipeline: parentPipeline2, + parentPipelineRun: parentPipelineRun2, + expectedChildPipelineRuns: expectedChildPipelineRun1And2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testData := test.Data{ + PipelineRuns: []*v1.PipelineRun{tc.parentPipelineRun}, + Pipelines: []*v1.Pipeline{tc.parentPipeline}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + + // WHEN + reconciledRun, childPipelineRuns := reconcileOncePinP( + t, + testData, + namespace, + tc.parentPipelineRun.Name, + expectedEvents, + ) + + // THEN + validatePinP( + t, + reconciledRun.Status, + reconciledRun.Name, + childPipelineRuns, + tc.expectedChildPipelineRuns, + ) + }) + } +} + +func reconcileOncePinP( + t *testing.T, + testData test.Data, + namespace, + parentPipelineRunName string, + expectedEvents []string, +) (*v1.PipelineRun, map[string]*v1.PipelineRun) { + t.Helper() + + prt := newPipelineRunTest(t, testData) + defer prt.Cancel() + + // reconcile once given parent PipelineRun + reconciledRun, clients := prt.reconcileRun( + namespace, + parentPipelineRunName, + expectedEvents, + false, + ) + + // fetch created child PipelineRun(s) + childPipelineRuns := getChildPipelineRunsForPipelineRun( + prt.TestAssets.Ctx, + t, + clients, + namespace, + parentPipelineRunName, + ) + + return reconciledRun, childPipelineRuns +} + +func getChildPipelineRunsForPipelineRun( + ctx context.Context, + t *testing.T, + clients test.Clients, + namespace, parentPipelineRunName string, +) map[string]*v1.PipelineRun { + t.Helper() + + opt := metav1.ListOptions{ + LabelSelector: pipeline.PipelineRunLabelKey + "=" + parentPipelineRunName, + } + + pipelineRunList, err := clients. + Pipeline. + TektonV1(). + PipelineRuns(namespace). + List(ctx, opt) + if err != nil { + t.Fatalf("failed to list child PipelineRuns: %v", err) + } + + result := make(map[string]*v1.PipelineRun) + for _, pipelineRun := range pipelineRunList.Items { + result[pipelineRun.Name] = &pipelineRun + } + + return result +} + +func validatePinP( + t *testing.T, + reconciledRunStatus v1.PipelineRunStatus, + reconciledRunName string, + childPipelineRuns map[string]*v1.PipelineRun, + expectedChildPipelineRuns []*v1.PipelineRun, +) { + t.Helper() + + // validate parent PipelineRun is in progress; the status should reflect that + th.CheckPipelineRunConditionStatusAndReason( + t, + reconciledRunStatus, + corev1.ConditionUnknown, + v1.PipelineRunReasonRunning.String(), + ) + + // validate there is the correct number of child references with the correct names of the child PipelineRuns + th.VerifyChildPipelineRunStatusesCount(t, reconciledRunStatus, len(expectedChildPipelineRuns)) + var expectedNames []string + for _, cpr := range expectedChildPipelineRuns { + expectedNames = append(expectedNames, cpr.Name) + } + th.VerifyChildPipelineRunStatusesNames(t, reconciledRunStatus, expectedNames...) + + validateChildPipelineRunCount(t, childPipelineRuns, len(expectedChildPipelineRuns)) + + // validate the actual child PipelineRuns are as expected + for _, expectedChild := range expectedChildPipelineRuns { + actualChild := getChildPipelineRunByName(t, childPipelineRuns, expectedChild.Name) + if d := cmp.Diff(expectedChild, actualChild, ignoreTypeMeta, ignoreResourceVersion); d != "" { + t.Errorf("expected to see child PipelineRun %v created. Diff %s", expectedChild, diff.PrintWantGot(d)) + } + + // validate correct owner reference + if len(actualChild.OwnerReferences) != 1 || actualChild.OwnerReferences[0].Name != reconciledRunName { + t.Errorf("Child PipelineRun should be owned by parent %s", reconciledRunName) + } + } +} + +func validateChildPipelineRunCount(t *testing.T, pipelineRuns map[string]*v1.PipelineRun, expectedCount int) { + t.Helper() + + actualCount := len(pipelineRuns) + if actualCount != expectedCount { + t.Fatalf("Expected %d child PipelineRuns, got %d", expectedCount, actualCount) + } +} + +func getChildPipelineRunByName(t *testing.T, pipelineRuns map[string]*v1.PipelineRun, expectedName string) *v1.PipelineRun { + t.Helper() + + pr, exist := pipelineRuns[expectedName] + if !exist { + t.Fatalf("Expected pipelinerun %s does not exist", expectedName) + } + + return pr +} + +// TestReconcile_NestedChildPipelineRuns verifies the reconciliation logic for multi-level nested PipelineRuns. +// It tests a parent pipeline that creates a child pipeline, which itself creates a grandchild pipeline. +// This test requires multiple reconciliation cycles: +// - First reconciliation: Parent creates child pipeline +// - Second reconciliation: Child creates grandchild pipeline +func TestReconcile_NestedChildPipelineRuns(t *testing.T) { + names.TestingSeed() + // GIVEN + namespace := "foo" + parentPipelineRunName := "parent-pipeline-run" + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun, + expectedGrandchildPipelineRun := th.NestedPipelinesInPipeline(t, namespace, parentPipelineRunName) + expectedEvents := []string{ + "Normal Started", + "Normal Running Tasks Completed: 0", + } + testData := test.Data{ + PipelineRuns: []*v1.PipelineRun{parentPipelineRun}, + Pipelines: []*v1.Pipeline{parentPipeline}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + + // WHEN + // first reconcile parent PipelineRun once which creates the child + reconciledRunParent, childPipelineRuns := reconcileOncePinP( + t, + testData, + namespace, + parentPipelineRun.Name, + expectedEvents, + ) + + // THEN + validatePinP( + t, + reconciledRunParent.Status, + reconciledRunParent.Name, + childPipelineRuns, + []*v1.PipelineRun{expectedChildPipelineRun}, + ) + + // GIVEN + // use the child from previous reconcile + childPipelineRun := getChildPipelineRunByName(t, childPipelineRuns, expectedChildPipelineRun.Name) + childTestData := test.Data{ + PipelineRuns: []*v1.PipelineRun{childPipelineRun}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + + // WHEN + // second reconcile child PipelineRun which creates the grandchild + reconciledRunChild, grandchildPipelineRuns := reconcileOncePinP( + t, + childTestData, + namespace, + childPipelineRun.Name, + expectedEvents, + ) + + // THEN + validatePinP( + t, + reconciledRunChild.Status, + reconciledRunChild.Name, + grandchildPipelineRuns, + []*v1.PipelineRun{expectedGrandchildPipelineRun}, + ) +} + +func TestReconcile_PropagateLabelsAndAnnotationsToChildPipelineRun(t *testing.T) { + names.TestingSeed() + // GIVEN + namespace := "foo" + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun := th.OnePipelineInPipeline(t, namespace, "parent-pipeline-run") + expectedChildPipelineRun = th.WithAnnotationAndLabel(expectedChildPipelineRun, false) + testData := test.Data{ + PipelineRuns: []*v1.PipelineRun{th.WithAnnotationAndLabel(parentPipelineRun, true)}, + Pipelines: []*v1.Pipeline{parentPipeline}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + + // WHEN + reconciledRun, childPipelineRuns := reconcileOncePinP( + t, + testData, + namespace, + parentPipelineRun.Name, + []string{}, + ) + + // THEN + validatePinP( + t, + reconciledRun.Status, + reconciledRun.Name, + childPipelineRuns, + []*v1.PipelineRun{expectedChildPipelineRun}, + ) +} + +func TestReconcile_ChildPipelineRunHasDefaultLabels(t *testing.T) { + names.TestingSeed() + // GIVEN + namespace := "foo" + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun := th.OnePipelineInPipeline(t, namespace, "parent-pipeline-run") + expectedLabels := map[string]string{ + pipeline.PipelineRunLabelKey: parentPipelineRun.Name, + pipeline.PipelineLabelKey: parentPipelineRun.Spec.PipelineRef.Name, + pipeline.PipelineRunUIDLabelKey: string(parentPipelineRun.UID), + pipeline.PipelineTaskLabelKey: parentPipeline.Spec.Tasks[0].Name, + pipeline.MemberOfLabelKey: v1.PipelineTasks, + } + testData := test.Data{ + PipelineRuns: []*v1.PipelineRun{parentPipelineRun}, + Pipelines: []*v1.Pipeline{parentPipeline}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + + // WHEN + _, childPipelineRuns := reconcileOncePinP( + t, + testData, + namespace, + parentPipelineRun.Name, + []string{}, + ) + + // THEN + validateChildPipelineRunCount(t, childPipelineRuns, 1) + + actualLabels := childPipelineRuns[expectedChildPipelineRun.Name].Labels + for k, v := range expectedLabels { + if actualLabels[k] != v { + t.Errorf("Expected label %q=%q on child PipelineRun, got %q", k, v, actualLabels[k]) + } + } +} + +func TestReconcile_ChildPipelineRunCreationError(t *testing.T) { + names.TestingSeed() + // GIVEN + namespace := "foo" + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun := th.OnePipelineInPipeline(t, namespace, "parent-pipeline-run") + testData := test.Data{ + PipelineRuns: []*v1.PipelineRun{parentPipelineRun}, + Pipelines: []*v1.Pipeline{parentPipeline}, + ConfigMaps: []*corev1.ConfigMap{withEnabledAlphaAPIFields(newFeatureFlagsConfigMap())}, + } + testCases := []struct { + name string + creationErr clientError + }{ + { + name: "invalid", + creationErr: clientError{ + verb: "create", + resource: "pipelineruns", + actualError: apierrors.NewInvalid( + schema.GroupKind{}, + expectedChildPipelineRun.Name, + field.ErrorList{}), + }, + }, + { + name: "bad request", + creationErr: clientError{ + verb: "create", + resource: "pipelineruns", + actualError: apierrors.NewBadRequest("bad request"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // WHEN + reconciledRun := reconcileWithError( + t, + testData, + namespace, + parentPipelineRun.Name, + tc.creationErr, + ) + + // THEN + th.CheckPipelineRunConditionStatusAndReason( + t, + reconciledRun.Status, + corev1.ConditionFalse, + "CreateRunFailed", + ) + + if reconciledRun.Status.CompletionTime == nil { + t.Errorf("Expected a CompletionTime on invalid PipelineRun but was nil") + } + }) + } +} + +type clientError struct { + verb, + resource string + actualError error +} + +func reconcileWithError( + t *testing.T, + testData test.Data, + namespace, + pipelineRunName string, + clientErr clientError, +) *v1.PipelineRun { + t.Helper() + + prt := newPipelineRunTest(t, testData) + defer prt.Cancel() + + // simulate error when creating child resource + prt.TestAssets.Clients. + Pipeline. + PrependReactor( + clientErr.verb, + clientErr.resource, + func(_ ktesting.Action) (bool, runtime.Object, error) { + return true, nil, clientErr.actualError + }, + ) + + reconciledRun, _ := prt.reconcileRun( + namespace, + pipelineRunName, + []string{}, + true, + ) + + return reconciledRun +} diff --git a/pkg/reconciler/pipelinerun/pipelinerun_updatestatus_test.go b/pkg/reconciler/pipelinerun/pipelinerun_updatestatus_test.go index 0c301c919d2..c397a81a2aa 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_updatestatus_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_updatestatus_test.go @@ -440,7 +440,7 @@ pipelineTaskName: task Status: tc.prStatus, } - updatePipelineRunStatusFromChildRefs(logger, pr, tc.trs, tc.customRuns) + updatePipelineRunStatusFromChildRefs(logger, pr, []*v1.PipelineRun{}, tc.trs, tc.customRuns) actualPrStatus := pr.Status @@ -573,7 +573,7 @@ metadata: Status: tc.prStatus(), } - if err := updatePipelineRunStatusFromChildObjects(ctx, logger, pr, tc.trs, tc.runs); err != nil { + if err := updatePipelineRunStatusFromChildObjects(ctx, logger, pr, []*v1.PipelineRun{}, tc.trs, tc.runs); err != nil { t.Fatalf("received an unexpected error: %v", err) } diff --git a/pkg/reconciler/testing/factory.go b/pkg/reconciler/testing/factory.go new file mode 100644 index 00000000000..7825bf17b8f --- /dev/null +++ b/pkg/reconciler/testing/factory.go @@ -0,0 +1,333 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/tektoncd/pipeline/pkg/apis/pipeline" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/test/parse" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var ( + trueb = true +) + +// TwoPipelinesInPipelineMixedTasks creates a parent Pipeline with two embedded child Pipelines: +// one using an embedded taskSpec and the other using a taskRef. It also creates a PipelineRun +// for the parent Pipeline, the expected child PipelineRuns for each child Pipeline and the +// referenced task. +func TwoPipelinesInPipelineMixedTasks(t *testing.T, namespace, parentPipelineRunName string) (*v1.Task, *v1.Pipeline, *v1.PipelineRun, []*v1.PipelineRun) { + t.Helper() + uid := "bar" + taskName := "ref-task" + parentPipelineName := "parent-pipeline-mixed" + childPipelineName1 := "child-pipeline-taskspec" + childPipelineName2 := "child-pipeline-taskref" + childPipelineTaskName1 := "child-taskspec" + childPipelineTaskName2 := "child-taskref" + + task := parse.MustParseV1Task(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from referenced task in child PipelineRun 2!"' +`, taskName, namespace)) + + parentPipeline := parse.MustParseV1Pipeline(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + tasks: + - name: %s + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from child PipelineRun 1!"' + - name: %s + pipelineSpec: + tasks: + - name: %s + taskRef: + name: %s +`, parentPipelineName, namespace, childPipelineName1, childPipelineTaskName1, childPipelineName2, childPipelineTaskName2, taskName)) + + parentPipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s + uid: %s +spec: + pipelineRef: + name: %s +`, parentPipelineRunName, namespace, uid, parentPipelineName)) + + expectedName1 := parentPipelineRunName + "-" + childPipelineName1 + expectedChildPipelineRun1 := parse.MustParseChildPipelineRunWithObjectMeta( + t, + childPipelineRunWithObjectMeta( + expectedName1, + namespace, + parentPipelineRunName, + parentPipelineName, + childPipelineName1, + uid, + ), + fmt.Sprintf(` +spec: + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from child PipelineRun 1!"' +`, childPipelineTaskName1), + ) + + expectedName2 := parentPipelineRunName + "-" + childPipelineName2 + expectedChildPipelineRun2 := parse.MustParseChildPipelineRunWithObjectMeta( + t, + childPipelineRunWithObjectMeta( + expectedName2, + namespace, + parentPipelineRunName, + parentPipelineName, + childPipelineName2, + uid, + ), + fmt.Sprintf(` +spec: + pipelineSpec: + tasks: + - name: %s + taskRef: + name: %s +`, childPipelineTaskName2, taskName), + ) + + return task, parentPipeline, parentPipelineRun, []*v1.PipelineRun{expectedChildPipelineRun1, expectedChildPipelineRun2} +} + +// OnePipelineInPipeline creates a single Pipeline with one child pipeline using +// PipelineSpec with TaskSpec. It also creates the according PipelineRun for it +// and the expected child PipelineRun against which the test will validate. +func OnePipelineInPipeline(t *testing.T, namespace, parentPipelineRunName string) (*v1.Pipeline, *v1.PipelineRun, *v1.PipelineRun) { + t.Helper() + uid := "bar" + parentPipelineName := "parent-pipeline" + childPipelineName := "child-pipeline" + childPipelineTaskName := "child-pipeline-task" + + parentPipeline := parse.MustParseV1Pipeline(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + tasks: + - name: %s + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from child PipelineRun!"' +`, parentPipelineName, namespace, childPipelineName, childPipelineTaskName)) + + parentPipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s + uid: %s +spec: + pipelineRef: + name: %s +`, parentPipelineRunName, namespace, uid, parentPipelineName)) + + expectedName := parentPipelineRunName + "-" + childPipelineName + expectedChildPipelineRun := parse.MustParseChildPipelineRunWithObjectMeta( + t, + childPipelineRunWithObjectMeta( + expectedName, + namespace, + parentPipelineRunName, + parentPipelineName, + childPipelineName, + uid, + ), + fmt.Sprintf(` +spec: + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from child PipelineRun!"' +`, childPipelineTaskName), + ) + + return parentPipeline, parentPipelineRun, expectedChildPipelineRun +} + +func WithAnnotationAndLabel(pr *v1.PipelineRun, withUnused bool) *v1.PipelineRun { + if pr.Annotations == nil { + pr.Annotations = map[string]string{} + } + pr.Annotations["tekton.test/annotation"] = "test-annotation-value" + + if pr.Labels == nil { + pr.Labels = map[string]string{} + } + pr.Labels["tekton.test/label"] = "test-label-value" + + if withUnused { + pr.Labels["tekton.dev/pipeline"] = "will-not-be-used" + } + + return pr +} + +func childPipelineRunWithObjectMeta( + childPipelineRunName, + ns, + parentPipelineRunName, + parentPipelineName, + pipelineTaskName, + uid string, +) metav1.ObjectMeta { + om := metav1.ObjectMeta{ + Name: childPipelineRunName, + Namespace: ns, + OwnerReferences: []metav1.OwnerReference{{ + Kind: pipeline.PipelineRunControllerName, + Name: parentPipelineRunName, + APIVersion: "tekton.dev/v1", + Controller: &trueb, + BlockOwnerDeletion: &trueb, + UID: types.UID(uid), + }}, + Labels: map[string]string{ + pipeline.PipelineLabelKey: parentPipelineName, + pipeline.PipelineRunLabelKey: parentPipelineRunName, + pipeline.PipelineTaskLabelKey: pipelineTaskName, + pipeline.PipelineRunUIDLabelKey: uid, + pipeline.MemberOfLabelKey: v1.PipelineTasks, + }, + Annotations: map[string]string{}, + } + + return om +} + +// NestedPipelinesInPipeline creates a three-level nested pipeline structure: +// Parent Pipeline -> Child Pipeline -> Grandchild Pipeline +// Returns the parent pipeline, parent pipelinerun, expected child pipelinerun, and expected grandchild pipelinerun +func NestedPipelinesInPipeline(t *testing.T, namespace, parentPipelineRunName string) (*v1.Pipeline, *v1.PipelineRun, *v1.PipelineRun, *v1.PipelineRun) { + t.Helper() + uid := "nested" + parentPipelineName := "parent-pipeline" + childPipelineName := "child-ppl" + grandchildPipelineName := "grandchild-ppl" + grandchildPipelineTaskName := "grandchild-task" + + parentPipeline := parse.MustParseV1Pipeline(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + tasks: + - name: %s + pipelineSpec: + tasks: + - name: %s + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from grandchild Pipeline!"' +`, parentPipelineName, namespace, childPipelineName, grandchildPipelineName, grandchildPipelineTaskName)) + + parentPipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s + uid: %s +spec: + pipelineRef: + name: %s +`, parentPipelineRunName, namespace, uid, parentPipelineName)) + + // expected child pipeline run created by parent + expectedChildName := parentPipelineRunName + "-" + childPipelineName + expectedChildPipelineRun := parse.MustParseChildPipelineRunWithObjectMeta( + t, + childPipelineRunWithObjectMeta( + expectedChildName, + namespace, + parentPipelineRunName, + parentPipelineName, + childPipelineName, + uid, + ), + fmt.Sprintf(` +spec: + pipelineSpec: + tasks: + - name: %s + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from grandchild Pipeline!"' +`, grandchildPipelineName, grandchildPipelineTaskName), + ) + + // expected grandchild pipeline run created by child + expectedGrandchildName := expectedChildName + "-" + grandchildPipelineName + expectedGrandchildPipelineRun := parse.MustParseChildPipelineRunWithObjectMeta( + t, + childPipelineRunWithObjectMeta( + expectedGrandchildName, + namespace, + expectedChildName, + expectedChildName, + grandchildPipelineName, + "", // keep empty, UID is not set on actual child PipelineRun by fake client + ), + fmt.Sprintf(` +spec: + pipelineSpec: + tasks: + - name: %s + taskSpec: + steps: + - name: mystep + image: mirror.gcr.io/busybox + script: 'echo "Hello from grandchild Pipeline!"' +`, grandchildPipelineTaskName), + ) + + return parentPipeline, parentPipelineRun, expectedChildPipelineRun, expectedGrandchildPipelineRun +} diff --git a/pkg/reconciler/testing/status.go b/pkg/reconciler/testing/status.go index ea38b884d92..c2966de71ef 100644 --- a/pkg/reconciler/testing/status.go +++ b/pkg/reconciler/testing/status.go @@ -111,3 +111,13 @@ func VerifyCustomRunOrRunStatusesNames(t *testing.T, prStatus v1.PipelineRunStat t.Helper() verifyNames(t, prStatus, expectedNames, customRun) } + +func VerifyChildPipelineRunStatusesCount(t *testing.T, prStatus v1.PipelineRunStatus, expectedCount int) { + t.Helper() + verifyCount(t, prStatus, expectedCount, pipelineRun) +} + +func VerifyChildPipelineRunStatusesNames(t *testing.T, prStatus v1.PipelineRunStatus, expectedNames ...string) { + t.Helper() + verifyNames(t, prStatus, expectedNames, pipelineRun) +} diff --git a/test/parse/yaml.go b/test/parse/yaml.go index a9983bd94be..79ddc5d7907 100644 --- a/test/parse/yaml.go +++ b/test/parse/yaml.go @@ -210,3 +210,11 @@ func MustParseCustomRunWithObjectMeta(t *testing.T, objectMeta metav1.ObjectMeta r.ObjectMeta = objectMeta return r } + +// MustParseChildPipelineRunWithObjectMeta parses YAML to *v1.PipelineRun and adds objectMeta to it +func MustParseChildPipelineRunWithObjectMeta(t *testing.T, objectMeta metav1.ObjectMeta, asYAML string) *v1.PipelineRun { + t.Helper() + pr := MustParseV1PipelineRun(t, asYAML) + pr.ObjectMeta = objectMeta + return pr +} diff --git a/test/pipelinerun_pinp_test.go b/test/pipelinerun_pinp_test.go new file mode 100644 index 00000000000..329eab10827 --- /dev/null +++ b/test/pipelinerun_pinp_test.go @@ -0,0 +1,343 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2025 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "context" + "fmt" + "testing" + + "github.com/tektoncd/pipeline/pkg/apis/pipeline" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + th "github.com/tektoncd/pipeline/pkg/reconciler/testing" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPipelineRun_OneChildPipelineRunFromPipelineSpec(t *testing.T) { + ctx, cancel, c, namespace := setupPinP(t) + defer cancel() + defer tearDownOptDump(ctx, t, c, namespace, true) + + // GIVEN + t.Logf("Setting up test resources for one child PipelineRun from PipelineSpec in namespace %q", namespace) + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun := th.OnePipelineInPipeline(t, namespace, "parent-pipeline-run") + parentPipelineRun = th.WithAnnotationAndLabel(parentPipelineRun, false) + expectedKinds := createKindsMap(parentPipelineRun, []*v1.PipelineRun{expectedChildPipelineRun}) + expectedEventsAmount := 3 + + // WHEN + createResourcesAndWaitForPipelineRun(ctx, t, c, namespace, parentPipeline, parentPipelineRun, nil) + + // THEN + assertPinP(ctx, t, c, namespace, expectedChildPipelineRun) + assertEvents(ctx, t, expectedEventsAmount, expectedKinds, c, namespace) +} + +func createKindsMap(parentPipelineRun *v1.PipelineRun, childPipelineRuns []*v1.PipelineRun) map[string][]string { + prNames := []string{parentPipelineRun.Name} + for _, cpr := range childPipelineRuns { + prNames = append(prNames, cpr.Name) + } + + var trNames []string + for _, cpr := range childPipelineRuns { + // collect names of TaskRuns; ignore nested PipelineRuns + if cpr.Spec.PipelineSpec.Tasks[0].PipelineSpec == nil { + trNames = append(trNames, cpr.Name+"-"+cpr.Spec.PipelineSpec.Tasks[0].Name) + } + } + + return map[string][]string{ + "PipelineRun": prNames, + "TaskRun": trNames, + } +} + +func assertPinP( + ctx context.Context, + t *testing.T, + c *clients, + namespace string, + childPipelineRun *v1.PipelineRun, +) { + t.Helper() + + t.Logf("Making sure the expected child PipelineRun %q was created", childPipelineRun.Name) + actualCpr, err := c.V1PipelineRunClient.Get(ctx, childPipelineRun.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Error listing child PipelineRuns for PipelineRun %s: %s", childPipelineRun.Name, err) + } + th.CheckPipelineRunConditionStatusAndReason( + t, + actualCpr.Status, + corev1.ConditionTrue, + v1.PipelineRunReasonSuccessful.String(), + ) + t.Logf("Checking that labels were propagated correctly for child PipelineRun %q", actualCpr.Name) + directParentPrName := actualCpr.OwnerReferences[0].Name + checkLabelPropagationToChildPipelineRun(ctx, t, c, namespace, directParentPrName, actualCpr) + t.Logf("Checking that annotations were propagated correctly for child PipelineRun %q", actualCpr.Name) + checkAnnotationPropagationToChildPipelineRun(ctx, t, c, namespace, directParentPrName, actualCpr) +} + +func TestPipelineRun_TwoChildPipelineRunsMixedTasks(t *testing.T) { + ctx, cancel, c, namespace := setupPinP(t) + defer cancel() + defer tearDownOptDump(ctx, t, c, namespace, true) + + // GIVEN + t.Logf("Setting up test resources for two child PipelineRuns (mixed tasks) in namespace %q", namespace) + task, + parentPipeline, + parentPipelineRun, + expectedChildPipelineRuns := th.TwoPipelinesInPipelineMixedTasks(t, namespace, "parent-pipeline-mixed") + parentPipelineRun = th.WithAnnotationAndLabel(parentPipelineRun, false) + expectedKinds := createKindsMap(parentPipelineRun, expectedChildPipelineRuns) + expectedEventsAmount := 5 + + // WHEN + createResourcesAndWaitForPipelineRun(ctx, t, c, namespace, parentPipeline, parentPipelineRun, task) + + // THEN + assertPinP(ctx, t, c, namespace, expectedChildPipelineRuns[0]) + assertPinP(ctx, t, c, namespace, expectedChildPipelineRuns[1]) + assertEvents(ctx, t, expectedEventsAmount, expectedKinds, c, namespace) +} + +func TestPipelineRun_TwoLevelDeepNestedChildPipelineRuns(t *testing.T) { + ctx, cancel, c, namespace := setupPinP(t) + defer cancel() + defer tearDownOptDump(ctx, t, c, namespace, true) + + // GIVEN + t.Logf("Setting up test resources for two level deep nested child PipelineRuns in namespace %q", namespace) + parentPipeline, + parentPipelineRun, + expectedChildPipelineRun, + expectedGrandchildPipelineRun := th.NestedPipelinesInPipeline(t, namespace, "parent-pipeline-nested") + parentPipelineRun = th.WithAnnotationAndLabel(parentPipelineRun, false) + expectedKinds := createKindsMap( + parentPipelineRun, + []*v1.PipelineRun{ + expectedChildPipelineRun, + expectedGrandchildPipelineRun, + }) + expectedEventsAmount := 4 + + // WHEN + createResourcesAndWaitForPipelineRun(ctx, t, c, namespace, parentPipeline, parentPipelineRun, nil) + + // THEN + assertPinP(ctx, t, c, namespace, expectedChildPipelineRun) + assertPinP(ctx, t, c, namespace, expectedGrandchildPipelineRun) + assertEvents(ctx, t, expectedEventsAmount, expectedKinds, c, namespace) +} + +func createResourcesAndWaitForPipelineRun( + ctx context.Context, + t *testing.T, + c *clients, + namespace string, + pipeline *v1.Pipeline, + pipelineRun *v1.PipelineRun, + task *v1.Task, +) { + t.Helper() + + if _, err := c.V1PipelineClient.Create(ctx, pipeline, metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create Pipeline `%s`: %s", pipeline.Name, err) + } + + if task != nil { + if _, err := c.V1TaskClient.Create(ctx, task, metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create Task `%s`: %s", task.Name, err) + } + } + + if _, err := c.V1PipelineRunClient.Create(ctx, pipelineRun, metav1.CreateOptions{}); err != nil { + t.Fatalf("Failed to create PipelineRun `%s`: %s", pipelineRun.Name, err) + } + + t.Logf("Waiting for PipelineRun %q in namespace %q to complete", pipelineRun.Name, namespace) + if err := WaitForPipelineRunState( + ctx, + c, + pipelineRun.Name, + timeout, + PipelineRunSucceed(pipelineRun.Name), + "PipelineRunSuccess", + v1Version, + ); err != nil { + t.Fatalf("Error waiting for PipelineRun %s to finish: %s", pipelineRun.Name, err) + } +} + +func assertEvents( + ctx context.Context, + t *testing.T, + expectedEventsAmount int, + matchKinds map[string][]string, + c *clients, + namespace string, +) { + t.Helper() + + t.Logf( + "Making sure %d events were created from parent PipelineRun, child PipelineRun and TaskRun with kinds %v", + expectedEventsAmount, + matchKinds, + ) + + events, err := collectMatchingEvents( + ctx, + c.KubeClient, + namespace, + matchKinds, + "Succeeded", + ) + if err != nil { + t.Fatalf("Failed to collect matching events: %q", err) + } + if len(events) != expectedEventsAmount { + collectedEvents := "" + for i, event := range events { + collectedEvents += fmt.Sprintf("%#v", event) + if i < (len(events) - 1) { + collectedEvents += ", " + } + } + t.Fatalf( + "Expected %d number of successful events from parent PipelineRun, child PipelineRun and "+ + "TaskRun but got %d; list of received events: %#v", + expectedEventsAmount, + len(events), + collectedEvents, + ) + } +} + +// checkLabelPropagationToChildPipelineRun checks that labels are correctly propagating from +// Pipelines and PipelineRuns to child/grandchild PipelineRuns. +func checkLabelPropagationToChildPipelineRun( + ctx context.Context, + t *testing.T, + c *clients, + namespace string, + parentPrName string, + childPr *v1.PipelineRun, +) { + t.Helper() + + labels := make(map[string]string) + + parentPr, err := c.V1PipelineRunClient.Get(ctx, parentPrName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Couldn't get expected PipelineRun for %s: %s", childPr.Name, err) + } + + // Does the parent PipelineRun have an owner? If not its the initial PipelineRun + // and we have to check for labels propagated from the Pipeline. + if parentPr.OwnerReferences == nil { + p, err := c.V1PipelineClient.Get(ctx, parentPr.Spec.PipelineRef.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Couldn't get expected Pipeline for %s: %s", parentPr.Name, err) + } + // Extract every label the Pipeline has. + for key, val := range p.ObjectMeta.Labels { + labels[key] = val + } + // This label is added to every PipelineRun by the PipelineRun controller. + labels[pipeline.PipelineLabelKey] = p.Name + // Check label propagation from Pipeline to parent PipelineRun. + assertLabelsMatch(t, labels, parentPr.ObjectMeta.Labels) + t.Logf("Labels propagated from Pipeline to PipelineRun: %#v", labels) + } + + // Check label propagation from parent PipelineRun to child PipelineRun. + for key, val := range parentPr.ObjectMeta.Labels { + // Skip overwritten labels. + if key == pipeline.MemberOfLabelKey || + key == pipeline.PipelineLabelKey || + key == pipeline.PipelineRunLabelKey || + key == pipeline.PipelineRunUIDLabelKey || + key == pipeline.PipelineTaskLabelKey { + continue + } + + labels[key] = val + } + + // Child always references the parent PipelineRun its labels. + labels[pipeline.PipelineRunLabelKey] = parentPr.Name + // The parent PipelineRun references an existing Pipeline via PipelineRef + // the child PipelineRun does not reference any existing Pipeline but it uses the + // PipelineSpec embedded field, that is why its label "tekton.dev/pipeline:" is + // set to its own name. Refer to "propagatePipelineNameLabelToPipelineRun" for + // more implementation details. + labels[pipeline.PipelineLabelKey] = childPr.Name + assertLabelsMatch(t, labels, childPr.ObjectMeta.Labels) + t.Logf("Labels propagated from parent PipelineRun to child PipelineRun: %#v", labels) +} + +// checkAnnotationPropagationToChildPipelineRun checks that annotations are correctly propagating from +// Pipelines and PipelineRuns to child PipelineRuns. +func checkAnnotationPropagationToChildPipelineRun( + ctx context.Context, + t *testing.T, + c *clients, + namespace string, + parentPrName string, + childPr *v1.PipelineRun, +) { + t.Helper() + + annotations := make(map[string]string) + parentPr, err := c.V1PipelineRunClient.Get(ctx, parentPrName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Couldn't get expected PipelineRun for %s: %s", childPr.Name, err) + } + + // Does the parent PipelineRun have an owner? If not its the initial PipelineRun + // and we have to check for annotations propagated from the Pipeline. + if parentPr.OwnerReferences == nil { + p, err := c.V1PipelineClient.Get(ctx, parentPr.Spec.PipelineRef.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Couldn't get expected Pipeline for %s: %s", parentPr.Name, err) + } + for key, val := range p.ObjectMeta.Annotations { + annotations[key] = val + } + + assertAnnotationsMatch(t, annotations, parentPr.ObjectMeta.Annotations) + } + + // Check annotation propagation to child PipelineRuns. + for key, val := range parentPr.ObjectMeta.Annotations { + annotations[key] = val + } + assertAnnotationsMatch(t, annotations, childPr.ObjectMeta.Annotations) + + if len(annotations) > 0 { + t.Logf("Propagated annotations: %#v", annotations) + } +} diff --git a/test/util.go b/test/util.go index bb8a3600e28..7eee2a06f5f 100644 --- a/test/util.go +++ b/test/util.go @@ -85,6 +85,31 @@ func setup(ctx context.Context, t *testing.T, fn ...func(context.Context, *testi return c, namespace } +func setupPinP(t *testing.T) (context.Context, context.CancelFunc, *clients, string) { + t.Helper() + + t.Parallel() + ctx := t.Context() + ctx, cancel := context.WithCancel(ctx) + c, namespace := setup(ctx, t) + + knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf) + + t.Log("Activating alpha feature flags") + configMapData := map[string]string{"enable-api-fields": "alpha"} + if err := updateConfigMap( + ctx, + c.KubeClient, + system.Namespace(), + config.GetFeatureFlagsConfigName(), + configMapData, + ); err != nil { + t.Fatal(err) + } + + return ctx, cancel, c, namespace +} + func header(t *testing.T, text string) { t.Helper() left := "### " @@ -131,6 +156,17 @@ func tearDown(ctx context.Context, t *testing.T, cs *clients, namespace string) } } +// tearDownNoDump prevents dumping Task/Pipeline/PipelineRun/TaskRun yamls to the terminal +// when a test fails. Useful for investigating issues from terminal logs without the +// need to scroll over all the deployed resource yamls. +func tearDownOptDump(ctx context.Context, t *testing.T, c *clients, namespace string, dump bool) { + t.Helper() + if !dump { + c.KubeClient = nil + } + tearDown(ctx, t, c, namespace) +} + func initializeLogsAndMetrics(t *testing.T) { t.Helper() initMetrics.Do(func() {