Repository: kubeflow/kfp-tekton (language: python)

Issue:
fix(sdk): Update wait_for_run_completion function to handle tekton status;
**Which issue is resolved by this Pull Request:** 
Resolves #1223 

**Description of your changes:**

**Environment tested:**

* Python Version (use `python --version`):
* Tekton Version (use `tkn version`):
* Kubernetes Version (use `kubectl version`):
* OS (e.g. from `/etc/os-release`):

**Checklist:**
- [x] The title for your pull request (PR) should follow our title convention. [Learn more about the pull request title convention used in this repository](https://github.com/kubeflow/pipelines/blob/master/CONTRIBUTING.md#pull-request-title-convention). 
<!--
   PR titles examples:
    * `fix(frontend): fixes empty page. Fixes #1234`
       Use `fix` to indicate that this PR fixes a bug.
    * `feat(backend): configurable service account. Fixes #1234, fixes #1235`
       Use `feat` to indicate that this PR adds a new feature. 
    * `chore: set up changelog generation tools`
       Use `chore` to indicate that this PR makes some changes that users don't need to know.
    * `test: fix CI failure. Part of #1234`
        Use `part of` to indicate that a PR is working on an issue, but shouldn't close the issue when merged.
-->

fix(sdk): Update kfp version to fix appengine bug
**Which issue is resolved by this Pull Request:** 
Resolves #

**Description of your changes:**

**Environment tested:**

* Python Version (use `python --version`):
* Tekton Version (use `tkn version`):
* Kubernetes Version (use `kubectl version`):
* OS (e.g. from `/etc/os-release`):

**Checklist:**
- [x] The title for your pull request (PR) should follow our title convention. [Learn more about the pull request title convention used in this repository](https://github.com/kubeflow/pipelines/blob/master/CONTRIBUTING.md#pull-request-title-convention). 
<!--
   PR titles examples:
    * `fix(frontend): fixes empty page. Fixes #1234`
       Use `fix` to indicate that this PR fixes a bug.
    * `feat(backend): configurable service account. Fixes #1234, fixes #1235`
       Use `feat` to indicate that this PR adds a new feature. 
    * `chore: set up changelog generation tools`
       Use `chore` to indicate that this PR makes some changes that users don't need to know.
    * `test: fix CI failure. Part of #1234`
        Use `part of` to indicate that a PR is working on an issue, but shouldn't close the issue when merged.
-->

fix(sdk): Update wait_for_run_completion function to handle tekton status;
**Which issue is resolved by this Pull Request:** 
Resolves #1223 

**Description of your changes:**

**Environment tested:**

* Python Version (use `python --version`):
* Tekton Version (use `tkn version`):
* Kubernetes Version (use `kubectl version`):
* OS (e.g. from `/etc/os-release`):

**Checklist:**
- [x] The title for your pull request (PR) should follow our title convention. [Learn more about the pull request title convention used in this repository](https://github.com/kubeflow/pipelines/blob/master/CONTRIBUTING.md#pull-request-title-convention). 
<!--
   PR titles examples:
    * `fix(frontend): fixes empty page. Fixes #1234`
       Use `fix` to indicate that this PR fixes a bug.
    * `feat(backend): configurable service account. Fixes #1234, fixes #1235`
       Use `feat` to indicate that this PR adds a new feature. 
    * `chore: set up changelog generation tools`
       Use `chore` to indicate that this PR makes some changes that users don't need to know.
    * `test: fix CI failure. Part of #1234`
        Use `part of` to indicate that a PR is working on an issue, but shouldn't close the issue when merged.
-->



Discussion / hints:

Fix patch:
diff --git a/tekton-catalog/pipeline-loops/Dockerfile b/tekton-catalog/pipeline-loops/Dockerfile
new file mode 100644
index 0000000000..010b46a3ae
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/Dockerfile
@@ -0,0 +1,10 @@
+FROM ubuntu
+ARG bin_name
+ENV BIN ${bin_name}
+
+WORKDIR /
+
+COPY _output/bin/${BIN} /usr/local/bin
+
+ENTRYPOINT []
+CMD ${BIN}
diff --git a/tekton-catalog/pipeline-loops/Makefile b/tekton-catalog/pipeline-loops/Makefile
new file mode 100644
index 0000000000..5c89bdc025
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/Makefile
@@ -0,0 +1,32 @@
+BIN_DIR=_output/bin
+
+# If tag not explicitly set in users default to the git sha.
+TAG ?= v0.0.1
+
+.EXPORT_ALL_VARIABLES:
+
+all: local
+
+init:
+	mkdir -p ${BIN_DIR}
+
+local: init
+	go build -o=${BIN_DIR}/pipelineloop-controller ./cmd/controller
+	go build -o=${BIN_DIR}/pipelineloop-webhook ./cmd/webhook
+
+build-linux: init
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o=${BIN_DIR}/pipelineloop-controller ./cmd/controller
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o=${BIN_DIR}/pipelineloop-webhook ./cmd/webhook
+
+images: build-linux
+	docker build --build-arg bin_name=pipelineloop-controller . -t registry-name/pipelineloop-controller:$(TAG)
+	docker build --build-arg bin_name=pipelineloop-webhook . -t registry-name/pipelineloop-webhook:$(TAG)
+
+update:
+	go mod download
+	go mod tidy
+	go mod vendor
+
+clean:
+	rm -rf _output/
+	rm -f *.log
diff --git a/tekton-catalog/pipeline-loops/README.md b/tekton-catalog/pipeline-loops/README.md
new file mode 100644
index 0000000000..6d6cad64b6
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/README.md
@@ -0,0 +1,49 @@
+# Custom Task: Pipeline Loops
+
+`Pipeline Loops` is implements based on [Tekton Custom Task](https://github.com/tektoncd/community/blob/master/teps/0002-custom-tasks.md) and the template of [Knative sample controller](https://github.com/knative-sandbox/sample-controller)
+
+# Goal
+`Pipeline Loops` is trying to provide a pipeline level loops to handle withItems loop in `Pipeline`(Tekton).
+
+# Installation
+## Optional-1 install by KO
+- Install&config [KO](https://github.com/google/ko)
+- Install pipelineloop controller
+  `ko apply -f config/`
+## Optional-2 install by building your own docker images
+- Modify `Makefile` by changing `registry-name` to your docker hub name
+
+- Run `make images` to build the docker image of yours.
+
+- Modify `config/500-webhook.yaml` and `config/500-controller.yaml` Change the image name to your docker image, e.g.:
+```
+- name: webhook
+  image: fenglixa/pipelineloop-webhook:v0.0.1
+```
+```
+- name: tekton-pipelineloop-controller
+  image: fenglixa/pipelineloop-controller:v0.0.1
+```
+
+- Install pipelineloop controller `kubectl apply -f config/`
+
+
+# Verification
+- check controller and the webhook. `kubectl get po -n tekton-pipelines`
+   ```
+    ...
+    tekton-pipelineloop-controller-db4c7dddb-vrlsd                        1/1     Running     0          6h24m
+    tekton-pipelineloop-webhook-7bb98ddc98-qqkv6                          1/1     Running     0          6h17m
+   ```
+- Try cases to loop pipelines
+  - `kubectl apply -f examples/pipelinespec-with-run-arrary-value.yaml`
+  - `kubectl apply -f examples/pipelinespec-with-run-string-value.yaml`
+  - `kubectl apply -f examples/pipelinespec-with-run-iterate-numeric.yaml`
+  - `kubectl apply -f examples/pipelinespec-with-run-condition.yaml`
+
+# E2E case
+- Install Tekton version >= v0.19
+- Edit feature-flags configmap, ensure "data.enable-custom-tasks" is "true":
+`kubectl edit cm feature-flags -n tekton-pipelines`
+
+- Run the E2E cases: `kubectl apply -f examples/loop-example-basic.yaml`
diff --git a/tekton-catalog/pipeline-loops/cmd/controller/main.go b/tekton-catalog/pipeline-loops/cmd/controller/main.go
new file mode 100644
index 0000000000..2c033fb998
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/cmd/controller/main.go
@@ -0,0 +1,43 @@
+/*
+Copyright 2020 The Knative 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 main
+
+import (
+	"flag"
+
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun"
+	corev1 "k8s.io/api/core/v1"
+	"knative.dev/pkg/injection"
+	"knative.dev/pkg/injection/sharedmain"
+	"knative.dev/pkg/signals"
+)
+
+const (
+	// ControllerLogKey is the name of the logger for the controller cmd
+	ControllerLogKey = "pipelineloop-controller"
+)
+
+var (
+	namespace = flag.String("namespace", corev1.NamespaceAll, "Namespace to restrict informer to. Optional, defaults to all namespaces.")
+)
+
+func main() {
+	flag.Parse()
+	sharedmain.MainWithContext(injection.WithNamespaceScope(signals.NewContext(), *namespace), ControllerLogKey,
+		pipelinelooprun.NewController(*namespace),
+	)
+}
diff --git a/tekton-catalog/pipeline-loops/cmd/webhook/main.go b/tekton-catalog/pipeline-loops/cmd/webhook/main.go
new file mode 100644
index 0000000000..8a25a78dff
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/cmd/webhook/main.go
@@ -0,0 +1,129 @@
+/*
+Copyright 2019 The Knative 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 main
+
+import (
+	"context"
+	"os"
+
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	defaultconfig "github.com/tektoncd/pipeline/pkg/apis/config"
+	"github.com/tektoncd/pipeline/pkg/contexts"
+	"github.com/tektoncd/pipeline/pkg/system"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"knative.dev/pkg/configmap"
+	"knative.dev/pkg/controller"
+	"knative.dev/pkg/injection"
+	"knative.dev/pkg/injection/sharedmain"
+	"knative.dev/pkg/logging"
+	"knative.dev/pkg/signals"
+	"knative.dev/pkg/webhook"
+	"knative.dev/pkg/webhook/certificates"
+	"knative.dev/pkg/webhook/resourcesemantics"
+	"knative.dev/pkg/webhook/resourcesemantics/defaulting"
+	"knative.dev/pkg/webhook/resourcesemantics/validation"
+)
+
+const (
+	// WebhookLogKey is the name of the logger for the webhook cmd.
+	// This name is also used to form lease names for the leader election of the webhook's controllers.
+	WebhookLogKey = "pipelineloop-webhook"
+)
+
+var types = map[schema.GroupVersionKind]resourcesemantics.GenericCRD{
+	pipelineloopv1alpha1.SchemeGroupVersion.WithKind("PipelineLoop"): &pipelineloopv1alpha1.PipelineLoop{},
+}
+
+func newDefaultingAdmissionController(ctx context.Context, cmw configmap.Watcher) *controller.Impl {
+	// Decorate contexts with the current state of the config.
+	store := defaultconfig.NewStore(logging.FromContext(ctx).Named("config-store"))
+	store.WatchConfigs(cmw)
+
+	return defaulting.NewAdmissionController(ctx,
+
+		// Name of the resource webhook.
+		"webhook.pipelineloop.custom.tekton.dev",
+
+		// The path on which to serve the webhook.
+		"/defaulting",
+
+		// The resources to validate and default.
+		types,
+
+		// A function that infuses the context passed to Validate/SetDefaults with custom metadata.
+		func(ctx context.Context) context.Context {
+			return contexts.WithUpgradeViaDefaulting(store.ToContext(ctx))
+		},
+
+		// Whether to disallow unknown fields.
+		true,
+	)
+}
+
+func newValidationAdmissionController(ctx context.Context, cmw configmap.Watcher) *controller.Impl {
+	// Decorate contexts with the current state of the config.
+	store := defaultconfig.NewStore(logging.FromContext(ctx).Named("config-store"))
+	store.WatchConfigs(cmw)
+	return validation.NewAdmissionController(ctx,
+
+		// Name of the resource webhook.
+		"validation.webhook.pipelineloop.custom.tekton.dev",
+
+		// The path on which to serve the webhook.
+		"/resource-validation",
+
+		// The resources to validate and default.
+		types,
+
+		// A function that infuses the context passed to Validate/SetDefaults with custom metadata.
+		func(ctx context.Context) context.Context {
+			return contexts.WithUpgradeViaDefaulting(store.ToContext(ctx))
+		},
+
+		// Whether to disallow unknown fields.
+		true,
+	)
+}
+
+func main() {
+	serviceName := os.Getenv("WEBHOOK_SERVICE_NAME")
+	if serviceName == "" {
+		serviceName = "tekton-pipelineloop-webhook"
+	}
+
+	secretName := os.Getenv("WEBHOOK_SECRET_NAME")
+	if secretName == "" {
+		secretName = "tekton-pipelineloop-webhook-certs" // #nosec
+	}
+
+	// Scope informers to the webhook's namespace instead of cluster-wide
+	ctx := injection.WithNamespaceScope(signals.NewContext(), system.GetNamespace())
+
+	// Set up a signal context with our webhook options
+	ctx = webhook.WithOptions(ctx, webhook.Options{
+		ServiceName: serviceName,
+		Port:        8443,
+		SecretName:  secretName,
+	})
+
+	sharedmain.WebhookMainWithConfig(ctx, WebhookLogKey,
+		sharedmain.ParseAndGetConfigOrDie(),
+		certificates.NewController,
+		newDefaultingAdmissionController,
+		newValidationAdmissionController,
+	)
+}
diff --git a/tekton-catalog/pipeline-loops/config/200-serviceaccount.yaml b/tekton-catalog/pipeline-loops/config/200-serviceaccount.yaml
new file mode 100644
index 0000000000..f2e6dc96c2
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/200-serviceaccount.yaml
@@ -0,0 +1,32 @@
+# Copyright 2020 The Knative 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.
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: tekton-pipelineloop-controller
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: tekton-pipelineloop-webhook
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
\ No newline at end of file
diff --git a/tekton-catalog/pipeline-loops/config/201-clusterrole.yaml b/tekton-catalog/pipeline-loops/config/201-clusterrole.yaml
new file mode 100644
index 0000000000..341e977908
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/201-clusterrole.yaml
@@ -0,0 +1,106 @@
+# Copyright 2020 The Knative 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
+#
+#     https://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.
+
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: tekton-pipelineloop-controller-cluster-access
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+    # Controller needs cluster access to all of the CRDs that it is responsible for managing.
+  - apiGroups: ["tekton.dev"]
+    resources: ["runs", "taskruns", "pipelineruns"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+  - apiGroups: ["tekton.dev"]
+    resources: ["runs/status", "taskruns/status", "pipelineruns/status", "runs/finalizers"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+  - apiGroups: ["custom.tekton.dev"]
+    resources: ["pipelineloops"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+  - apiGroups: ["apps"]
+    resources: ["deployments", "deployments/finalizers"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  # This is the access that the controller needs on a per-namespace basis.
+  name: tekton-pipelineloop-controller-tenant-access
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+  - apiGroups: [""]
+    resources: ["events"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: tekton-pipelineloop-webhook-cluster-access
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+  # The webhook needs to be able to list and update customresourcedefinitions,
+  # mainly to update the webhook certificates.
+  - apiGroups: ["apiextensions.k8s.io"]
+    resources: ["customresourcedefinitions", "customresourcedefinitions/status"]
+    verbs: ["get", "list", "update", "patch", "watch"]
+  - apiGroups: ["admissionregistration.k8s.io"]
+    # The webhook performs a reconciliation on these two resources and continuously
+    # updates configuration.
+    resources: ["mutatingwebhookconfigurations", "validatingwebhookconfigurations"]
+    # knative starts informers on these things, which is why we need get, list and watch.
+    verbs: ["list", "watch"]
+  - apiGroups: ["admissionregistration.k8s.io"]
+    resources: ["mutatingwebhookconfigurations"]
+    # This mutating webhook is responsible for applying defaults to tekton objects
+    # as they are received.
+    resourceNames: ["webhook.pipelineloop.custom.tekton.dev"]
+    # When there are changes to the configs or secrets, knative updates the mutatingwebhook config
+    # with the updated certificates or the refreshed set of rules.
+    verbs: ["get", "update"]
+  - apiGroups: ["apps"]
+    resources: ["deployments", "deployments/finalizers"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
+  - apiGroups: ["admissionregistration.k8s.io"]
+    resources: ["validatingwebhookconfigurations"]
+    # validation.webhook.pipelineloop.custom.tekton.dev performs schema validation when you, for example, create PipelineLoops.
+    resourceNames: ["validation.webhook.pipelineloop.custom.tekton.dev"]
+    # When there are changes to the configs or secrets, knative updates the validatingwebhook config
+    # with the updated certificates or the refreshed set of rules.
+    verbs: ["get", "update"]
+  - apiGroups: ["policy"]
+    resources: ["podsecuritypolicies"]
+    resourceNames: ["tekton-pipelines"]
+    verbs: ["use"]
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: tekton-pipelineloop-leader-election
+  labels:
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+  # We uses leases for leaderelection
+  - apiGroups: ["coordination.k8s.io"]
+    resources: ["leases"]
+    verbs: ["get", "list", "create", "update", "delete", "patch", "watch"]
diff --git a/tekton-catalog/pipeline-loops/config/201-role.yaml b/tekton-catalog/pipeline-loops/config/201-role.yaml
new file mode 100644
index 0000000000..a71bb701d5
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/201-role.yaml
@@ -0,0 +1,68 @@
+# Copyright 2020 The Knative 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
+#
+#     https://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.
+
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: tekton-pipelineloop-controller
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+  - apiGroups: [""]
+    resources: ["configmaps"]
+    verbs: ["list", "watch"]
+  - apiGroups: [""]
+    resources: ["configmaps"]
+    verbs: ["get"]
+    resourceNames: ["config-leader-election", "config-logging", "config-observability"]
+  - apiGroups: ["policy"]
+    resources: ["podsecuritypolicies"]
+    resourceNames: ["tekton-pipelines"]
+    verbs: ["use"]
+---
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: tekton-pipelineloop-webhook
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+rules:
+  - apiGroups: [""]
+    resources: ["configmaps"]
+    verbs: ["list", "watch"]
+  # The webhook needs access to these configmaps for logging information.
+  - apiGroups: [""]
+    resources: ["configmaps"]
+    verbs: ["get"]
+    resourceNames: ["config-logging", "config-observability", "config-leader-election"]
+  - apiGroups: [""]
+    resources: ["secrets"]
+    verbs: ["list", "watch"]
+  # The webhook daemon makes a reconciliation loop on tekton-pipelineloop-webhook-certs. Whenever
+  # the secret changes it updates the webhook configurations with the certificates
+  # stored in the secret.
+  - apiGroups: [""]
+    resources: ["secrets"]
+    verbs: ["get", "update"]
+    resourceNames: ["tekton-pipelineloop-webhook-certs"]
+  - apiGroups: ["policy"]
+    resources: ["podsecuritypolicies"]
+    resourceNames: ["tekton-pipelines"]
+    verbs: ["use"]
\ No newline at end of file
diff --git a/tekton-catalog/pipeline-loops/config/201-rolebinding.yaml b/tekton-catalog/pipeline-loops/config/201-rolebinding.yaml
new file mode 100644
index 0000000000..acaa747efa
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/201-rolebinding.yaml
@@ -0,0 +1,49 @@
+# Copyright 2020 The Knative 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.
+
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: RoleBinding
+metadata:
+  name: tekton-pipelineloop-controller
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-controller
+    namespace: tekton-pipelines
+roleRef:
+  kind: Role
+  name: tekton-pipelineloop-controller
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: RoleBinding
+metadata:
+  name: tekton-pipelineloop-webhook
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-webhook
+    namespace: tekton-pipelines
+roleRef:
+  kind: Role
+  name: tekton-pipelineloop-webhook
+  apiGroup: rbac.authorization.k8s.io
\ No newline at end of file
diff --git a/tekton-catalog/pipeline-loops/config/202-clusterrolebinding.yaml b/tekton-catalog/pipeline-loops/config/202-clusterrolebinding.yaml
new file mode 100644
index 0000000000..a0add3e046
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/202-clusterrolebinding.yaml
@@ -0,0 +1,102 @@
+# Copyright 2020 The Knative 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.
+
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: ClusterRoleBinding
+metadata:
+  name: tekton-pipelineloop-controller-cluster-access
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-controller
+    namespace: tekton-pipelines
+roleRef:
+  kind: ClusterRole
+  name: tekton-pipelineloop-controller-cluster-access
+  apiGroup: rbac.authorization.k8s.io
+---
+# If this ClusterRoleBinding is replaced with a RoleBinding
+# then the ClusterRole would be namespaced. The access described by
+# the tekton-pipelineloop-controller-tenant-access ClusterRole would
+# be scoped to individual tenant namespaces.
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: ClusterRoleBinding
+metadata:
+  name: tekton-pipelineloop-controller-tenant-access
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-controller
+    namespace: tekton-pipelines
+roleRef:
+  kind: ClusterRole
+  name: tekton-pipelineloop-controller-tenant-access
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: ClusterRoleBinding
+metadata:
+  name: tekton-pipelineloop-controller-leaderelection
+  labels:
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-controller
+    namespace: tekton-pipelines
+roleRef:
+  kind: ClusterRole
+  name: tekton-pipelineloop-leader-election
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: ClusterRoleBinding
+metadata:
+  name: tekton-pipelineloop-webhook-cluster-access
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-webhook
+    namespace: tekton-pipelines
+roleRef:
+  kind: ClusterRole
+  name: tekton-pipelineloop-webhook-cluster-access
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: rbac.authorization.k8s.io/v1beta1
+kind: ClusterRoleBinding
+metadata:
+  name: tekton-pipelineloop-webhook-leaderelection
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+subjects:
+  - kind: ServiceAccount
+    name: tekton-pipelineloop-webhook
+    namespace: tekton-pipelines
+roleRef:
+  kind: ClusterRole
+  name: tekton-pipelineloop-leader-election
+  apiGroup: rbac.authorization.k8s.io
\ No newline at end of file
diff --git a/tekton-catalog/pipeline-loops/config/300-pipelineloop.yaml b/tekton-catalog/pipeline-loops/config/300-pipelineloop.yaml
new file mode 100644
index 0000000000..c84e385d24
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/300-pipelineloop.yaml
@@ -0,0 +1,52 @@
+# Copyright 2020 The Knative 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
+#
+#     https://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.
+
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: pipelineloops.custom.tekton.dev
+  labels:
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+    version: "devel"
+spec:
+  group: custom.tekton.dev
+  preserveUnknownFields: false
+  validation:
+    openAPIV3Schema:
+      type: object
+      # One can use x-kubernetes-preserve-unknown-fields: true
+      # at the root of the schema (and inside any properties, additionalProperties)
+      # to get the traditional CRD behaviour that nothing is pruned, despite
+      # setting spec.preserveUnknownProperties: false.
+      #
+      # See https://kubernetes.io/blog/2019/06/20/crd-structural-schema/
+      # See issue: https://github.com/knative/serving/issues/912
+      x-kubernetes-preserve-unknown-fields: true
+  versions:
+  - name: v1alpha1
+    served: true
+    storage: true
+  names:
+    kind: PipelineLoop
+    plural: pipelineloops
+    categories:
+    - tekton
+    - tekton-pipelines
+  scope: Namespaced
+  # Opt into the status subresource so metadata.generation
+  # starts to increment
+  subresources:
+    status: {}
diff --git a/tekton-catalog/pipeline-loops/config/500-controller.yaml b/tekton-catalog/pipeline-loops/config/500-controller.yaml
new file mode 100644
index 0000000000..f4c2bb5ea2
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/500-controller.yaml
@@ -0,0 +1,70 @@
+# Copyright 2020 The Knative 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.
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: tekton-pipelineloop-controller
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/name: controller
+    app.kubernetes.io/component: controller
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/version: "devel"
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    # tekton.dev/release value replaced with inputs.params.versionTag in pipeline/tekton/publish.yaml
+    pipeline.tekton.dev/release: "devel"
+    # labels below are related to istio and should not be used for resource lookup
+    version: "devel"
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: controller
+      app.kubernetes.io/component: controller
+      app.kubernetes.io/instance: default
+      app.kubernetes.io/part-of: tekton-pipeline-loops
+  template:
+    metadata:
+      annotations:
+        cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
+      labels:
+        app.kubernetes.io/name: controller
+        app.kubernetes.io/component: controller
+        app.kubernetes.io/instance: default
+        app.kubernetes.io/version: "devel"
+        app.kubernetes.io/part-of: tekton-pipeline-loops
+        # tekton.dev/release value replaced with inputs.params.versionTag in pipeline/tekton/publish.yaml
+        pipeline.tekton.dev/release: "devel"
+        # labels below are related to istio and should not be used for resource lookup
+        app: tekton-pipelineloop-controller
+        version: "devel"
+    spec:
+      serviceAccountName: tekton-pipelineloop-controller
+      containers:
+      - name: tekton-pipelineloop-controller
+        image: ko://github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/cmd/controller
+        env:
+        - name: SYSTEM_NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        - name: CONFIG_LEADERELECTION_NAME
+          value: config-leader-election
+        - name: CONFIG_LOGGING_NAME
+          value: config-logging
+        - name: CONFIG_OBSERVABILITY_NAME
+          value: config-observability
+        - name: METRICS_DOMAIN
+          value: tekton.dev/pipeline
diff --git a/tekton-catalog/pipeline-loops/config/500-webhook-configuration.yaml b/tekton-catalog/pipeline-loops/config/500-webhook-configuration.yaml
new file mode 100644
index 0000000000..33b91e5140
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/500-webhook-configuration.yaml
@@ -0,0 +1,68 @@
+# Copyright 2020 The Knative 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
+#
+#     https://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.
+
+apiVersion: v1
+kind: Secret
+metadata:
+  name: tekton-pipelineloop-webhook-certs
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+# The data is populated at install time.
+
+---
+apiVersion: admissionregistration.k8s.io/v1beta1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: validation.webhook.pipelineloop.custom.tekton.dev
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+webhooks:
+- admissionReviewVersions:
+  - v1beta1
+  clientConfig:
+    service:
+      name: tekton-pipelineloop-webhook
+      namespace: tekton-pipelines
+  failurePolicy: Fail
+  sideEffects: None
+  name: validation.webhook.pipelineloop.custom.tekton.dev
+
+---
+apiVersion: admissionregistration.k8s.io/v1beta1
+kind: MutatingWebhookConfiguration
+metadata:
+  name: webhook.pipelineloop.custom.tekton.dev
+  labels:
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+webhooks:
+- admissionReviewVersions:
+  - v1beta1
+  clientConfig:
+    service:
+      name: tekton-pipelineloop-webhook
+      namespace: tekton-pipelines
+  failurePolicy: Fail
+  sideEffects: None
+  name: webhook.pipelineloop.custom.tekton.dev
+
diff --git a/tekton-catalog/pipeline-loops/config/500-webhook.yaml b/tekton-catalog/pipeline-loops/config/500-webhook.yaml
new file mode 100644
index 0000000000..a59754a340
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/config/500-webhook.yaml
@@ -0,0 +1,112 @@
+# Copyright 2020 The Knative 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
+#
+#     https://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.
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: tekton-pipelineloop-webhook
+  namespace: tekton-pipelines
+  labels:
+    app.kubernetes.io/name: webhook
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/version: "devel"
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+    version: "devel"
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: webhook
+      app.kubernetes.io/component: webhook
+      app.kubernetes.io/instance: default
+      app.kubernetes.io/part-of: tekton-pipeline-loops
+  template:
+    metadata:
+      annotations:
+        cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
+      labels:
+        app.kubernetes.io/name: webhook
+        app.kubernetes.io/component: webhook
+        app.kubernetes.io/instance: default
+        app.kubernetes.io/version: "devel"
+        app.kubernetes.io/part-of: tekton-pipeline-loops
+        pipeline.tekton.dev/release: "devel"
+        app: tekton-pipelines-webhook
+        version: "devel"
+    spec:
+      serviceAccountName: tekton-pipelineloop-webhook
+      containers:
+      - name: webhook
+        image: ko://github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/cmd/webhook
+        env:
+        - name: SYSTEM_NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        # If you are changing these names, you will also need to update
+        # the webhook's Role in 200-role.yaml to include the new
+        # values in the "configmaps" "get" rule.
+        - name: CONFIG_LOGGING_NAME
+          value: config-logging
+        - name: CONFIG_OBSERVABILITY_NAME
+          value: config-observability
+        - name: CONFIG_LEADERELECTION_NAME
+          value: config-leader-election
+        - name: WEBHOOK_SERVICE_NAME
+          value: tekton-pipelineloop-webhook
+        - name: WEBHOOK_SECRET_NAME
+          value: tekton-pipelineloop-webhook-certs
+        - name: METRICS_DOMAIN
+          value: tekton.dev/pipeline
+        ports:
+        - name: metrics
+          containerPort: 9090
+        - name: profiling
+          containerPort: 8008
+        - name: https-webhook
+          containerPort: 8443
+---
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    app.kubernetes.io/name: webhook
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/version: "devel"
+    app.kubernetes.io/part-of: tekton-pipeline-loops
+    pipeline.tekton.dev/release: "devel"
+    app: tekton-pipelines-webhook
+    version: "devel"
+  name: tekton-pipelineloop-webhook
+  namespace: tekton-pipelines
+spec:
+  ports:
+  # Define metrics and profiling for them to be accessible within service meshes.
+  - name: http-metrics
+    port: 9090
+    targetPort: 9090
+  - name: http-profiling
+    port: 8008
+    targetPort: 8008
+  - name: https-webhook
+    port: 443
+    targetPort: 8443
+  selector:
+    app.kubernetes.io/name: webhook
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/instance: default
+    app.kubernetes.io/part-of: tekton-pipeline-loops
diff --git a/tekton-catalog/pipeline-loops/examples/loop-example-basic.yaml b/tekton-catalog/pipeline-loops/examples/loop-example-basic.yaml
new file mode 100644
index 0000000000..a56f221e14
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/loop-example-basic.yaml
@@ -0,0 +1,71 @@
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: pipelineloop
+spec:
+  pipelineSpec:
+    params:
+    - name: message
+      type: string
+    tasks:
+    - name: echo-loop-task
+      params:
+      - name: message
+        value: $(params.message)
+      taskSpec:
+        params:
+        - name: message
+          type: string
+        steps:
+          - name: echo
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/usr/bin/env bash
+              echo "$(params.message)"
+  iterateParam: message
+---
+apiVersion: tekton.dev/v1beta1
+kind: PipelineRun
+metadata:
+  annotations:
+    tekton.dev/example-loop-pipeline: '{"spec":{"pipelineSpec":{"params":[{"name":"message","type":"string"}],"tasks":[{"name":"echo-loop-task","params":[{"name":"message","value":"$(params.message)"}],"taskSpec":{"params":[{"name":"message","type":"string"}],"steps":[{"name":"echo","image":"ubuntu","imagePullPolicy":"IfNotPresent","script":"#!/usr/bin/env bash\necho \"$(params.message)\"\n"}]}}]},"iterateParam":"message"}}'
+  name: pr-loop-example
+  labels:
+    mylooplabels: mylooplabels
+spec:
+  pipelineSpec:
+    tasks:
+      - name: first-task
+        taskSpec:
+          steps:
+            - name: echo
+              image: ubuntu
+              imagePullPolicy: IfNotPresent
+              script: |
+                #!/usr/bin/env bash
+                echo "I am the first task before the loop task"
+      - name: loop-task
+        runAfter:
+          - first-task
+        params:
+          - name: message
+            value:
+              - I am the first one
+              - I am the second one
+              - I am the third one
+        taskRef:
+          apiVersion: custom.tekton.dev/v1alpha1
+          kind: PipelineLoop
+          name: pipelineloop
+      - name: last-task
+        runAfter:
+          - loop-task
+        taskSpec:
+          steps:
+            - name: echo
+              image: ubuntu
+              imagePullPolicy: IfNotPresent
+              script: |
+                #!/usr/bin/env bash
+                echo "I am the last task after the loop task"
diff --git a/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-arrary-value.yaml b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-arrary-value.yaml
new file mode 100644
index 0000000000..6ba5b2238f
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-arrary-value.yaml
@@ -0,0 +1,49 @@
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: pipelineloop
+spec:
+  pipelineSpec:
+    params:
+    - name: message
+      type: string
+    tasks:
+    - name: echo-loop-task
+      params:
+      - name: message
+        value: $(params.message)
+      taskSpec:
+        params:
+        - name: message
+          type: string
+        steps:
+          - name: echo
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/usr/bin/env bash
+              echo "$(params.message)"
+  iterateParam: message
+
+---
+
+apiVersion: tekton.dev/v1alpha1
+kind: Run
+metadata:
+  labels:
+    custom.tekton.dev/pipelineLoop: pipelineloop
+    tekton.dev/pipeline: pr-loop-example
+    tekton.dev/pipelineRun: pr-loop-example
+    tekton.dev/pipelineTask: loop-task
+  name: pr-loop-example-loop-9w87k
+spec:
+  params:
+  - name: message
+    value:
+    - I am the first one
+    - I am the second one
+    - I am the third one
+  ref:
+    apiVersion: custom.tekton.dev/v1alpha1
+    kind: PipelineLoop
+    name: pipelineloop
diff --git a/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-condition.yaml b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-condition.yaml
new file mode 100644
index 0000000000..12df36e769
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-condition.yaml
@@ -0,0 +1,74 @@
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: conditionloop
+spec:
+  pipelineSpec:
+    params:
+    - name: message
+      type: string
+    tasks:
+    - name: task1
+      params:
+      - name: message
+        value: $(params.message)
+      taskSpec:
+        params:
+        - name: message
+          type: string
+        results:
+          - name: output
+        steps:
+          - name: main
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/usr/bin/env bash
+              printf "$(params.message)" > $(results.output.path)
+    - name: task-ok
+      runAfter:
+        - task1
+      when:
+        - input: "$(tasks.task1.results.output)"
+          operator: in
+          values: ["B"]
+      taskSpec:
+        steps:
+          - name: main
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/bin/sh
+              echo "job start to run..."
+    - name: task-fail
+      runAfter:
+        - task1
+      when:
+        - input: "$(tasks.task1.results.output)"
+          operator: in
+          values: ["A"]
+      taskSpec:
+        steps:
+          - name: main
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/bin/sh
+              echo "continue loop"
+  iterateParam: message
+
+---
+apiVersion: tekton.dev/v1alpha1
+kind: Run
+metadata:
+  name: simplepipelinelooprun
+  labels: 
+    last-loop-task: "task-fail"
+spec:
+  params:
+    - name: message
+      value: '["A", "B", "A"]'
+  ref:
+    apiVersion: custom.tekton.dev/v1alpha1
+    kind: PipelineLoop
+    name: conditionloop
diff --git a/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-iterate-numeric.yaml b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-iterate-numeric.yaml
new file mode 100644
index 0000000000..02992afee1
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-iterate-numeric.yaml
@@ -0,0 +1,45 @@
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: echoloop
+spec:
+  pipelineSpec:
+    params:
+    - name: iteration
+      type: string
+    tasks:
+    - name: echo-good-morning
+      params:
+      - name: iteration
+        value: $(params.iteration)
+      taskSpec:
+        params:
+        - name: iteration
+          type: string
+        steps:
+          - name: echo
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/usr/bin/env bash
+              echo "$(params.iteration)"
+  iterateNumeric: iteration
+
+---
+apiVersion: tekton.dev/v1alpha1
+kind: Run
+metadata:
+  name: simplepipelinelooprun
+spec:
+  params:
+    - name: from
+      value: '1'
+    - name: step
+      value: '1'
+    - name: to
+      value: '3'
+  ref:
+    apiVersion: custom.tekton.dev/v1alpha1
+    kind: PipelineLoop
+    name: echoloop
+
diff --git a/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-string-value.yaml b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-string-value.yaml
new file mode 100644
index 0000000000..a5957e4385
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/pipelinespec-with-run-string-value.yaml
@@ -0,0 +1,41 @@
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: echoloop
+spec:
+  pipelineSpec:
+    params:
+    - name: message
+      type: string
+    tasks:
+    - name: echo-good-morning
+      params:
+      - name: message
+        value: $(params.message)
+      taskSpec:
+        params:
+        - name: message
+          type: string
+        steps:
+          - name: echo
+            image: ubuntu
+            imagePullPolicy: IfNotPresent
+            script: |
+              #!/usr/bin/env bash
+              echo "$(params.message)"
+  iterateParam: message
+
+---
+apiVersion: tekton.dev/v1alpha1
+kind: Run
+metadata:
+  name: simplepipelinelooprun
+spec:
+  params:
+    - name: message
+      value: '["I am the first one", "I am the second one", "I am the third one"]'
+  ref:
+    apiVersion: custom.tekton.dev/v1alpha1
+    kind: PipelineLoop
+    name: echoloop
+
diff --git a/tekton-catalog/pipeline-loops/examples/simplepipelineloop.yaml b/tekton-catalog/pipeline-loops/examples/simplepipelineloop.yaml
new file mode 100644
index 0000000000..d533af016e
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/examples/simplepipelineloop.yaml
@@ -0,0 +1,82 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: simpletask
+spec:
+  params:
+    - name: word
+      type: string
+    - name: suffix
+      type: string
+  steps:
+    - name: echo
+      image: ubuntu
+      script: |
+        #!/usr/bin/env bash
+        echo "$(params.word)$(params.suffix)"
+---
+apiVersion: tekton.dev/v1beta1
+kind: Pipeline
+metadata:
+  name: demo-pipeline
+spec:
+  params:
+  - name: word
+    default: "word"
+  - name: suffix
+    default: "suffix"
+  tasks:
+  - name: simpletask
+    taskRef:
+      name: simpletask
+    params:
+    - name: word
+      value: $(params.word)
+    - name: suffix
+      value: $(params.suffix)
+
+# ---
+# apiVersion: tekton.dev/v1beta1
+# kind: PipelineRun
+# metadata:
+#   name: demo-pipeline-run-1
+# spec:
+#   params:
+#   - name: word
+#     value: "word"
+#   - name: suffix
+#     value: "suffix"
+#   pipelineRef:
+#     name: demo-pipeline
+
+---
+apiVersion: custom.tekton.dev/v1alpha1
+kind: PipelineLoop
+metadata:
+  name: simpletaskloop
+spec:
+  # Task to run (inline taskSpec also works)
+  pipelineRef:
+    name: demo-pipeline
+  # Parameter that contains the values to iterate
+  iterateParam: word
+  # Timeout (defaults to global default timeout, usually 1h00m; use "0" for no timeout)
+  timeout: 60s
+---
+apiVersion: tekton.dev/v1alpha1
+kind: Run
+metadata:
+  name: simpletasklooprun
+spec:
+  params:
+    - name: word
+      value:
+        - jump
+        - land
+        - roll
+    - name: suffix
+      value: ing
+  ref:
+    apiVersion: custom.tekton.dev/v1alpha1
+    kind: PipelineLoop
+    name: simpletaskloop
diff --git a/tekton-catalog/pipeline-loops/go.mod b/tekton-catalog/pipeline-loops/go.mod
new file mode 100644
index 0000000000..095a7aeb51
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/go.mod
@@ -0,0 +1,37 @@
+module github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops
+
+go 1.13
+
+require (
+	github.com/docker/cli v0.0.0-20200303162255-7d407207c304 // indirect
+	github.com/google/go-cmp v0.5.2
+	github.com/hashicorp/go-multierror v1.1.0
+	github.com/tektoncd/pipeline v0.18.0
+	go.opencensus.io v0.22.4
+	go.uber.org/zap v1.15.0
+	golang.org/x/tools v0.0.0-20200924205911-8a9a89368bd3 // indirect
+	gomodules.xyz/jsonpatch/v2 v2.1.0
+	gotest.tools/v3 v3.0.2 // indirect
+	k8s.io/api v0.18.8
+	k8s.io/apimachinery v0.19.0
+	k8s.io/client-go v11.0.1-0.20190805182717-6502b5e7b1b5+incompatible
+	knative.dev/pkg v0.0.0-20200922164940-4bf40ad82aab
+)
+
+// Knative deps
+replace (
+	contrib.go.opencensus.io/exporter/stackdriver => contrib.go.opencensus.io/exporter/stackdriver v0.12.9-0.20191108183826-59d068f8d8ff
+	github.com/Azure/azure-sdk-for-go => github.com/Azure/azure-sdk-for-go v38.2.0+incompatible
+	github.com/Azure/go-autorest => github.com/Azure/go-autorest v13.4.0+incompatible
+)
+
+// Pin k8s deps to v0.18.8
+replace (
+	k8s.io/api => k8s.io/api v0.18.8
+	k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.18.8
+	k8s.io/apimachinery => k8s.io/apimachinery v0.18.8
+	k8s.io/apiserver => k8s.io/apiserver v0.18.8
+	k8s.io/client-go => k8s.io/client-go v0.18.8
+	k8s.io/code-generator => k8s.io/code-generator v0.18.8
+	k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20200410145947-bcb3869e6f29
+)
diff --git a/tekton-catalog/pipeline-loops/hack/boilerplate/boilerplate.go.txt b/tekton-catalog/pipeline-loops/hack/boilerplate/boilerplate.go.txt
new file mode 100644
index 0000000000..6f818683bd
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/hack/boilerplate/boilerplate.go.txt
@@ -0,0 +1,15 @@
+/*
+Copyright 2020 The Knative 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.
+*/
diff --git a/tekton-catalog/pipeline-loops/hack/tools.go b/tekton-catalog/pipeline-loops/hack/tools.go
new file mode 100644
index 0000000000..eae239b181
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/hack/tools.go
@@ -0,0 +1,7 @@
+// +build tools
+
+package tools
+
+import (
+	_ "knative.dev/pkg/hack"
+)
diff --git a/tekton-catalog/pipeline-loops/hack/update-codegen.sh b/tekton-catalog/pipeline-loops/hack/update-codegen.sh
new file mode 100755
index 0000000000..6551ef16fe
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/hack/update-codegen.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+
+# Copyright 2020 The Knative 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.
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+export GO111MODULE=on
+
+REPO_ROOT=$(dirname ${BASH_SOURCE})/..
+CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${REPO_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ../code-generator)}
+
+KNATIVE_CODEGEN_PKG=${KNATIVE_CODEGEN_PKG:-$(cd ${REPO_ROOT}; ls -d -1 ./vendor/knative.dev/pkg 2>/dev/null || echo ../pkg)}
+
+# generate the code with:
+# --output-base    because this script should also be able to run inside the vendor dir of
+#                  k8s.io/kubernetes. The output-base is needed for the generators to output into the vendor dir
+#                  instead of the $GOPATH directly. For normal projects this can be dropped.
+chmod +x ${CODEGEN_PKG}/generate-groups.sh
+${CODEGEN_PKG}/generate-groups.sh "deepcopy,client,informer,lister" \
+  github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis \
+  "pipelineloop:v1alpha1" \
+  --go-header-file ${REPO_ROOT}/hack/boilerplate/boilerplate.go.txt
+
+# Knative Injection
+
+chmod +x ${KNATIVE_CODEGEN_PKG}/hack/generate-knative.sh
+${KNATIVE_CODEGEN_PKG}/hack/generate-knative.sh "injection" \
+  github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis \
+  "pipelineloop:v1alpha1" \
+  --go-header-file ${REPO_ROOT}/hack/boilerplate/boilerplate.go.txt
+
+# Make sure our dependencies are up-to-date
+${REPO_ROOT}/hack/update-deps.sh
diff --git a/tekton-catalog/pipeline-loops/hack/update-deps.sh b/tekton-catalog/pipeline-loops/hack/update-deps.sh
new file mode 100755
index 0000000000..08270dd13d
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/hack/update-deps.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+
+# Copyright 2019 The Knative 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.
+
+readonly ROOT_DIR=$(dirname $0)/..
+source ${ROOT_DIR}/vendor/knative.dev/test-infra/scripts/library.sh
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+export GO111MODULE=on
+export GOFLAGS=-mod=vendor
+
+cd ${ROOT_DIR}
+
+VERSION="release-0.18"
+
+# The list of dependencies that we track at HEAD and periodically
+# float forward in this repository.
+FLOATING_DEPS=(
+  "knative.dev/pkg@${VERSION}"
+  "knative.dev/eventing@${VERSION}"
+  "knative.dev/test-infra@${VERSION}"
+)
+
+# Parse flags to determine any we should pass to dep.
+GO_GET=0
+while [[ $# -ne 0 ]]; do
+  parameter=$1
+  case ${parameter} in
+    --upgrade) GO_GET=1 ;;
+    *) abort "unknown option ${parameter}" ;;
+  esac
+  shift
+done
+readonly GO_GET
+
+if (( GO_GET )); then
+  go get -d ${FLOATING_DEPS[@]}
+fi
+
+# Prune modules.
+go mod tidy
+go mod vendor
+
+rm -rf $(find vendor/ -name 'OWNERS')
+rm -rf $(find vendor/ -name '*_test.go')
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/controller.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/controller.go
new file mode 100644
index 0000000000..7f8d5e3f7d
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/controller.go
@@ -0,0 +1,22 @@
+/*
+Copyright 2020 The Knative 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 pipelineloop
+
+const (
+	// PipelineLoopControllerName holds the name of the TaskRun controller
+	PipelineLoopControllerName = "PipelineLoop"
+)
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/register.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/register.go
new file mode 100644
index 0000000000..523e20f5b9
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/register.go
@@ -0,0 +1,22 @@
+/*
+Copyright 2020 The Knative 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 pipelineloop
+
+const (
+	// GroupName is the Kubernetes resource group name for PipelineLoop types.
+	GroupName = "custom.tekton.dev"
+)
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/doc.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/doc.go
new file mode 100644
index 0000000000..1ac17cb4b7
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/doc.go
@@ -0,0 +1,22 @@
+/*
+Copyright 2020 The Knative 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 v1alpha1 contains API Schema definitions for the pipelineloop v1alpha1 API group
+// +k8s:openapi-gen=true
+// +k8s:deepcopy-gen=package,register
+// +k8s:defaulter-gen=TypeMeta
+// +groupName=custom.tekton.dev
+package v1alpha1
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_defaults.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_defaults.go
new file mode 100644
index 0000000000..b730ff2dd1
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_defaults.go
@@ -0,0 +1,34 @@
+/*
+Copyright 2020 The Knative 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 v1alpha1
+
+import (
+	"context"
+
+	"knative.dev/pkg/apis"
+)
+
+var _ apis.Defaultable = (*PipelineLoop)(nil)
+
+// SetDefaults set any defaults for the PipelineLoop
+func (tl *PipelineLoop) SetDefaults(ctx context.Context) {
+	tl.Spec.SetDefaults(ctx)
+}
+
+// SetDefaults set any defaults for the PipelineLoop spec
+func (tls *PipelineLoopSpec) SetDefaults(ctx context.Context) {
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_types.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_types.go
new file mode 100644
index 0000000000..4d81e2692e
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_types.go
@@ -0,0 +1,128 @@
+/*
+Copyright 2020 The Knative 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 v1alpha1
+
+import (
+	v1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +genclient
+// +genclient:noStatus
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// PipelineLoop iteratively executes a Task over elements in an array.
+// +k8s:openapi-gen=true
+type PipelineLoop struct {
+	metav1.TypeMeta `json:",inline"`
+	// +optional
+	metav1.ObjectMeta `json:"metadata"`
+
+	// Spec holds the desired state of the PipelineLoop from the client
+	// +optional
+	Spec PipelineLoopSpec `json:"spec"`
+}
+
+// PipelineLoopSpec defines the desired state of the PipelineLoop
+type PipelineLoopSpec struct {
+	// TaskRef is a reference to a task definition.
+	// +optional
+	// TaskRef     *v1beta1.TaskRef     `json:"taskRef,omitempty"`
+	PipelineRef *v1beta1.PipelineRef `json:"pipelineRef,omitempty"`
+
+	// TaskSpec is a specification of a task
+	// +optional
+	PipelineSpec *v1beta1.PipelineSpec `json:"pipelineSpec,omitempty"`
+
+	// IterateParam is the name of the task parameter that is iterated upon.
+	IterateParam string `json:"iterateParam"`
+
+	IterateNumeric string `json:"iterateNumeric"`
+
+	// Time after which the TaskRun times out.
+	// +optional
+	Timeout *metav1.Duration `json:"timeout,omitempty"`
+
+	// Retries represents how many times a task should be retried in case of task failure.
+	// +optional
+	Retries int `json:"retries,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// PipelineLoopList contains a list of PipelineLoops
+type PipelineLoopList struct {
+	metav1.TypeMeta `json:",inline"`
+	// +optional
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []PipelineLoop `json:"items"`
+}
+
+// PipelineLoopRunReason represents a reason for the Run "Succeeded" condition
+type PipelineLoopRunReason string
+
+const (
+	// PipelineLoopRunReasonStarted is the reason set when the Run has just started
+	PipelineLoopRunReasonStarted PipelineLoopRunReason = "Started"
+
+	// PipelineLoopRunReasonRunning indicates that the Run is in progress
+	PipelineLoopRunReasonRunning PipelineLoopRunReason = "Running"
+
+	// PipelineLoopRunReasonFailed indicates that one of the TaskRuns created from the Run failed
+	PipelineLoopRunReasonFailed PipelineLoopRunReason = "Failed"
+
+	// PipelineLoopRunReasonSucceeded indicates that all of the TaskRuns created from the Run completed successfully
+	PipelineLoopRunReasonSucceeded PipelineLoopRunReason = "Succeeded"
+
+	// PipelineLoopRunReasonCancelled indicates that a Run was cancelled.
+	PipelineLoopRunReasonCancelled PipelineLoopRunReason = "PipelineLoopRunCancelled"
+
+	// PipelineLoopRunReasonCouldntCancel indicates that a Run was cancelled but attempting to update
+	// the running TaskRun as cancelled failed.
+	PipelineLoopRunReasonCouldntCancel PipelineLoopRunReason = "PipelineLoopRunCouldntCancel"
+
+	// PipelineLoopRunReasonCouldntGetPipelineLoop indicates that the associated PipelineLoop couldn't be retrieved
+	PipelineLoopRunReasonCouldntGetPipelineLoop PipelineLoopRunReason = "CouldntGetPipelineLoop"
+
+	// PipelineLoopRunReasonFailedValidation indicates that the PipelineLoop failed runtime validation
+	PipelineLoopRunReasonFailedValidation PipelineLoopRunReason = "PipelineLoopValidationFailed"
+
+	// PipelineLoopRunReasonInternalError indicates that the PipelineLoop failed due to an internal error in the reconciler
+	PipelineLoopRunReasonInternalError PipelineLoopRunReason = "PipelineLoopInternalError"
+)
+
+func (t PipelineLoopRunReason) String() string {
+	return string(t)
+}
+
+// PipelineLoopRunStatus contains the status stored in the ExtraFields of a Run that references a PipelineLoop.
+type PipelineLoopRunStatus struct {
+	// PipelineLoopSpec contains the exact spec used to instantiate the Run
+	PipelineLoopSpec *PipelineLoopSpec `json:"pipelineLoopSpec,omitempty"`
+	// map of PipelineLoopPipelineRunStatus with the PipelineRun name as the key
+	// +optional
+	PipelineRuns map[string]*PipelineLoopPipelineRunStatus `json:"pipelineRuns,omitempty"`
+}
+
+// PipelineLoopPipelineRunStatus contains the iteration number for a PipelineRun and the PipelineRun's Status
+type PipelineLoopPipelineRunStatus struct {
+	// iteration number
+	Iteration int `json:"iteration,omitempty"`
+	// Status is the TaskRunStatus for the corresponding TaskRun
+	// +optional
+	Status *v1beta1.PipelineRunStatus `json:"status,omitempty"`
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation.go
new file mode 100644
index 0000000000..ccdc0b6746
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation.go
@@ -0,0 +1,69 @@
+/*
+Copyright 2020 The Knative 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 v1alpha1
+
+import (
+	"context"
+	"strings"
+
+	"github.com/tektoncd/pipeline/pkg/apis/validate"
+	"k8s.io/apimachinery/pkg/util/validation"
+	"knative.dev/pkg/apis"
+)
+
+var _ apis.Validatable = (*PipelineLoop)(nil)
+
+// Validate PipelineLoop
+func (tl *PipelineLoop) Validate(ctx context.Context) *apis.FieldError {
+	if err := validate.ObjectMetadata(tl.GetObjectMeta()); err != nil {
+		return err.ViaField("metadata")
+	}
+	return tl.Spec.Validate(ctx)
+}
+
+// Validate PipelineLoopSpec
+func (tls *PipelineLoopSpec) Validate(ctx context.Context) *apis.FieldError {
+	// Validate Task reference or inline task spec.
+	if err := validateTask(ctx, tls); err != nil {
+		return err
+	}
+	return nil
+}
+
+func validateTask(ctx context.Context, tls *PipelineLoopSpec) *apis.FieldError {
+	// pipelineRef and taskSpec are mutually exclusive.
+	if (tls.PipelineRef != nil && tls.PipelineRef.Name != "") && tls.PipelineSpec != nil {
+		return apis.ErrMultipleOneOf("spec.pipelineRef", "spec.pipelineSpec")
+	}
+	// Check that one of pipelineRef and taskSpec is present.
+	if (tls.PipelineRef == nil || tls.PipelineRef.Name == "") && tls.PipelineSpec == nil {
+		return apis.ErrMissingOneOf("spec.pipelineRef", "spec.pipelineSpec")
+	}
+	// Validate PipelineSpec if it's present
+	if tls.PipelineSpec != nil {
+		if err := tls.PipelineSpec.Validate(ctx); err != nil {
+			return err.ViaField("spec.pipelineSpec")
+		}
+	}
+	if tls.PipelineRef != nil && tls.PipelineRef.Name != "" {
+		// pipelineRef name must be a valid k8s name
+		if errSlice := validation.IsQualifiedName(tls.PipelineRef.Name); len(errSlice) != 0 {
+			return apis.ErrInvalidValue(strings.Join(errSlice, ","), "spec.pipelineRef.name")
+		}
+	}
+	return nil
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/register.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/register.go
new file mode 100644
index 0000000000..c16f7a23cb
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/register.go
@@ -0,0 +1,54 @@
+/*
+Copyright 2019 The Knative 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 v1alpha1
+
+import (
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// SchemeGroupVersion is group version used to register these objects
+var SchemeGroupVersion = schema.GroupVersion{Group: pipelineloop.GroupName, Version: "v1alpha1"}
+
+// Kind takes an unqualified kind and returns back a Group qualified GroupKind
+func Kind(kind string) schema.GroupKind {
+	return SchemeGroupVersion.WithKind(kind).GroupKind()
+}
+
+// Resource takes an unqualified resource and returns a Group qualified GroupResource
+func Resource(resource string) schema.GroupResource {
+	return SchemeGroupVersion.WithResource(resource).GroupResource()
+}
+
+var (
+	schemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
+
+	// AddToScheme adds Build types to the scheme.
+	AddToScheme = schemeBuilder.AddToScheme
+)
+
+// Adds the list of known types to Scheme.
+func addKnownTypes(scheme *runtime.Scheme) error {
+	scheme.AddKnownTypes(SchemeGroupVersion,
+		&PipelineLoop{},
+		&PipelineLoopList{},
+	)
+	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
+	return nil
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/zz_generated.deepcopy.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 0000000000..86bd52f970
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,175 @@
+// +build !ignore_autogenerated
+
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by deepcopy-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineLoop) DeepCopyInto(out *PipelineLoop) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineLoop.
+func (in *PipelineLoop) DeepCopy() *PipelineLoop {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineLoop)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PipelineLoop) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineLoopList) DeepCopyInto(out *PipelineLoopList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]PipelineLoop, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineLoopList.
+func (in *PipelineLoopList) DeepCopy() *PipelineLoopList {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineLoopList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PipelineLoopList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineLoopPipelineRunStatus) DeepCopyInto(out *PipelineLoopPipelineRunStatus) {
+	*out = *in
+	if in.Status != nil {
+		in, out := &in.Status, &out.Status
+		*out = new(v1beta1.PipelineRunStatus)
+		(*in).DeepCopyInto(*out)
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineLoopPipelineRunStatus.
+func (in *PipelineLoopPipelineRunStatus) DeepCopy() *PipelineLoopPipelineRunStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineLoopPipelineRunStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineLoopRunStatus) DeepCopyInto(out *PipelineLoopRunStatus) {
+	*out = *in
+	if in.PipelineLoopSpec != nil {
+		in, out := &in.PipelineLoopSpec, &out.PipelineLoopSpec
+		*out = new(PipelineLoopSpec)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.PipelineRuns != nil {
+		in, out := &in.PipelineRuns, &out.PipelineRuns
+		*out = make(map[string]*PipelineLoopPipelineRunStatus, len(*in))
+		for key, val := range *in {
+			var outVal *PipelineLoopPipelineRunStatus
+			if val == nil {
+				(*out)[key] = nil
+			} else {
+				in, out := &val, &outVal
+				*out = new(PipelineLoopPipelineRunStatus)
+				(*in).DeepCopyInto(*out)
+			}
+			(*out)[key] = outVal
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineLoopRunStatus.
+func (in *PipelineLoopRunStatus) DeepCopy() *PipelineLoopRunStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineLoopRunStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineLoopSpec) DeepCopyInto(out *PipelineLoopSpec) {
+	*out = *in
+	if in.PipelineRef != nil {
+		in, out := &in.PipelineRef, &out.PipelineRef
+		*out = new(v1beta1.PipelineRef)
+		**out = **in
+	}
+	if in.PipelineSpec != nil {
+		in, out := &in.PipelineSpec, &out.PipelineSpec
+		*out = new(v1beta1.PipelineSpec)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Timeout != nil {
+		in, out := &in.Timeout, &out.Timeout
+		*out = new(v1.Duration)
+		**out = **in
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineLoopSpec.
+func (in *PipelineLoopSpec) DeepCopy() *PipelineLoopSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineLoopSpec)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/clientset.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/clientset.go
new file mode 100644
index 0000000000..5c5968c7cc
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/clientset.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package versioned
+
+import (
+	"fmt"
+
+	customv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1"
+	discovery "k8s.io/client-go/discovery"
+	rest "k8s.io/client-go/rest"
+	flowcontrol "k8s.io/client-go/util/flowcontrol"
+)
+
+type Interface interface {
+	Discovery() discovery.DiscoveryInterface
+	CustomV1alpha1() customv1alpha1.CustomV1alpha1Interface
+}
+
+// Clientset contains the clients for groups. Each group has exactly one
+// version included in a Clientset.
+type Clientset struct {
+	*discovery.DiscoveryClient
+	customV1alpha1 *customv1alpha1.CustomV1alpha1Client
+}
+
+// CustomV1alpha1 retrieves the CustomV1alpha1Client
+func (c *Clientset) CustomV1alpha1() customv1alpha1.CustomV1alpha1Interface {
+	return c.customV1alpha1
+}
+
+// Discovery retrieves the DiscoveryClient
+func (c *Clientset) Discovery() discovery.DiscoveryInterface {
+	if c == nil {
+		return nil
+	}
+	return c.DiscoveryClient
+}
+
+// NewForConfig creates a new Clientset for the given config.
+// If config's RateLimiter is not set and QPS and Burst are acceptable,
+// NewForConfig will generate a rate-limiter in configShallowCopy.
+func NewForConfig(c *rest.Config) (*Clientset, error) {
+	configShallowCopy := *c
+	if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 {
+		if configShallowCopy.Burst <= 0 {
+			return nil, fmt.Errorf("burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0")
+		}
+		configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst)
+	}
+	var cs Clientset
+	var err error
+	cs.customV1alpha1, err = customv1alpha1.NewForConfig(&configShallowCopy)
+	if err != nil {
+		return nil, err
+	}
+
+	cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy)
+	if err != nil {
+		return nil, err
+	}
+	return &cs, nil
+}
+
+// NewForConfigOrDie creates a new Clientset for the given config and
+// panics if there is an error in the config.
+func NewForConfigOrDie(c *rest.Config) *Clientset {
+	var cs Clientset
+	cs.customV1alpha1 = customv1alpha1.NewForConfigOrDie(c)
+
+	cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c)
+	return &cs
+}
+
+// New creates a new Clientset for the given RESTClient.
+func New(c rest.Interface) *Clientset {
+	var cs Clientset
+	cs.customV1alpha1 = customv1alpha1.New(c)
+
+	cs.DiscoveryClient = discovery.NewDiscoveryClient(c)
+	return &cs
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/doc.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/doc.go
new file mode 100644
index 0000000000..e48c2aa446
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// This package has the automatically generated clientset.
+package versioned
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/clientset_generated.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/clientset_generated.go
new file mode 100644
index 0000000000..ed6f401b6b
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/clientset_generated.go
@@ -0,0 +1,82 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	clientset "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	customv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1"
+	fakecustomv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/watch"
+	"k8s.io/client-go/discovery"
+	fakediscovery "k8s.io/client-go/discovery/fake"
+	"k8s.io/client-go/testing"
+)
+
+// NewSimpleClientset returns a clientset that will respond with the provided objects.
+// It's backed by a very simple object tracker that processes creates, updates and deletions as-is,
+// without applying any validations and/or defaults. It shouldn't be considered a replacement
+// for a real clientset and is mostly useful in simple unit tests.
+func NewSimpleClientset(objects ...runtime.Object) *Clientset {
+	o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder())
+	for _, obj := range objects {
+		if err := o.Add(obj); err != nil {
+			panic(err)
+		}
+	}
+
+	cs := &Clientset{tracker: o}
+	cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake}
+	cs.AddReactor("*", "*", testing.ObjectReaction(o))
+	cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) {
+		gvr := action.GetResource()
+		ns := action.GetNamespace()
+		watch, err := o.Watch(gvr, ns)
+		if err != nil {
+			return false, nil, err
+		}
+		return true, watch, nil
+	})
+
+	return cs
+}
+
+// Clientset implements clientset.Interface. Meant to be embedded into a
+// struct to get a default implementation. This makes faking out just the method
+// you want to test easier.
+type Clientset struct {
+	testing.Fake
+	discovery *fakediscovery.FakeDiscovery
+	tracker   testing.ObjectTracker
+}
+
+func (c *Clientset) Discovery() discovery.DiscoveryInterface {
+	return c.discovery
+}
+
+func (c *Clientset) Tracker() testing.ObjectTracker {
+	return c.tracker
+}
+
+var _ clientset.Interface = &Clientset{}
+
+// CustomV1alpha1 retrieves the CustomV1alpha1Client
+func (c *Clientset) CustomV1alpha1() customv1alpha1.CustomV1alpha1Interface {
+	return &fakecustomv1alpha1.FakeCustomV1alpha1{Fake: &c.Fake}
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/doc.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/doc.go
new file mode 100644
index 0000000000..2c4903250c
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// This package has the automatically generated fake clientset.
+package fake
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/register.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/register.go
new file mode 100644
index 0000000000..e7a48f782a
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake/register.go
@@ -0,0 +1,56 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	customv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	serializer "k8s.io/apimachinery/pkg/runtime/serializer"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+)
+
+var scheme = runtime.NewScheme()
+var codecs = serializer.NewCodecFactory(scheme)
+var parameterCodec = runtime.NewParameterCodec(scheme)
+var localSchemeBuilder = runtime.SchemeBuilder{
+	customv1alpha1.AddToScheme,
+}
+
+// AddToScheme adds all types of this clientset into the given scheme. This allows composition
+// of clientsets, like in:
+//
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
+//
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//
+// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
+// correctly.
+var AddToScheme = localSchemeBuilder.AddToScheme
+
+func init() {
+	v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"})
+	utilruntime.Must(AddToScheme(scheme))
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/doc.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/doc.go
new file mode 100644
index 0000000000..7acc2dcf25
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// This package contains the scheme of the automatically generated clientset.
+package scheme
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/register.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/register.go
new file mode 100644
index 0000000000..1b4288da47
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme/register.go
@@ -0,0 +1,56 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package scheme
+
+import (
+	customv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	serializer "k8s.io/apimachinery/pkg/runtime/serializer"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+)
+
+var Scheme = runtime.NewScheme()
+var Codecs = serializer.NewCodecFactory(Scheme)
+var ParameterCodec = runtime.NewParameterCodec(Scheme)
+var localSchemeBuilder = runtime.SchemeBuilder{
+	customv1alpha1.AddToScheme,
+}
+
+// AddToScheme adds all types of this clientset into the given scheme. This allows composition
+// of clientsets, like in:
+//
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
+//
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//
+// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
+// correctly.
+var AddToScheme = localSchemeBuilder.AddToScheme
+
+func init() {
+	v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})
+	utilruntime.Must(AddToScheme(Scheme))
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/doc.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/doc.go
new file mode 100644
index 0000000000..41e872fe9a
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// This package has the automatically generated typed clients.
+package v1alpha1
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/doc.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/doc.go
new file mode 100644
index 0000000000..c7f6e65cab
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// Package fake has the automatically generated clients.
+package fake
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop.go
new file mode 100644
index 0000000000..c288d8407a
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop.go
@@ -0,0 +1,130 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	"context"
+
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	testing "k8s.io/client-go/testing"
+)
+
+// FakePipelineLoops implements PipelineLoopInterface
+type FakePipelineLoops struct {
+	Fake *FakeCustomV1alpha1
+	ns   string
+}
+
+var pipelineloopsResource = schema.GroupVersionResource{Group: "custom.tekton.dev", Version: "v1alpha1", Resource: "pipelineloops"}
+
+var pipelineloopsKind = schema.GroupVersionKind{Group: "custom.tekton.dev", Version: "v1alpha1", Kind: "PipelineLoop"}
+
+// Get takes name of the pipelineLoop, and returns the corresponding pipelineLoop object, and an error if there is any.
+func (c *FakePipelineLoops) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.PipelineLoop, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(pipelineloopsResource, c.ns, name), &v1alpha1.PipelineLoop{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.PipelineLoop), err
+}
+
+// List takes label and field selectors, and returns the list of PipelineLoops that match those selectors.
+func (c *FakePipelineLoops) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.PipelineLoopList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(pipelineloopsResource, pipelineloopsKind, c.ns, opts), &v1alpha1.PipelineLoopList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1alpha1.PipelineLoopList{ListMeta: obj.(*v1alpha1.PipelineLoopList).ListMeta}
+	for _, item := range obj.(*v1alpha1.PipelineLoopList).Items {
+		if label.Matches(labels.Set(item.Labels)) {
+			list.Items = append(list.Items, item)
+		}
+	}
+	return list, err
+}
+
+// Watch returns a watch.Interface that watches the requested pipelineLoops.
+func (c *FakePipelineLoops) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(pipelineloopsResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a pipelineLoop and creates it.  Returns the server's representation of the pipelineLoop, and an error, if there is any.
+func (c *FakePipelineLoops) Create(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.CreateOptions) (result *v1alpha1.PipelineLoop, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(pipelineloopsResource, c.ns, pipelineLoop), &v1alpha1.PipelineLoop{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.PipelineLoop), err
+}
+
+// Update takes the representation of a pipelineLoop and updates it. Returns the server's representation of the pipelineLoop, and an error, if there is any.
+func (c *FakePipelineLoops) Update(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.UpdateOptions) (result *v1alpha1.PipelineLoop, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(pipelineloopsResource, c.ns, pipelineLoop), &v1alpha1.PipelineLoop{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.PipelineLoop), err
+}
+
+// Delete takes name of the pipelineLoop and deletes it. Returns an error if one occurs.
+func (c *FakePipelineLoops) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteAction(pipelineloopsResource, c.ns, name), &v1alpha1.PipelineLoop{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakePipelineLoops) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(pipelineloopsResource, c.ns, listOpts)
+
+	_, err := c.Fake.Invokes(action, &v1alpha1.PipelineLoopList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched pipelineLoop.
+func (c *FakePipelineLoops) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.PipelineLoop, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(pipelineloopsResource, c.ns, name, pt, data, subresources...), &v1alpha1.PipelineLoop{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.PipelineLoop), err
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop_client.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop_client.go
new file mode 100644
index 0000000000..5e70b8b5e7
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/fake/fake_pipelineloop_client.go
@@ -0,0 +1,40 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1"
+	rest "k8s.io/client-go/rest"
+	testing "k8s.io/client-go/testing"
+)
+
+type FakeCustomV1alpha1 struct {
+	*testing.Fake
+}
+
+func (c *FakeCustomV1alpha1) PipelineLoops(namespace string) v1alpha1.PipelineLoopInterface {
+	return &FakePipelineLoops{c, namespace}
+}
+
+// RESTClient returns a RESTClient that is used to communicate
+// with API server by this client implementation.
+func (c *FakeCustomV1alpha1) RESTClient() rest.Interface {
+	var ret *rest.RESTClient
+	return ret
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/generated_expansion.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/generated_expansion.go
new file mode 100644
index 0000000000..4209c417dd
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/generated_expansion.go
@@ -0,0 +1,21 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+type PipelineLoopExpansion interface{}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop.go
new file mode 100644
index 0000000000..da61135e63
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop.go
@@ -0,0 +1,178 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"context"
+	"time"
+
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	scheme "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	rest "k8s.io/client-go/rest"
+)
+
+// PipelineLoopsGetter has a method to return a PipelineLoopInterface.
+// A group's client should implement this interface.
+type PipelineLoopsGetter interface {
+	PipelineLoops(namespace string) PipelineLoopInterface
+}
+
+// PipelineLoopInterface has methods to work with PipelineLoop resources.
+type PipelineLoopInterface interface {
+	Create(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.CreateOptions) (*v1alpha1.PipelineLoop, error)
+	Update(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.UpdateOptions) (*v1alpha1.PipelineLoop, error)
+	Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
+	DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
+	Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.PipelineLoop, error)
+	List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.PipelineLoopList, error)
+	Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
+	Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.PipelineLoop, err error)
+	PipelineLoopExpansion
+}
+
+// pipelineLoops implements PipelineLoopInterface
+type pipelineLoops struct {
+	client rest.Interface
+	ns     string
+}
+
+// newPipelineLoops returns a PipelineLoops
+func newPipelineLoops(c *CustomV1alpha1Client, namespace string) *pipelineLoops {
+	return &pipelineLoops{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the pipelineLoop, and returns the corresponding pipelineLoop object, and an error if there is any.
+func (c *pipelineLoops) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.PipelineLoop, err error) {
+	result = &v1alpha1.PipelineLoop{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of PipelineLoops that match those selectors.
+func (c *pipelineLoops) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.PipelineLoopList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1alpha1.PipelineLoopList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested pipelineLoops.
+func (c *pipelineLoops) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	opts.Watch = true
+	return c.client.Get().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch(ctx)
+}
+
+// Create takes the representation of a pipelineLoop and creates it.  Returns the server's representation of the pipelineLoop, and an error, if there is any.
+func (c *pipelineLoops) Create(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.CreateOptions) (result *v1alpha1.PipelineLoop, err error) {
+	result = &v1alpha1.PipelineLoop{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(pipelineLoop).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Update takes the representation of a pipelineLoop and updates it. Returns the server's representation of the pipelineLoop, and an error, if there is any.
+func (c *pipelineLoops) Update(ctx context.Context, pipelineLoop *v1alpha1.PipelineLoop, opts v1.UpdateOptions) (result *v1alpha1.PipelineLoop, err error) {
+	result = &v1alpha1.PipelineLoop{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		Name(pipelineLoop.Name).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(pipelineLoop).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Delete takes name of the pipelineLoop and deletes it. Returns an error if one occurs.
+func (c *pipelineLoops) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		Name(name).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *pipelineLoops) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	var timeout time.Duration
+	if listOpts.TimeoutSeconds != nil {
+		timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second
+	}
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		VersionedParams(&listOpts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// Patch applies the patch and returns the patched pipelineLoop.
+func (c *pipelineLoops) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.PipelineLoop, err error) {
+	result = &v1alpha1.PipelineLoop{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("pipelineloops").
+		Name(name).
+		SubResource(subresources...).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(data).
+		Do(ctx).
+		Into(result)
+	return
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop_client.go b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop_client.go
new file mode 100644
index 0000000000..fce0deead6
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/typed/pipelineloop/v1alpha1/pipelineloop_client.go
@@ -0,0 +1,89 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/scheme"
+	rest "k8s.io/client-go/rest"
+)
+
+type CustomV1alpha1Interface interface {
+	RESTClient() rest.Interface
+	PipelineLoopsGetter
+}
+
+// CustomV1alpha1Client is used to interact with features provided by the custom.tekton.dev group.
+type CustomV1alpha1Client struct {
+	restClient rest.Interface
+}
+
+func (c *CustomV1alpha1Client) PipelineLoops(namespace string) PipelineLoopInterface {
+	return newPipelineLoops(c, namespace)
+}
+
+// NewForConfig creates a new CustomV1alpha1Client for the given config.
+func NewForConfig(c *rest.Config) (*CustomV1alpha1Client, error) {
+	config := *c
+	if err := setConfigDefaults(&config); err != nil {
+		return nil, err
+	}
+	client, err := rest.RESTClientFor(&config)
+	if err != nil {
+		return nil, err
+	}
+	return &CustomV1alpha1Client{client}, nil
+}
+
+// NewForConfigOrDie creates a new CustomV1alpha1Client for the given config and
+// panics if there is an error in the config.
+func NewForConfigOrDie(c *rest.Config) *CustomV1alpha1Client {
+	client, err := NewForConfig(c)
+	if err != nil {
+		panic(err)
+	}
+	return client
+}
+
+// New creates a new CustomV1alpha1Client for the given RESTClient.
+func New(c rest.Interface) *CustomV1alpha1Client {
+	return &CustomV1alpha1Client{c}
+}
+
+func setConfigDefaults(config *rest.Config) error {
+	gv := v1alpha1.SchemeGroupVersion
+	config.GroupVersion = &gv
+	config.APIPath = "/apis"
+	config.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
+
+	if config.UserAgent == "" {
+		config.UserAgent = rest.DefaultKubernetesUserAgent()
+	}
+
+	return nil
+}
+
+// RESTClient returns a RESTClient that is used to communicate
+// with API server by this client implementation.
+func (c *CustomV1alpha1Client) RESTClient() rest.Interface {
+	if c == nil {
+		return nil
+	}
+	return c.restClient
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/factory.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/factory.go
new file mode 100644
index 0000000000..678a7a7b97
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/factory.go
@@ -0,0 +1,180 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package externalversions
+
+import (
+	reflect "reflect"
+	sync "sync"
+	time "time"
+
+	versioned "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	internalinterfaces "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces"
+	pipelineloop "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	cache "k8s.io/client-go/tools/cache"
+)
+
+// SharedInformerOption defines the functional option type for SharedInformerFactory.
+type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory
+
+type sharedInformerFactory struct {
+	client           versioned.Interface
+	namespace        string
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+	lock             sync.Mutex
+	defaultResync    time.Duration
+	customResync     map[reflect.Type]time.Duration
+
+	informers map[reflect.Type]cache.SharedIndexInformer
+	// startedInformers is used for tracking which informers have been started.
+	// This allows Start() to be called multiple times safely.
+	startedInformers map[reflect.Type]bool
+}
+
+// WithCustomResyncConfig sets a custom resync period for the specified informer types.
+func WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption {
+	return func(factory *sharedInformerFactory) *sharedInformerFactory {
+		for k, v := range resyncConfig {
+			factory.customResync[reflect.TypeOf(k)] = v
+		}
+		return factory
+	}
+}
+
+// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory.
+func WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption {
+	return func(factory *sharedInformerFactory) *sharedInformerFactory {
+		factory.tweakListOptions = tweakListOptions
+		return factory
+	}
+}
+
+// WithNamespace limits the SharedInformerFactory to the specified namespace.
+func WithNamespace(namespace string) SharedInformerOption {
+	return func(factory *sharedInformerFactory) *sharedInformerFactory {
+		factory.namespace = namespace
+		return factory
+	}
+}
+
+// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces.
+func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory {
+	return NewSharedInformerFactoryWithOptions(client, defaultResync)
+}
+
+// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory.
+// Listers obtained via this SharedInformerFactory will be subject to the same filters
+// as specified here.
+// Deprecated: Please use NewSharedInformerFactoryWithOptions instead
+func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory {
+	return NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions))
+}
+
+// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options.
+func NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory {
+	factory := &sharedInformerFactory{
+		client:           client,
+		namespace:        v1.NamespaceAll,
+		defaultResync:    defaultResync,
+		informers:        make(map[reflect.Type]cache.SharedIndexInformer),
+		startedInformers: make(map[reflect.Type]bool),
+		customResync:     make(map[reflect.Type]time.Duration),
+	}
+
+	// Apply all options
+	for _, opt := range options {
+		factory = opt(factory)
+	}
+
+	return factory
+}
+
+// Start initializes all requested informers.
+func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) {
+	f.lock.Lock()
+	defer f.lock.Unlock()
+
+	for informerType, informer := range f.informers {
+		if !f.startedInformers[informerType] {
+			go informer.Run(stopCh)
+			f.startedInformers[informerType] = true
+		}
+	}
+}
+
+// WaitForCacheSync waits for all started informers' cache were synced.
+func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool {
+	informers := func() map[reflect.Type]cache.SharedIndexInformer {
+		f.lock.Lock()
+		defer f.lock.Unlock()
+
+		informers := map[reflect.Type]cache.SharedIndexInformer{}
+		for informerType, informer := range f.informers {
+			if f.startedInformers[informerType] {
+				informers[informerType] = informer
+			}
+		}
+		return informers
+	}()
+
+	res := map[reflect.Type]bool{}
+	for informType, informer := range informers {
+		res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced)
+	}
+	return res
+}
+
+// InternalInformerFor returns the SharedIndexInformer for obj using an internal
+// client.
+func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer {
+	f.lock.Lock()
+	defer f.lock.Unlock()
+
+	informerType := reflect.TypeOf(obj)
+	informer, exists := f.informers[informerType]
+	if exists {
+		return informer
+	}
+
+	resyncPeriod, exists := f.customResync[informerType]
+	if !exists {
+		resyncPeriod = f.defaultResync
+	}
+
+	informer = newFunc(f.client, resyncPeriod)
+	f.informers[informerType] = informer
+
+	return informer
+}
+
+// SharedInformerFactory provides shared informers for resources in all known
+// API group versions.
+type SharedInformerFactory interface {
+	internalinterfaces.SharedInformerFactory
+	ForResource(resource schema.GroupVersionResource) (GenericInformer, error)
+	WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool
+
+	Custom() pipelineloop.Interface
+}
+
+func (f *sharedInformerFactory) Custom() pipelineloop.Interface {
+	return pipelineloop.New(f, f.namespace, f.tweakListOptions)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/generic.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/generic.go
new file mode 100644
index 0000000000..9c237c5e4c
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/generic.go
@@ -0,0 +1,62 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package externalversions
+
+import (
+	"fmt"
+
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	cache "k8s.io/client-go/tools/cache"
+)
+
+// GenericInformer is type of SharedIndexInformer which will locate and delegate to other
+// sharedInformers based on type
+type GenericInformer interface {
+	Informer() cache.SharedIndexInformer
+	Lister() cache.GenericLister
+}
+
+type genericInformer struct {
+	informer cache.SharedIndexInformer
+	resource schema.GroupResource
+}
+
+// Informer returns the SharedIndexInformer.
+func (f *genericInformer) Informer() cache.SharedIndexInformer {
+	return f.informer
+}
+
+// Lister returns the GenericLister.
+func (f *genericInformer) Lister() cache.GenericLister {
+	return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource)
+}
+
+// ForResource gives generic access to a shared informer of the matching type
+// TODO extend this to unknown resources with a client pool
+func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {
+	switch resource {
+	// Group=custom.tekton.dev, Version=v1alpha1
+	case v1alpha1.SchemeGroupVersion.WithResource("pipelineloops"):
+		return &genericInformer{resource: resource.GroupResource(), informer: f.Custom().V1alpha1().PipelineLoops().Informer()}, nil
+
+	}
+
+	return nil, fmt.Errorf("no informer found for %v", resource)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go
new file mode 100644
index 0000000000..22748e3b5f
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go
@@ -0,0 +1,40 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package internalinterfaces
+
+import (
+	time "time"
+
+	versioned "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	cache "k8s.io/client-go/tools/cache"
+)
+
+// NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer.
+type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer
+
+// SharedInformerFactory a small interface to allow for adding an informer without an import cycle
+type SharedInformerFactory interface {
+	Start(stopCh <-chan struct{})
+	InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer
+}
+
+// TweakListOptionsFunc is a function that transforms a v1.ListOptions.
+type TweakListOptionsFunc func(*v1.ListOptions)
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/interface.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/interface.go
new file mode 100644
index 0000000000..7545c329e1
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/interface.go
@@ -0,0 +1,46 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package pipelineloop
+
+import (
+	internalinterfaces "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces"
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1"
+)
+
+// Interface provides access to each of this group's versions.
+type Interface interface {
+	// V1alpha1 provides access to shared informers for resources in V1alpha1.
+	V1alpha1() v1alpha1.Interface
+}
+
+type group struct {
+	factory          internalinterfaces.SharedInformerFactory
+	namespace        string
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+}
+
+// New returns a new Interface.
+func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {
+	return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
+}
+
+// V1alpha1 returns a new v1alpha1.Interface.
+func (g *group) V1alpha1() v1alpha1.Interface {
+	return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/interface.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/interface.go
new file mode 100644
index 0000000000..c7feca5d16
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/interface.go
@@ -0,0 +1,45 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	internalinterfaces "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces"
+)
+
+// Interface provides access to all the informers in this group version.
+type Interface interface {
+	// PipelineLoops returns a PipelineLoopInformer.
+	PipelineLoops() PipelineLoopInformer
+}
+
+type version struct {
+	factory          internalinterfaces.SharedInformerFactory
+	namespace        string
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+}
+
+// New returns a new Interface.
+func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {
+	return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
+}
+
+// PipelineLoops returns a PipelineLoopInformer.
+func (v *version) PipelineLoops() PipelineLoopInformer {
+	return &pipelineLoopInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/pipelineloop.go b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/pipelineloop.go
new file mode 100644
index 0000000000..9d15d597d5
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1/pipelineloop.go
@@ -0,0 +1,90 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"context"
+	time "time"
+
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	versioned "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	internalinterfaces "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/internalinterfaces"
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	watch "k8s.io/apimachinery/pkg/watch"
+	cache "k8s.io/client-go/tools/cache"
+)
+
+// PipelineLoopInformer provides access to a shared informer and lister for
+// PipelineLoops.
+type PipelineLoopInformer interface {
+	Informer() cache.SharedIndexInformer
+	Lister() v1alpha1.PipelineLoopLister
+}
+
+type pipelineLoopInformer struct {
+	factory          internalinterfaces.SharedInformerFactory
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+	namespace        string
+}
+
+// NewPipelineLoopInformer constructs a new informer for PipelineLoop type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewPipelineLoopInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
+	return NewFilteredPipelineLoopInformer(client, namespace, resyncPeriod, indexers, nil)
+}
+
+// NewFilteredPipelineLoopInformer constructs a new informer for PipelineLoop type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewFilteredPipelineLoopInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {
+	return cache.NewSharedIndexInformer(
+		&cache.ListWatch{
+			ListFunc: func(options v1.ListOptions) (runtime.Object, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.CustomV1alpha1().PipelineLoops(namespace).List(context.TODO(), options)
+			},
+			WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.CustomV1alpha1().PipelineLoops(namespace).Watch(context.TODO(), options)
+			},
+		},
+		&pipelineloopv1alpha1.PipelineLoop{},
+		resyncPeriod,
+		indexers,
+	)
+}
+
+func (f *pipelineLoopInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
+	return NewFilteredPipelineLoopInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
+}
+
+func (f *pipelineLoopInformer) Informer() cache.SharedIndexInformer {
+	return f.factory.InformerFor(&pipelineloopv1alpha1.PipelineLoop{}, f.defaultInformer)
+}
+
+func (f *pipelineLoopInformer) Lister() v1alpha1.PipelineLoopLister {
+	return v1alpha1.NewPipelineLoopLister(f.Informer().GetIndexer())
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/client/client.go b/tekton-catalog/pipeline-loops/pkg/client/injection/client/client.go
new file mode 100644
index 0000000000..c395d6358a
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/client/client.go
@@ -0,0 +1,49 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package client
+
+import (
+	context "context"
+
+	versioned "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	rest "k8s.io/client-go/rest"
+	injection "knative.dev/pkg/injection"
+	logging "knative.dev/pkg/logging"
+)
+
+func init() {
+	injection.Default.RegisterClient(withClient)
+}
+
+// Key is used as the key for associating information with a context.Context.
+type Key struct{}
+
+func withClient(ctx context.Context, cfg *rest.Config) context.Context {
+	return context.WithValue(ctx, Key{}, versioned.NewForConfigOrDie(cfg))
+}
+
+// Get extracts the versioned.Interface client from the context.
+func Get(ctx context.Context) versioned.Interface {
+	untyped := ctx.Value(Key{})
+	if untyped == nil {
+		logging.FromContext(ctx).Panic(
+			"Unable to fetch github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned.Interface from context.")
+	}
+	return untyped.(versioned.Interface)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/client/fake/fake.go b/tekton-catalog/pipeline-loops/pkg/client/injection/client/fake/fake.go
new file mode 100644
index 0000000000..e145d76a0c
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/client/fake/fake.go
@@ -0,0 +1,54 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	context "context"
+
+	fake "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake"
+	client "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/client"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	rest "k8s.io/client-go/rest"
+	injection "knative.dev/pkg/injection"
+	logging "knative.dev/pkg/logging"
+)
+
+func init() {
+	injection.Fake.RegisterClient(withClient)
+}
+
+func withClient(ctx context.Context, cfg *rest.Config) context.Context {
+	ctx, _ = With(ctx)
+	return ctx
+}
+
+func With(ctx context.Context, objects ...runtime.Object) (context.Context, *fake.Clientset) {
+	cs := fake.NewSimpleClientset(objects...)
+	return context.WithValue(ctx, client.Key{}, cs), cs
+}
+
+// Get extracts the Kubernetes client from the context.
+func Get(ctx context.Context) *fake.Clientset {
+	untyped := ctx.Value(client.Key{})
+	if untyped == nil {
+		logging.FromContext(ctx).Panic(
+			"Unable to fetch github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned/fake.Clientset from context.")
+	}
+	return untyped.(*fake.Clientset)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/factory.go b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/factory.go
new file mode 100644
index 0000000000..c388f72993
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/factory.go
@@ -0,0 +1,56 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package factory
+
+import (
+	context "context"
+
+	externalversions "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions"
+	client "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/client"
+	controller "knative.dev/pkg/controller"
+	injection "knative.dev/pkg/injection"
+	logging "knative.dev/pkg/logging"
+)
+
+func init() {
+	injection.Default.RegisterInformerFactory(withInformerFactory)
+}
+
+// Key is used as the key for associating information with a context.Context.
+type Key struct{}
+
+func withInformerFactory(ctx context.Context) context.Context {
+	c := client.Get(ctx)
+	opts := make([]externalversions.SharedInformerOption, 0, 1)
+	if injection.HasNamespaceScope(ctx) {
+		opts = append(opts, externalversions.WithNamespace(injection.GetNamespaceScope(ctx)))
+	}
+	return context.WithValue(ctx, Key{},
+		externalversions.NewSharedInformerFactoryWithOptions(c, controller.GetResyncPeriod(ctx), opts...))
+}
+
+// Get extracts the InformerFactory from the context.
+func Get(ctx context.Context) externalversions.SharedInformerFactory {
+	untyped := ctx.Value(Key{})
+	if untyped == nil {
+		logging.FromContext(ctx).Panic(
+			"Unable to fetch github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions.SharedInformerFactory from context.")
+	}
+	return untyped.(externalversions.SharedInformerFactory)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/fake/fake.go b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/fake/fake.go
new file mode 100644
index 0000000000..78b0275cd2
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/fake/fake.go
@@ -0,0 +1,45 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	context "context"
+
+	externalversions "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions"
+	fake "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/client/fake"
+	factory "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory"
+	controller "knative.dev/pkg/controller"
+	injection "knative.dev/pkg/injection"
+)
+
+var Get = factory.Get
+
+func init() {
+	injection.Fake.RegisterInformerFactory(withInformerFactory)
+}
+
+func withInformerFactory(ctx context.Context) context.Context {
+	c := fake.Get(ctx)
+	opts := make([]externalversions.SharedInformerOption, 0, 1)
+	if injection.HasNamespaceScope(ctx) {
+		opts = append(opts, externalversions.WithNamespace(injection.GetNamespaceScope(ctx)))
+	}
+	return context.WithValue(ctx, factory.Key{},
+		externalversions.NewSharedInformerFactoryWithOptions(c, controller.GetResyncPeriod(ctx), opts...))
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/fake/fake.go b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/fake/fake.go
new file mode 100644
index 0000000000..8215d250e2
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/fake/fake.go
@@ -0,0 +1,40 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	context "context"
+
+	fake "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory/fake"
+	pipelineloop "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop"
+	controller "knative.dev/pkg/controller"
+	injection "knative.dev/pkg/injection"
+)
+
+var Get = pipelineloop.Get
+
+func init() {
+	injection.Fake.RegisterInformer(withInformer)
+}
+
+func withInformer(ctx context.Context) (context.Context, controller.Informer) {
+	f := fake.Get(ctx)
+	inf := f.Custom().V1alpha1().PipelineLoops()
+	return context.WithValue(ctx, pipelineloop.Key{}, inf), inf.Informer()
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/pipelineloop.go b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/pipelineloop.go
new file mode 100644
index 0000000000..03ba08012e
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/pipelineloop.go
@@ -0,0 +1,52 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by injection-gen. DO NOT EDIT.
+
+package pipelineloop
+
+import (
+	context "context"
+
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1"
+	factory "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/factory"
+	controller "knative.dev/pkg/controller"
+	injection "knative.dev/pkg/injection"
+	logging "knative.dev/pkg/logging"
+)
+
+func init() {
+	injection.Default.RegisterInformer(withInformer)
+}
+
+// Key is used for associating the Informer inside the context.Context.
+type Key struct{}
+
+func withInformer(ctx context.Context) (context.Context, controller.Informer) {
+	f := factory.Get(ctx)
+	inf := f.Custom().V1alpha1().PipelineLoops()
+	return context.WithValue(ctx, Key{}, inf), inf.Informer()
+}
+
+// Get extracts the typed informer from the context.
+func Get(ctx context.Context) v1alpha1.PipelineLoopInformer {
+	untyped := ctx.Value(Key{})
+	if untyped == nil {
+		logging.FromContext(ctx).Panic(
+			"Unable to fetch github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/informers/externalversions/pipelineloop/v1alpha1.PipelineLoopInformer from context.")
+	}
+	return untyped.(v1alpha1.PipelineLoopInformer)
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/expansion_generated.go b/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/expansion_generated.go
new file mode 100644
index 0000000000..41cb668add
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/expansion_generated.go
@@ -0,0 +1,27 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// PipelineLoopListerExpansion allows custom methods to be added to
+// PipelineLoopLister.
+type PipelineLoopListerExpansion interface{}
+
+// PipelineLoopNamespaceListerExpansion allows custom methods to be added to
+// PipelineLoopNamespaceLister.
+type PipelineLoopNamespaceListerExpansion interface{}
diff --git a/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/pipelineloop.go b/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/pipelineloop.go
new file mode 100644
index 0000000000..b56d805181
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1/pipelineloop.go
@@ -0,0 +1,94 @@
+/*
+Copyright 2020 The Knative 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.
+*/
+
+// Code generated by lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/client-go/tools/cache"
+)
+
+// PipelineLoopLister helps list PipelineLoops.
+type PipelineLoopLister interface {
+	// List lists all PipelineLoops in the indexer.
+	List(selector labels.Selector) (ret []*v1alpha1.PipelineLoop, err error)
+	// PipelineLoops returns an object that can list and get PipelineLoops.
+	PipelineLoops(namespace string) PipelineLoopNamespaceLister
+	PipelineLoopListerExpansion
+}
+
+// pipelineLoopLister implements the PipelineLoopLister interface.
+type pipelineLoopLister struct {
+	indexer cache.Indexer
+}
+
+// NewPipelineLoopLister returns a new PipelineLoopLister.
+func NewPipelineLoopLister(indexer cache.Indexer) PipelineLoopLister {
+	return &pipelineLoopLister{indexer: indexer}
+}
+
+// List lists all PipelineLoops in the indexer.
+func (s *pipelineLoopLister) List(selector labels.Selector) (ret []*v1alpha1.PipelineLoop, err error) {
+	err = cache.ListAll(s.indexer, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.PipelineLoop))
+	})
+	return ret, err
+}
+
+// PipelineLoops returns an object that can list and get PipelineLoops.
+func (s *pipelineLoopLister) PipelineLoops(namespace string) PipelineLoopNamespaceLister {
+	return pipelineLoopNamespaceLister{indexer: s.indexer, namespace: namespace}
+}
+
+// PipelineLoopNamespaceLister helps list and get PipelineLoops.
+type PipelineLoopNamespaceLister interface {
+	// List lists all PipelineLoops in the indexer for a given namespace.
+	List(selector labels.Selector) (ret []*v1alpha1.PipelineLoop, err error)
+	// Get retrieves the PipelineLoop from the indexer for a given namespace and name.
+	Get(name string) (*v1alpha1.PipelineLoop, error)
+	PipelineLoopNamespaceListerExpansion
+}
+
+// pipelineLoopNamespaceLister implements the PipelineLoopNamespaceLister
+// interface.
+type pipelineLoopNamespaceLister struct {
+	indexer   cache.Indexer
+	namespace string
+}
+
+// List lists all PipelineLoops in the indexer for a given namespace.
+func (s pipelineLoopNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.PipelineLoop, err error) {
+	err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.PipelineLoop))
+	})
+	return ret, err
+}
+
+// Get retrieves the PipelineLoop from the indexer for a given namespace and name.
+func (s pipelineLoopNamespaceLister) Get(name string) (*v1alpha1.PipelineLoop, error) {
+	obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name)
+	if err != nil {
+		return nil, err
+	}
+	if !exists {
+		return nil, errors.NewNotFound(v1alpha1.Resource("pipelineloop"), name)
+	}
+	return obj.(*v1alpha1.PipelineLoop), nil
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/controller.go b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/controller.go
new file mode 100644
index 0000000000..07fae50447
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/controller.go
@@ -0,0 +1,78 @@
+/*
+Copyright 2020 The Knative 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 pipelinelooprun
+
+import (
+	"context"
+
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop"
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	pipelineloopclient "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/client"
+	pipelineloopinformer "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop"
+	pipelineclient "github.com/tektoncd/pipeline/pkg/client/injection/client"
+	runinformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1alpha1/run"
+	pipelineruninformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/pipelinerun"
+	runreconciler "github.com/tektoncd/pipeline/pkg/client/injection/reconciler/pipeline/v1alpha1/run"
+	pipelinecontroller "github.com/tektoncd/pipeline/pkg/controller"
+	"k8s.io/client-go/tools/cache"
+	"knative.dev/pkg/configmap"
+	"knative.dev/pkg/controller"
+	"knative.dev/pkg/logging"
+)
+
+// NewController instantiates a new controller.Impl from knative.dev/pkg/controller
+func NewController(namespace string) func(context.Context, configmap.Watcher) *controller.Impl {
+	return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl {
+
+		logger := logging.FromContext(ctx)
+		pipelineclientset := pipelineclient.Get(ctx)
+		pipelineloopclientset := pipelineloopclient.Get(ctx)
+		runInformer := runinformer.Get(ctx)
+		pipelineLoopInformer := pipelineloopinformer.Get(ctx)
+		pipelineRunInformer := pipelineruninformer.Get(ctx)
+
+		c := &Reconciler{
+			pipelineClientSet: pipelineclientset,
+			pipelineloopClientSet: pipelineloopclientset,
+			runLister:         runInformer.Lister(),
+			pipelineLoopLister:    pipelineLoopInformer.Lister(),
+			pipelineRunLister: pipelineRunInformer.Lister(),
+		}
+
+		impl := runreconciler.NewImpl(ctx, c, func(impl *controller.Impl) controller.Options {
+			return controller.Options{
+				AgentName: "run-pipelineloop",
+			}
+		})
+
+		logger.Info("Setting up event handlers")
+
+		// Add event handler for Runs
+		runInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{
+			FilterFunc: pipelinecontroller.FilterRunRef(pipelineloopv1alpha1.SchemeGroupVersion.String(), pipelineloop.PipelineLoopControllerName),
+			Handler:    controller.HandleAll(impl.Enqueue),
+		})
+
+		// Add event handler for PipelineRuns controlled by Run
+		pipelineRunInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{
+			FilterFunc: pipelinecontroller.FilterOwnerRunRef(runInformer.Lister(), pipelineloopv1alpha1.SchemeGroupVersion.String(), pipelineloop.PipelineLoopControllerName),
+			Handler:    controller.HandleAll(impl.EnqueueControllerOf),
+		})
+
+		return impl
+	}
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun.go b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun.go
new file mode 100644
index 0000000000..c475895ede
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun.go
@@ -0,0 +1,582 @@
+/*
+Copyright 2020 The Knative 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 pipelinelooprun
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strconv"
+	"time"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned"
+	runreconciler "github.com/tektoncd/pipeline/pkg/client/injection/reconciler/pipeline/v1alpha1/run"
+	listersalpha "github.com/tektoncd/pipeline/pkg/client/listers/pipeline/v1alpha1"
+	listers "github.com/tektoncd/pipeline/pkg/client/listers/pipeline/v1beta1"
+	"github.com/tektoncd/pipeline/pkg/names"
+	"github.com/tektoncd/pipeline/pkg/reconciler/events"
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop"
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	pipelineloopclientset "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/clientset/versioned"
+	listerspipelineloop "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/listers/pipelineloop/v1alpha1"
+	"go.uber.org/zap"
+	"gomodules.xyz/jsonpatch/v2"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
+	"knative.dev/pkg/apis"
+	"knative.dev/pkg/logging"
+	pkgreconciler "knative.dev/pkg/reconciler"
+)
+
+const (
+	// pipelineLoopLabelKey is the label identifier for a PipelineLoop.  This label is added to the Run and its PipelineRuns.
+	pipelineLoopLabelKey = "/pipelineLoop"
+
+	// pipelineLoopRunLabelKey is the label identifier for a Run.  This label is added to the Run's PipelineRuns.
+	pipelineLoopRunLabelKey = "/run"
+
+	// pipelineLoopIterationLabelKey is the label identifier for the iteration number.  This label is added to the Run's PipelineRuns.
+	pipelineLoopIterationLabelKey = "/pipelineLoopIteration"
+)
+
+// Reconciler implements controller.Reconciler for Configuration resources.
+type Reconciler struct {
+	pipelineClientSet     clientset.Interface
+	pipelineloopClientSet pipelineloopclientset.Interface
+	runLister             listersalpha.RunLister
+	pipelineLoopLister    listerspipelineloop.PipelineLoopLister
+	pipelineRunLister     listers.PipelineRunLister
+}
+
+var (
+	// Check that our Reconciler implements runreconciler.Interface
+	_ runreconciler.Interface = (*Reconciler)(nil)
+)
+
+// ReconcileKind compares the actual state with the desired, and attempts to converge the two.
+// It then updates the Status block of the Run resource with the current status of the resource.
+func (c *Reconciler) ReconcileKind(ctx context.Context, run *v1alpha1.Run) pkgreconciler.Event {
+	var merr error
+	logger := logging.FromContext(ctx)
+	logger.Infof("Reconciling Run %s/%s at %v", run.Namespace, run.Name, time.Now())
+
+	// Check that the Run references a PipelineLoop CRD.  The logic is controller.go should ensure that only this type of Run
+	// is reconciled this controller but it never hurts to do some bullet-proofing.
+	if run.Spec.Ref == nil ||
+		run.Spec.Ref.APIVersion != pipelineloopv1alpha1.SchemeGroupVersion.String() ||
+		run.Spec.Ref.Kind != pipelineloop.PipelineLoopControllerName {
+		logger.Errorf("Received control for a Run %s/%s that does not reference a PipelineLoop custom CRD", run.Namespace, run.Name)
+		return nil
+	}
+
+	// If the Run has not started, initialize the Condition and set the start time.
+	if !run.HasStarted() {
+		logger.Infof("Starting new Run %s/%s", run.Namespace, run.Name)
+		run.Status.InitializeConditions()
+		// In case node time was not synchronized, when controller has been scheduled to other nodes.
+		if run.Status.StartTime.Sub(run.CreationTimestamp.Time) < 0 {
+			logger.Warnf("Run %s createTimestamp %s is after the Run started %s", run.Name, run.CreationTimestamp, run.Status.StartTime)
+			run.Status.StartTime = &run.CreationTimestamp
+		}
+		// Emit events. During the first reconcile the status of the Run may change twice
+		// from not Started to Started and then to Running, so we need to sent the event here
+		// and at the end of 'Reconcile' again.
+		// We also want to send the "Started" event as soon as possible for anyone who may be waiting
+		// on the event to perform user facing initialisations, such has reset a CI check status
+		afterCondition := run.Status.GetCondition(apis.ConditionSucceeded)
+		events.Emit(ctx, nil, afterCondition, run)
+	}
+
+	if run.IsDone() {
+		logger.Infof("Run %s/%s is done", run.Namespace, run.Name)
+		return nil
+	}
+
+	// Store the condition before reconcile
+	beforeCondition := run.Status.GetCondition(apis.ConditionSucceeded)
+
+	status := &pipelineloopv1alpha1.PipelineLoopRunStatus{}
+	if err := run.Status.DecodeExtraFields(status); err != nil {
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonInternalError.String(),
+			"Internal error calling DecodeExtraFields: %v", err)
+		logger.Errorf("DecodeExtraFields error: %v", err.Error())
+	}
+
+	// Reconcile the Run
+	if err := c.reconcile(ctx, run, status); err != nil {
+		logger.Errorf("Reconcile error: %v", err.Error())
+		merr = multierror.Append(merr, err)
+	}
+
+	if err := c.updateLabelsAndAnnotations(ctx, run); err != nil {
+		logger.Warn("Failed to update Run labels/annotations", zap.Error(err))
+		merr = multierror.Append(merr, err)
+	}
+
+	if err := run.Status.EncodeExtraFields(status); err != nil {
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonInternalError.String(),
+			"Internal error calling EncodeExtraFields: %v", err)
+		logger.Errorf("EncodeExtraFields error: %v", err.Error())
+	}
+
+	afterCondition := run.Status.GetCondition(apis.ConditionSucceeded)
+	events.Emit(ctx, beforeCondition, afterCondition, run)
+
+	// Only transient errors that should retry the reconcile are returned.
+	return merr
+}
+
+func (c *Reconciler) reconcile(ctx context.Context, run *v1alpha1.Run, status *pipelineloopv1alpha1.PipelineLoopRunStatus) error {
+	logger := logging.FromContext(ctx)
+
+	// Get the PipelineLoop referenced by the Run
+	pipelineLoopMeta, pipelineLoopSpec, err := c.getPipelineLoop(ctx, run)
+	if err != nil {
+		return nil
+	}
+
+	// Store the fetched PipelineLoopSpec on the Run for auditing
+	storePipelineLoopSpec(status, pipelineLoopSpec)
+
+	// Propagate labels and annotations from PipelineLoop to Run.
+	propagatePipelineLoopLabelsAndAnnotations(run, pipelineLoopMeta)
+
+	// Validate PipelineLoop spec
+	if err := pipelineLoopSpec.Validate(ctx); err != nil {
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonFailedValidation.String(),
+			"PipelineLoop %s/%s can't be Run; it has an invalid spec: %s",
+			pipelineLoopMeta.Namespace, pipelineLoopMeta.Name, err)
+		return nil
+	}
+
+	// Determine how many iterations of the Task will be done.
+	totalIterations, err := computeIterations(run, pipelineLoopSpec)
+	if err != nil {
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonFailedValidation.String(),
+			"Cannot determine number of iterations: %s", err)
+		return nil
+	}
+
+	// Update status of PipelineRuns.  Return the PipelineRun representing the highest loop iteration.
+	highestIteration, highestIterationPr, err := c.updatePipelineRunStatus(logger, run, status)
+	if err != nil {
+		return fmt.Errorf("error updating PipelineRun status for Run %s/%s: %w", run.Namespace, run.Name, err)
+	}
+
+	// Check the status of the PipelineRun for the highest iteration.
+	if highestIterationPr != nil {
+		// If it's not done, wait for it to finish or cancel it if the run is cancelled.
+		if !highestIterationPr.IsDone() {
+			if run.IsCancelled() {
+				logger.Infof("Run %s/%s is cancelled.  Cancelling PipelineRun %s.", run.Namespace, run.Name, highestIterationPr.Name)
+				b, err := getCancelPatch()
+				if err != nil {
+					return fmt.Errorf("Failed to make patch to cancel PipelineRun %s: %v", highestIterationPr.Name, err)
+				}
+				if _, err := c.pipelineClientSet.TektonV1beta1().PipelineRuns(run.Namespace).Patch(ctx, highestIterationPr.Name, types.JSONPatchType, b, metav1.PatchOptions{}); err != nil {
+					run.Status.MarkRunRunning(pipelineloopv1alpha1.PipelineLoopRunReasonCouldntCancel.String(),
+						"Failed to patch PipelineRun `%s` with cancellation: %v", highestIterationPr.Name, err)
+					return nil
+				}
+				// Update status. It is still running until the PipelineRun is actually cancelled.
+				run.Status.MarkRunRunning(pipelineloopv1alpha1.PipelineLoopRunReasonRunning.String(),
+					"Cancelling PipelineRun %s", highestIterationPr.Name)
+				return nil
+			}
+			run.Status.MarkRunRunning(pipelineloopv1alpha1.PipelineLoopRunReasonRunning.String(),
+				"Iterations completed: %d", highestIteration-1)
+			return nil
+		}
+
+		if !highestIterationPr.Status.GetCondition(apis.ConditionSucceeded).IsTrue() {
+			if run.IsCancelled() {
+				run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonCancelled.String(),
+					"Run %s/%s was cancelled",
+					run.Namespace, run.Name)
+			} else {
+				run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonFailed.String(),
+					"PipelineRun %s has failed", highestIterationPr.Name)
+			}
+			return nil
+		}
+
+		// Mark run successful if the condition are met.
+		// if the last loop task is skipped, but the highestIterationPr successed. Mark run success.
+		// lastLoopTask := highestIterationPr.ObjectMeta.Annotations["last-loop-task"]
+		lastLoopTask := ""
+		for key, val := range run.ObjectMeta.Labels {
+			if key == "last-loop-task" {
+				lastLoopTask = val
+			}
+		}
+		if lastLoopTask != "" {
+			skippedTaskList := highestIterationPr.Status.SkippedTasks
+			for _, task := range skippedTaskList {
+				if task.Name == lastLoopTask {
+					// Mark run successful and stop the loop pipelinerun
+					run.Status.MarkRunSucceeded(pipelineloopv1alpha1.PipelineLoopRunReasonSucceeded.String(),
+						"PipelineRuns completed successfully with the conditions are met")
+					run.Status.Results = []v1beta1.TaskRunResult{{
+						Name:  "condition",
+						Value: "pass",
+					}}
+					return nil
+				}
+			}
+		}
+	}
+
+	// Move on to the next iteration (or the first iteration if there was no PipelineRun).
+	// Check if the Run is done.
+	nextIteration := highestIteration + 1
+	if nextIteration > totalIterations {
+		run.Status.MarkRunSucceeded(pipelineloopv1alpha1.PipelineLoopRunReasonSucceeded.String(),
+			"All PipelineRuns completed successfully")
+		run.Status.Results = []v1beta1.TaskRunResult{{
+			Name:  "condition",
+			Value: "fail",
+		}}
+		return nil
+	}
+	// Before starting up another PipelineRun, check if the run was cancelled.
+	if run.IsCancelled() {
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonCancelled.String(),
+			"Run %s/%s was cancelled",
+			run.Namespace, run.Name)
+		return nil
+	}
+
+	// Create a PipelineRun to run this iteration.
+	pr, err := c.createPipelineRun(ctx, logger, pipelineLoopSpec, run, nextIteration)
+	if err != nil {
+		return fmt.Errorf("error creating PipelineRun from Run %s: %w", run.Name, err)
+	}
+
+	status.PipelineRuns[pr.Name] = &pipelineloopv1alpha1.PipelineLoopPipelineRunStatus{
+		Iteration: nextIteration,
+		Status:    &pr.Status,
+	}
+
+	run.Status.MarkRunRunning(pipelineloopv1alpha1.PipelineLoopRunReasonRunning.String(),
+		"Iterations completed: %d", highestIteration)
+
+	return nil
+}
+
+func (c *Reconciler) getPipelineLoop(ctx context.Context, run *v1alpha1.Run) (*metav1.ObjectMeta, *pipelineloopv1alpha1.PipelineLoopSpec, error) {
+	pipelineLoopMeta := metav1.ObjectMeta{}
+	pipelineLoopSpec := pipelineloopv1alpha1.PipelineLoopSpec{}
+	if run.Spec.Ref != nil && run.Spec.Ref.Name != "" {
+		// Use the k8 client to get the PipelineLoop rather than the lister.  This avoids a timing issue where
+		// the PipelineLoop is not yet in the lister cache if it is created at nearly the same time as the Run.
+		// See https://github.com/tektoncd/pipeline/issues/2740 for discussion on this issue.
+		//
+		// tl, err := c.pipelineLoopLister.PipelineLoops(run.Namespace).Get(run.Spec.Ref.Name)
+		tl, err := c.pipelineloopClientSet.CustomV1alpha1().PipelineLoops(run.Namespace).Get(ctx, run.Spec.Ref.Name, metav1.GetOptions{})
+		if err != nil {
+			run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonCouldntGetPipelineLoop.String(),
+				"Error retrieving PipelineLoop for Run %s/%s: %s",
+				run.Namespace, run.Name, err)
+			return nil, nil, fmt.Errorf("Error retrieving PipelineLoop for Run %s: %w", fmt.Sprintf("%s/%s", run.Namespace, run.Name), err)
+		}
+		pipelineLoopMeta = tl.ObjectMeta
+		pipelineLoopSpec = tl.Spec
+	} else {
+		// Run does not require name but for PipelineLoop it does.
+		run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonCouldntGetPipelineLoop.String(),
+			"Missing spec.ref.name for Run %s/%s",
+			run.Namespace, run.Name)
+		return nil, nil, fmt.Errorf("Missing spec.ref.name for Run %s", fmt.Sprintf("%s/%s", run.Namespace, run.Name))
+	}
+	return &pipelineLoopMeta, &pipelineLoopSpec, nil
+}
+
+func (c *Reconciler) createPipelineRun(ctx context.Context, logger *zap.SugaredLogger, tls *pipelineloopv1alpha1.PipelineLoopSpec, run *v1alpha1.Run, iteration int) (*v1beta1.PipelineRun, error) {
+
+	// Create name for PipelineRun from Run name plus iteration number.
+	prName := names.SimpleNameGenerator.RestrictLengthWithRandomSuffix(fmt.Sprintf("%s-%s", run.Name, fmt.Sprintf("%05d", iteration)))
+
+	pr := &v1beta1.PipelineRun{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            prName,
+			Namespace:       run.Namespace,
+			OwnerReferences: []metav1.OwnerReference{run.GetOwnerReference()},
+			Labels:          getPipelineRunLabels(run, strconv.Itoa(iteration)),
+			Annotations:     getPipelineRunAnnotations(run),
+		},
+		Spec: v1beta1.PipelineRunSpec{
+			Params:             getParameters(run, tls, iteration),
+			Timeout:            tls.Timeout,
+			ServiceAccountName: "",  // TODO: Implement service account name
+			PodTemplate:        nil, // TODO: Implement pod template
+		}}
+
+	if tls.PipelineRef != nil {
+		pr.Spec.PipelineRef = &v1beta1.PipelineRef{
+			Name: tls.PipelineRef.Name,
+			// Kind: tls.PipelineRef.Kind,
+		}
+	} else if tls.PipelineSpec != nil {
+		pr.Spec.PipelineSpec = tls.PipelineSpec
+	}
+
+	logger.Infof("Creating a new PipelineRun object %s", prName)
+	return c.pipelineClientSet.TektonV1beta1().PipelineRuns(run.Namespace).Create(ctx, pr, metav1.CreateOptions{})
+
+}
+
+// func (c *Reconciler) retryPipelineRun(ctx context.Context, tr *v1beta1.PipelineRun) (*v1beta1.PipelineRun, error) {
+// 	newStatus := *tr.Status.DeepCopy()
+// 	newStatus.RetriesStatus = nil
+// 	tr.Status.RetriesStatus = append(tr.Status.RetriesStatus, newStatus)
+// 	tr.Status.StartTime = nil
+// 	tr.Status.CompletionTime = nil
+// 	tr.Status.PodName = ""
+// 	tr.Status.SetCondition(&apis.Condition{
+// 		Type:   apis.ConditionSucceeded,
+// 		Status: corev1.ConditionUnknown,
+// 	})
+// 	return c.pipelineClientSet.TektonV1beta1().PipelineRuns(tr.Namespace).UpdateStatus(ctx, tr, metav1.UpdateOptions{})
+// }
+
+func (c *Reconciler) updateLabelsAndAnnotations(ctx context.Context, run *v1alpha1.Run) error {
+	newRun, err := c.runLister.Runs(run.Namespace).Get(run.Name)
+	if err != nil {
+		return fmt.Errorf("error getting Run %s when updating labels/annotations: %w", run.Name, err)
+	}
+	if !reflect.DeepEqual(run.ObjectMeta.Labels, newRun.ObjectMeta.Labels) || !reflect.DeepEqual(run.ObjectMeta.Annotations, newRun.ObjectMeta.Annotations) {
+		mergePatch := map[string]interface{}{
+			"metadata": map[string]interface{}{
+				"labels":      run.ObjectMeta.Labels,
+				"annotations": run.ObjectMeta.Annotations,
+			},
+		}
+		patch, err := json.Marshal(mergePatch)
+		if err != nil {
+			return err
+		}
+		_, err = c.pipelineClientSet.TektonV1alpha1().Runs(run.Namespace).Patch(ctx, run.Name, types.MergePatchType, patch, metav1.PatchOptions{})
+		return err
+	}
+	return nil
+}
+
+func (c *Reconciler) updatePipelineRunStatus(logger *zap.SugaredLogger, run *v1alpha1.Run, status *pipelineloopv1alpha1.PipelineLoopRunStatus) (int, *v1beta1.PipelineRun, error) {
+	highestIteration := 0
+	var highestIterationPr *v1beta1.PipelineRun = nil
+	if status.PipelineRuns == nil {
+		status.PipelineRuns = make(map[string]*pipelineloopv1alpha1.PipelineLoopPipelineRunStatus)
+	}
+	pipelineRunLabels := getPipelineRunLabels(run, "")
+	pipelineRuns, err := c.pipelineRunLister.PipelineRuns(run.Namespace).List(labels.SelectorFromSet(pipelineRunLabels))
+	if err != nil {
+		return 0, nil, fmt.Errorf("could not list PipelineRuns %#v", err)
+	}
+	if pipelineRuns == nil || len(pipelineRuns) == 0 {
+		return 0, nil, nil
+	}
+	for _, pr := range pipelineRuns {
+		lbls := pr.GetLabels()
+		iterationStr := lbls[pipelineloop.GroupName+pipelineLoopIterationLabelKey]
+		iteration, err := strconv.Atoi(iterationStr)
+		if err != nil {
+			run.Status.MarkRunFailed(pipelineloopv1alpha1.PipelineLoopRunReasonFailedValidation.String(),
+				"Error converting iteration number in PipelineRun %s:  %#v", pr.Name, err)
+			logger.Errorf("Error converting iteration number in PipelineRun %s:  %#v", pr.Name, err)
+			return 0, nil, nil
+		}
+		status.PipelineRuns[pr.Name] = &pipelineloopv1alpha1.PipelineLoopPipelineRunStatus{
+			Iteration: iteration,
+			Status:    &pr.Status,
+		}
+		if iteration > highestIteration {
+			highestIteration = iteration
+			highestIterationPr = pr
+		}
+	}
+	return highestIteration, highestIterationPr, nil
+}
+
+func getCancelPatch() ([]byte, error) {
+	patches := []jsonpatch.JsonPatchOperation{{
+		Operation: "add",
+		Path:      "/spec/status",
+		Value:     v1beta1.PipelineRunSpecStatusCancelled,
+	}}
+	patchBytes, err := json.Marshal(patches)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal patch bytes in order to cancel: %v", err)
+	}
+	return patchBytes, nil
+}
+
+func computeIterations(run *v1alpha1.Run, tls *pipelineloopv1alpha1.PipelineLoopSpec) (int, error) {
+	// Find the iterate parameter.
+	numberOfIterations := -1
+	from := -1
+	step := -1
+	to := -1
+	for _, p := range run.Spec.Params {
+		if tls.IterateNumeric != "" {
+			if p.Name == "from" {
+				from, _ = strconv.Atoi(p.Value.StringVal)
+			}
+			if p.Name == "step" {
+				step, _ = strconv.Atoi(p.Value.StringVal)
+			}
+			if p.Name == "to" {
+				to, _ = strconv.Atoi(p.Value.StringVal)
+			}
+		} else if p.Name == tls.IterateParam {
+			if p.Value.Type == v1beta1.ParamTypeString {
+				// Transfer p.Value to Array.
+				var strings []string
+				if err := json.Unmarshal([]byte(p.Value.StringVal), &strings); err != nil {
+					return 0, fmt.Errorf("The value of the iterate parameter %q can not transfer to array", tls.IterateParam)
+				}
+				numberOfIterations = len(strings)
+				break
+			}
+			if p.Value.Type == v1beta1.ParamTypeArray {
+				numberOfIterations = len(p.Value.ArrayVal)
+				break
+			}
+		}
+	}
+	if tls.IterateNumeric != "" {
+		if from == -1 || to == -1 {
+			return 0, fmt.Errorf("The from or to parameters was not found in runs")
+		}
+		if step == -1 {
+			step = 1
+		}
+		// numberOfIterations is the number of (to - from) / step + 1
+		numberOfIterations = (to-from)/step + 1
+	} else if numberOfIterations == -1 {
+		return 0, fmt.Errorf("The iterate parameter %q was not found", tls.IterateParam)
+	}
+	return numberOfIterations, nil
+}
+
+func getParameters(run *v1alpha1.Run, tls *pipelineloopv1alpha1.PipelineLoopSpec, iteration int) []v1beta1.Param {
+	var out []v1beta1.Param
+	if tls.IterateParam != "" {
+		// IterateParam defined
+		for i, p := range run.Spec.Params {
+			if p.Name == tls.IterateParam {
+				if p.Value.Type == v1beta1.ParamTypeArray {
+					out = append(out, v1beta1.Param{
+						Name:  p.Name,
+						Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: p.Value.ArrayVal[iteration-1]},
+					})
+				}
+				if p.Value.Type == v1beta1.ParamTypeString {
+					var strings []string
+					_ = json.Unmarshal([]byte(p.Value.StringVal), &strings)
+					out = append(out, v1beta1.Param{
+						Name:  p.Name,
+						Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: strings[iteration-1]},
+					})
+				}
+			} else {
+				out = append(out, run.Spec.Params[i])
+			}
+		}
+	} else {
+		// IterateNumeric defined
+		IterateStrings := []string{"from", "step", "to"}
+		for i, p := range run.Spec.Params {
+			if _, found := Find(IterateStrings, p.Name); !found {
+				out = append(out, run.Spec.Params[i])
+			}
+		}
+		out = append(out, v1beta1.Param{
+			Name:  tls.IterateNumeric,
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: strconv.Itoa(iteration)},
+		})
+	}
+	return out
+}
+
+func getPipelineRunAnnotations(run *v1alpha1.Run) map[string]string {
+	// Propagate annotations from Run to PipelineRun.
+	annotations := make(map[string]string, len(run.ObjectMeta.Annotations)+1)
+	for key, val := range run.ObjectMeta.Annotations {
+		annotations[key] = val
+	}
+	return annotations
+}
+
+// Find takes a slice and looks for an element in it. If found it will
+// return it's key, otherwise it will return -1 and a bool of false.
+func Find(slice []string, val string) (int, bool) {
+	for i, item := range slice {
+		if item == val {
+			return i, true
+		}
+	}
+	return -1, false
+}
+
+func getPipelineRunLabels(run *v1alpha1.Run, iterationStr string) map[string]string {
+	// Propagate labels from Run to PipelineRun.
+	labels := make(map[string]string, len(run.ObjectMeta.Labels)+1)
+	ignnoreLabelsKey := []string{"tekton.dev/pipelineRun", "tekton.dev/pipelineTask", "tekton.dev/pipeline"}
+	for key, val := range run.ObjectMeta.Labels {
+		if _, found := Find(ignnoreLabelsKey, key); !found {
+			labels[key] = val
+		}
+	}
+	// Note: The Run label uses the normal Tekton group name.
+	labels[pipeline.GroupName+pipelineLoopRunLabelKey] = run.Name
+	if iterationStr != "" {
+		labels[pipelineloop.GroupName+pipelineLoopIterationLabelKey] = iterationStr
+	}
+	return labels
+}
+
+func propagatePipelineLoopLabelsAndAnnotations(run *v1alpha1.Run, pipelineLoopMeta *metav1.ObjectMeta) {
+	// Propagate labels from PipelineLoop to Run.
+	if run.ObjectMeta.Labels == nil {
+		run.ObjectMeta.Labels = make(map[string]string, len(pipelineLoopMeta.Labels)+1)
+	}
+	for key, value := range pipelineLoopMeta.Labels {
+		run.ObjectMeta.Labels[key] = value
+	}
+	run.ObjectMeta.Labels[pipelineloop.GroupName+pipelineLoopLabelKey] = pipelineLoopMeta.Name
+
+	// Propagate annotations from PipelineLoop to Run.
+	if run.ObjectMeta.Annotations == nil {
+		run.ObjectMeta.Annotations = make(map[string]string, len(pipelineLoopMeta.Annotations))
+	}
+	for key, value := range pipelineLoopMeta.Annotations {
+		run.ObjectMeta.Annotations[key] = value
+	}
+}
+
+func storePipelineLoopSpec(status *pipelineloopv1alpha1.PipelineLoopRunStatus, tls *pipelineloopv1alpha1.PipelineLoopSpec) {
+	// Only store the PipelineLoopSpec once, if it has never been set before.
+	if status.PipelineLoopSpec == nil {
+		status.PipelineLoopSpec = tls
+	}
+}


Test patch:
diff --git a/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation_test.go b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation_test.go
new file mode 100644
index 0000000000..b42f9df5e3
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1/pipelineloop_validation_test.go
@@ -0,0 +1,207 @@
+/*
+Copyright 2020 The Knative 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 v1alpha1_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	"github.com/tektoncd/pipeline/test/diff"
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"knative.dev/pkg/apis"
+)
+
+func TestPipelineLoop_Validate_Success(t *testing.T) {
+	tests := []struct {
+		name string
+		tl   *pipelineloopv1alpha1.PipelineLoop
+	}{{
+		name: "pipelineRef",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				PipelineRef: &v1beta1.PipelineRef{Name: "mypipeline"},
+			},
+		},
+	}, {
+		name: "pipelineSpecWithoutParam",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				IterateParam: "messages",
+				PipelineSpec: &v1beta1.PipelineSpec{
+					Tasks: []v1beta1.PipelineTask{{
+						Name: "mytask",
+						TaskSpec: &v1beta1.EmbeddedTask{
+							TaskSpec: v1beta1.TaskSpec{
+								Steps: []v1beta1.Step{{
+									Container: corev1.Container{Name: "foo", Image: "bar"},
+								}},
+							},
+						},
+					}},
+				},
+			},
+		},
+	}, {
+		name: "pipelineSpecWithParams",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				IterateParam: "messages",
+				PipelineSpec: &v1beta1.PipelineSpec{
+					Params: []v1beta1.ParamSpec{{
+						Name: "messages",
+						Type: v1beta1.ParamTypeString,
+					}, {
+						Name: "additional-parameter",
+						Type: v1beta1.ParamTypeString,
+					}},
+					Tasks: []v1beta1.PipelineTask{{
+						Name: "mytask",
+						Params: []v1beta1.Param{{
+							Name:  "messages",
+							Value: v1beta1.ArrayOrString{},
+						}, {
+							Name:  "additional-parameter",
+							Value: v1beta1.ArrayOrString{},
+						}},
+						TaskSpec: &v1beta1.EmbeddedTask{
+							TaskSpec: v1beta1.TaskSpec{
+								Params: []v1beta1.ParamSpec{{
+									Name: "messages",
+									Type: v1beta1.ParamTypeString,
+								}, {
+									Name: "additional-parameter",
+									Type: v1beta1.ParamTypeString,
+								}},
+								Steps: []v1beta1.Step{{
+									Container: corev1.Container{Name: "foo", Image: "bar"},
+								}},
+							},
+						},
+					}},
+				},
+			},
+		},
+	}}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := tc.tl.Validate(context.Background())
+			if err != nil {
+				t.Errorf("Unexpected error for %s: %s", tc.name, err)
+			}
+		})
+	}
+}
+
+func TestPipelineLoop_Validate_Error(t *testing.T) {
+	tests := []struct {
+		name          string
+		tl            *pipelineloopv1alpha1.PipelineLoop
+		expectedError apis.FieldError
+	}{{
+		name: "no pipelineRef or pipelineSpec",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec:       pipelineloopv1alpha1.PipelineLoopSpec{},
+		},
+		expectedError: apis.FieldError{
+			Message: "expected exactly one, got neither",
+			Paths:   []string{"spec.pipelineRef", "spec.pipelineSpec"},
+		},
+	}, {
+		name: "both pipelineRef and pipelineSpec",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				PipelineRef: &v1beta1.PipelineRef{Name: "mypipeline"},
+				PipelineSpec: &v1beta1.PipelineSpec{
+					Tasks: []v1beta1.PipelineTask{{
+						Name: "mytask",
+						TaskSpec: &v1beta1.EmbeddedTask{
+							TaskSpec: v1beta1.TaskSpec{
+								Steps: []v1beta1.Step{{
+									Container: corev1.Container{Name: "foo", Image: "bar"},
+								}},
+							},
+						},
+					}},
+				},
+			},
+		},
+		expectedError: apis.FieldError{
+			Message: "expected exactly one, got both",
+			Paths:   []string{"spec.pipelineRef", "spec.pipelineSpec"},
+		},
+	}, {
+		name: "invalid pipelineRef",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				PipelineRef: &v1beta1.PipelineRef{Name: "_bad"},
+			},
+		},
+		expectedError: apis.FieldError{
+			Message: "invalid value: name part must consist of alphanumeric characters, '-', '_' or '.', and must start " +
+				"and end with an alphanumeric character (e.g. 'MyName',  or 'my.name',  or '123-abc', regex used for " +
+				"validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')",
+			Paths: []string{"spec.pipelineRef.name"},
+		},
+	}, {
+		name: "invalid pipelineSpec",
+		tl: &pipelineloopv1alpha1.PipelineLoop{
+			ObjectMeta: metav1.ObjectMeta{Name: "pipelineloop"},
+			Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+				PipelineSpec: &v1beta1.PipelineSpec{
+					Tasks: []v1beta1.PipelineTask{{
+						Name: "mytask",
+						TaskSpec: &v1beta1.EmbeddedTask{
+							TaskSpec: v1beta1.TaskSpec{
+								Steps: []v1beta1.Step{{
+									Container: corev1.Container{Name: "bad@name!", Image: "bar"},
+								}},
+							},
+						},
+					}},
+				},
+			},
+		},
+		expectedError: apis.FieldError{
+			Message: `invalid value "bad@name!"`,
+			Details: "Task step name must be a valid DNS Label, For more info refer to https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names",
+			Paths:   []string{"spec.pipelineSpec.tasks[0].taskSpec.steps[0].name"},
+		},
+	}}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := tc.tl.Validate(context.Background())
+			if err == nil {
+				t.Errorf("Expected an Error but did not get one for %s", tc.name)
+			} else {
+				if d := cmp.Diff(tc.expectedError.Error(), err.Error(), cmpopts.IgnoreUnexported(apis.FieldError{})); d != "" {
+					t.Errorf("Error is different from expected for %s. diff %s", tc.name, diff.PrintWantGot(d))
+				}
+			}
+		})
+	}
+}
diff --git a/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun_test.go b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun_test.go
new file mode 100644
index 0000000000..ff7a6b4acd
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/pkg/reconciler/pipelinelooprun/pipelinelooprun_test.go
@@ -0,0 +1,1025 @@
+/*
+Copyright 2020 The Knative 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 pipelinelooprun
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop"
+	pipelineloopv1alpha1 "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/apis/pipelineloop/v1alpha1"
+	fakeclient "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/client/fake"
+	fakepipelineloopinformer "github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/pkg/client/injection/informers/pipelineloop/v1alpha1/pipelineloop/fake"
+	"github.com/kubeflow/kfp-tekton/tekton-catalog/pipeline-loops/test"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing"
+	"github.com/tektoncd/pipeline/pkg/system"
+	"github.com/tektoncd/pipeline/test/diff"
+	"github.com/tektoncd/pipeline/test/names"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	ktesting "k8s.io/client-go/testing"
+	"k8s.io/client-go/tools/record"
+	"knative.dev/pkg/apis"
+	"knative.dev/pkg/configmap"
+	"knative.dev/pkg/controller"
+	"knative.dev/pkg/logging"
+	"knative.dev/pkg/reconciler"
+)
+
+var (
+	namespace = ""
+	trueB     = true
+)
+
+func getRunName(run *v1alpha1.Run) string {
+	return strings.Join([]string{run.Namespace, run.Name}, "/")
+}
+
+func loopRunning(run *v1alpha1.Run) *v1alpha1.Run {
+	runWithStatus := run.DeepCopy()
+	runWithStatus.Status.InitializeConditions()
+	runWithStatus.Status.MarkRunRunning(pipelineloopv1alpha1.PipelineLoopRunReasonRunning.String(), "")
+	return runWithStatus
+}
+
+func requestCancel(run *v1alpha1.Run) *v1alpha1.Run {
+	runWithCancelStatus := run.DeepCopy()
+	runWithCancelStatus.Spec.Status = v1alpha1.RunSpecStatusCancelled
+	return runWithCancelStatus
+}
+
+func allowRetry(tl *pipelineloopv1alpha1.PipelineLoop) *pipelineloopv1alpha1.PipelineLoop {
+	pipelineLoopWithRetries := tl.DeepCopy()
+	pipelineLoopWithRetries.Spec.Retries = 1
+	return pipelineLoopWithRetries
+}
+
+func running(tr *v1beta1.PipelineRun) *v1beta1.PipelineRun {
+	trWithStatus := tr.DeepCopy()
+	trWithStatus.Status.SetCondition(&apis.Condition{
+		Type:   apis.ConditionSucceeded,
+		Status: corev1.ConditionUnknown,
+		Reason: v1beta1.PipelineRunReasonRunning.String(),
+	})
+	return trWithStatus
+}
+
+func successful(pr *v1beta1.PipelineRun) *v1beta1.PipelineRun {
+	prWithStatus := pr.DeepCopy()
+	prWithStatus.Status.SetCondition(&apis.Condition{
+		Type:    apis.ConditionSucceeded,
+		Status:  corev1.ConditionTrue,
+		Reason:  v1beta1.PipelineRunReasonSuccessful.String(),
+		Message: "All Steps have completed executing",
+	})
+	return prWithStatus
+}
+
+func successfulWithSkipedTasks(pr *v1beta1.PipelineRun) *v1beta1.PipelineRun {
+	prWithStatus := pr.DeepCopy()
+	prWithStatus.Status.SetCondition(&apis.Condition{
+		Type:    apis.ConditionSucceeded,
+		Status:  corev1.ConditionTrue,
+		Reason:  v1beta1.PipelineRunReasonSuccessful.String(),
+		Message: "Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 1",
+	})
+	prWithStatus.Status.SkippedTasks = []v1beta1.SkippedTask{{
+		Name: "task-fail",
+	}}
+	return prWithStatus
+}
+
+func failed(pr *v1beta1.PipelineRun) *v1beta1.PipelineRun {
+	prWithStatus := pr.DeepCopy()
+	prWithStatus.Status.SetCondition(&apis.Condition{
+		Type:    apis.ConditionSucceeded,
+		Status:  corev1.ConditionFalse,
+		Reason:  v1beta1.PipelineRunReasonFailed.String(),
+		Message: "Something went wrong",
+	})
+	return prWithStatus
+}
+
+// getPipelineLoopController returns an instance of the PipelineLoop controller/reconciler that has been seeded with
+// d, where d represents the state of the system (existing resources) needed for the test.
+func getPipelineLoopController(t *testing.T, d test.Data, pipelineloops []*pipelineloopv1alpha1.PipelineLoop) (test.Assets, func()) {
+	ctx, _ := ttesting.SetupFakeContext(t)
+	ctx, cancel := context.WithCancel(ctx)
+	c, informers := test.SeedTestData(t, ctx, d)
+
+	client := fakeclient.Get(ctx)
+	client.PrependReactor("*", "pipelineloops", test.AddToInformer(t, fakepipelineloopinformer.Get(ctx).Informer().GetIndexer()))
+	for _, tl := range pipelineloops {
+		tl := tl.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := client.CustomV1alpha1().PipelineLoops(tl.Namespace).Create(ctx, tl, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	configMapWatcher := configmap.NewInformedWatcher(c.Kube, system.GetNamespace())
+	ctl := NewController(namespace)(ctx, configMapWatcher)
+
+	if la, ok := ctl.Reconciler.(reconciler.LeaderAware); ok {
+		la.Promote(reconciler.UniversalBucket(), func(reconciler.Bucket, types.NamespacedName) {})
+	}
+	if err := configMapWatcher.Start(ctx.Done()); err != nil {
+		t.Fatalf("error starting configmap watcher: %v", err)
+	}
+
+	return test.Assets{
+		Logger:     logging.FromContext(ctx),
+		Controller: ctl,
+		Clients:    c,
+		Informers:  informers,
+		Recorder:   controller.GetEventRecorder(ctx).(*record.FakeRecorder),
+	}, cancel
+}
+
+func getCreatedPipelinerun(t *testing.T, clients test.Clients) *v1beta1.PipelineRun {
+	t.Log("actions", clients.Pipeline.Actions())
+	for _, a := range clients.Pipeline.Actions() {
+		if a.GetVerb() == "create" {
+			obj := a.(ktesting.CreateAction).GetObject()
+			if pr, ok := obj.(*v1beta1.PipelineRun); ok {
+				return pr
+			}
+		}
+	}
+	return nil
+}
+
+func checkEvents(fr *record.FakeRecorder, testName string, wantEvents []string) error {
+	// The fake recorder runs in a go routine, so the timeout is here to avoid waiting
+	// on the channel forever if fewer than expected events are received.
+	// We only hit the timeout in case of failure of the test, so the actual value
+	// of the timeout is not so relevant. It's only used when tests are going to fail.
+	timer := time.NewTimer(1 * time.Second)
+	foundEvents := []string{}
+	for ii := 0; ii < len(wantEvents)+1; ii++ {
+		// We loop over all the events that we expect. Once they are all received
+		// we exit the loop. If we never receive enough events, the timeout takes us
+		// out of the loop.
+		select {
+		case event := <-fr.Events:
+			foundEvents = append(foundEvents, event)
+			if ii > len(wantEvents)-1 {
+				return fmt.Errorf(`Received extra event "%s" for test "%s"`, event, testName)
+			}
+			wantEvent := wantEvents[ii]
+			if !(strings.HasPrefix(event, wantEvent)) {
+				return fmt.Errorf(`Expected event "%s" but got "%s" instead for test "%s"`, wantEvent, event, testName)
+			}
+		case <-timer.C:
+			if len(foundEvents) > len(wantEvents) {
+				return fmt.Errorf(`Received %d events but %d expected for test "%s". Found events: %#v`, len(foundEvents), len(wantEvents), testName, foundEvents)
+			}
+		}
+	}
+	return nil
+}
+
+func checkRunCondition(t *testing.T, run *v1alpha1.Run, expectedStatus corev1.ConditionStatus, expectedReason pipelineloopv1alpha1.PipelineLoopRunReason) {
+	condition := run.Status.GetCondition(apis.ConditionSucceeded)
+	if condition == nil {
+		t.Error("Condition missing in Run")
+	} else {
+		if condition.Status != expectedStatus {
+			t.Errorf("Expected Run status to be %v but was %v", expectedStatus, condition)
+		}
+		if condition.Reason != expectedReason.String() {
+			t.Errorf("Expected reason to be %q but was %q", expectedReason.String(), condition.Reason)
+		}
+	}
+	if run.Status.StartTime == nil {
+		t.Errorf("Expected Run start time to be set but it wasn't")
+	}
+	if expectedStatus == corev1.ConditionUnknown {
+		if run.Status.CompletionTime != nil {
+			t.Errorf("Expected Run completion time to not be set but it was")
+		}
+	} else if run.Status.CompletionTime == nil {
+		t.Errorf("Expected Run completion time to be set but it wasn't")
+	}
+}
+
+func checkRunStatus(t *testing.T, run *v1alpha1.Run, expectedStatus map[string]pipelineloopv1alpha1.PipelineLoopPipelineRunStatus) {
+	status := &pipelineloopv1alpha1.PipelineLoopRunStatus{}
+	if err := run.Status.DecodeExtraFields(status); err != nil {
+		t.Errorf("DecodeExtraFields error: %v", err.Error())
+	}
+	t.Log("pipelineruns", status.PipelineRuns)
+	if len(status.PipelineRuns) != len(expectedStatus) {
+		t.Errorf("Expected Run status to include %d PipelineRuns but found %d: %v", len(expectedStatus), len(status.PipelineRuns), status.PipelineRuns)
+		return
+	}
+	for expectedPipelineRunName, expectedPipelineRunStatus := range expectedStatus {
+		actualPipelineRunStatus, exists := status.PipelineRuns[expectedPipelineRunName]
+		if !exists {
+			t.Errorf("Expected Run status to include PipelineRun status for PipelineRun %s", expectedPipelineRunName)
+			continue
+		}
+		if actualPipelineRunStatus.Iteration != expectedPipelineRunStatus.Iteration {
+			t.Errorf("Run status for PipelineRun %s has iteration number %d instead of %d",
+				expectedPipelineRunName, actualPipelineRunStatus.Iteration, expectedPipelineRunStatus.Iteration)
+		}
+		if d := cmp.Diff(expectedPipelineRunStatus.Status, actualPipelineRunStatus.Status, cmpopts.IgnoreTypes(apis.Condition{}.LastTransitionTime.Inner.Time)); d != "" {
+			t.Errorf("Run status for PipelineRun %s is incorrect. Diff %s", expectedPipelineRunName, diff.PrintWantGot(d))
+		}
+	}
+}
+
+var aPipeline = &v1beta1.Pipeline{
+	ObjectMeta: metav1.ObjectMeta{Name: "a-pipeline", Namespace: "foo"},
+	Spec: v1beta1.PipelineSpec{
+		Params: []v1beta1.ParamSpec{{
+			Name: "current-item",
+			Type: v1beta1.ParamTypeString,
+		}, {
+			Name: "additional-parameter",
+			Type: v1beta1.ParamTypeString,
+		}},
+		Tasks: []v1beta1.PipelineTask{{
+			Name: "mytask",
+			TaskSpec: &v1beta1.EmbeddedTask{
+				TaskSpec: v1beta1.TaskSpec{
+					Steps: []v1beta1.Step{{
+						Container: corev1.Container{Name: "foo", Image: "bar"},
+					}},
+				},
+			},
+		}},
+	},
+}
+
+var aPipelineLoop = &pipelineloopv1alpha1.PipelineLoop{
+	ObjectMeta: metav1.ObjectMeta{Name: "a-pipelineloop", Namespace: "foo"},
+	Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+		PipelineRef:  &v1beta1.PipelineRef{Name: "a-pipeline"},
+		IterateParam: "current-item",
+	},
+}
+
+var nPipeline = &v1beta1.Pipeline{
+	ObjectMeta: metav1.ObjectMeta{Name: "n-pipeline", Namespace: "foo"},
+	Spec: v1beta1.PipelineSpec{
+		Params: []v1beta1.ParamSpec{{
+			Name: "iteration",
+			Type: v1beta1.ParamTypeString,
+		}, {
+			Name: "additional-parameter",
+			Type: v1beta1.ParamTypeString,
+		}},
+		Tasks: []v1beta1.PipelineTask{{
+			Name: "mytask",
+			TaskSpec: &v1beta1.EmbeddedTask{
+				TaskSpec: v1beta1.TaskSpec{
+					Steps: []v1beta1.Step{{
+						Container: corev1.Container{Name: "foo", Image: "bar"},
+					}},
+				},
+			},
+		}},
+	},
+}
+
+var nPipelineLoop = &pipelineloopv1alpha1.PipelineLoop{
+	ObjectMeta: metav1.ObjectMeta{Name: "n-pipelineloop", Namespace: "foo"},
+	Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+		PipelineRef:    &v1beta1.PipelineRef{Name: "n-pipeline"},
+		IterateNumeric: "iteration",
+	},
+}
+
+var aPipelineLoopWithInlineTask = &pipelineloopv1alpha1.PipelineLoop{
+	ObjectMeta: metav1.ObjectMeta{Name: "a-pipelineloop-with-inline-task", Namespace: "foo"},
+	Spec: pipelineloopv1alpha1.PipelineLoopSpec{
+		PipelineSpec: &v1beta1.PipelineSpec{
+			Tasks: []v1beta1.PipelineTask{{
+				Name: "mytask",
+				TaskSpec: &v1beta1.EmbeddedTask{
+					TaskSpec: v1beta1.TaskSpec{
+						Params: []v1beta1.ParamSpec{{
+							Name: "current-item",
+							Type: v1beta1.ParamTypeString,
+						}, {
+							Name: "additional-parameter",
+							Type: v1beta1.ParamTypeString,
+						}},
+						Steps: []v1beta1.Step{{
+							Container: corev1.Container{Name: "foo", Image: "bar"},
+						}},
+					},
+				},
+			}},
+		},
+		IterateParam: "current-item",
+		Timeout:      &metav1.Duration{Duration: 5 * time.Minute},
+	},
+}
+
+var runPipelineLoop = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop",
+		Namespace: "foo",
+		Labels: map[string]string{
+			"myTestLabel":                    "myTestLabelValue",
+			"custom.tekton.dev/pipelineLoop": "a-pipelineloop",
+			"tekton.dev/pipeline":            "pr-loop-example",
+			"tekton.dev/pipelineRun":         "pr-loop-example",
+			"tekton.dev/pipelineTask":        "loop-task",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeArray, ArrayVal: []string{"item1", "item2"}},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop",
+		},
+	},
+}
+
+var conditionRunPipelineLoop = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop",
+		Namespace: "foo",
+		Labels: map[string]string{
+			"myTestLabel":    "myTestLabelValue",
+			"last-loop-task": "task-fail",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeArray, ArrayVal: []string{"item1", "item2"}},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop",
+		},
+	},
+}
+
+var runPipelineLoopWithInStringParams = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop",
+		Namespace: "foo",
+		Labels: map[string]string{
+			"myTestLabel":                    "myTestLabelValue",
+			"custom.tekton.dev/pipelineLoop": "a-pipelineloop",
+			"tekton.dev/pipeline":            "pr-loop-example",
+			"tekton.dev/pipelineRun":         "pr-loop-example",
+			"tekton.dev/pipelineTask":        "loop-task",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: `["item1", "item2"]`},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop",
+		},
+	},
+}
+
+var runPipelineLoopWithIterateNumeric = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop",
+		Namespace: "foo",
+		Labels: map[string]string{
+			"myTestLabel":                    "myTestLabelValue",
+			"custom.tekton.dev/pipelineLoop": "n-pipelineloop",
+			"tekton.dev/pipeline":            "pr-loop-example",
+			"tekton.dev/pipelineRun":         "pr-loop-example",
+			"tekton.dev/pipelineTask":        "loop-task",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			Name:  "from",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: `1`},
+		}, {
+			Name:  "step",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: `1`},
+		}, {
+			Name:  "to",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: `3`},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "n-pipelineloop",
+		},
+	},
+}
+
+var runPipelineLoopWithInlineTask = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-with-inline-task",
+		Namespace: "foo",
+		Labels: map[string]string{
+			"myTestLabel": "myTestLabelValue",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeArray, ArrayVal: []string{"item1", "item2"}},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop-with-inline-task",
+		},
+	},
+}
+
+var runWithMissingPipelineLoopName = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "bad-run-pipelineloop-missing",
+		Namespace: "foo",
+	},
+	Spec: v1alpha1.RunSpec{
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			// missing Name
+		},
+	},
+}
+
+var runWithNonexistentPipelineLoop = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "bad-run-pipelineloop-not-found",
+		Namespace: "foo",
+	},
+	Spec: v1alpha1.RunSpec{
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "no-such-pipelineloop",
+		},
+	},
+}
+
+var runWithMissingIterateParam = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "bad-run-missing-iterate-param",
+		Namespace: "foo",
+	},
+	Spec: v1alpha1.RunSpec{
+		// current-item, which is the iterate parameter, is missing from parameters
+		Params: []v1beta1.Param{{
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop",
+		},
+	},
+}
+
+var runWithIterateParamNotAnArray = &v1alpha1.Run{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "bad-run-iterate-param-not-an-array",
+		Namespace: "foo",
+	},
+	Spec: v1alpha1.RunSpec{
+		Params: []v1beta1.Param{{
+			// Value of iteration parameter must be an array so this is an error.
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "item1"},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Ref: &v1alpha1.TaskRef{
+			APIVersion: pipelineloopv1alpha1.SchemeGroupVersion.String(),
+			Kind:       pipelineloop.PipelineLoopControllerName,
+			Name:       "a-pipelineloop",
+		},
+	},
+}
+
+var expectedPipelineRunIteration1 = &v1beta1.PipelineRun{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-00001-9l9zj",
+		Namespace: "foo",
+		OwnerReferences: []metav1.OwnerReference{{
+			APIVersion:         "tekton.dev/v1alpha1",
+			Kind:               "Run",
+			Name:               "run-pipelineloop",
+			Controller:         &trueB,
+			BlockOwnerDeletion: &trueB,
+		}},
+		Labels: map[string]string{
+			"custom.tekton.dev/pipelineLoop":          "a-pipelineloop",
+			"tekton.dev/run":                          "run-pipelineloop",
+			"custom.tekton.dev/pipelineLoopIteration": "1",
+			"myTestLabel":                             "myTestLabelValue",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1beta1.PipelineRunSpec{
+		PipelineRef: &v1beta1.PipelineRef{Name: "a-pipeline"},
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "item1"},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+	},
+}
+
+var expectedConditionPipelineRunIteration1 = &v1beta1.PipelineRun{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-00001-9l9zj",
+		Namespace: "foo",
+		OwnerReferences: []metav1.OwnerReference{{
+			APIVersion:         "tekton.dev/v1alpha1",
+			Kind:               "Run",
+			Name:               "run-pipelineloop",
+			Controller:         &trueB,
+			BlockOwnerDeletion: &trueB,
+		}},
+		Labels: map[string]string{
+			"custom.tekton.dev/pipelineLoop":          "a-pipelineloop",
+			"tekton.dev/run":                          "run-pipelineloop",
+			"custom.tekton.dev/pipelineLoopIteration": "1",
+			"myTestLabel":                             "myTestLabelValue",
+			"last-loop-task":                          "task-fail",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1beta1.PipelineRunSpec{
+		PipelineRef: &v1beta1.PipelineRef{Name: "a-pipeline"},
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "item1"},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+	},
+}
+
+var expectedPipelineRunIterateNumeric1 = &v1beta1.PipelineRun{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-00001-9l9zj",
+		Namespace: "foo",
+		OwnerReferences: []metav1.OwnerReference{{
+			APIVersion:         "tekton.dev/v1alpha1",
+			Kind:               "Run",
+			Name:               "run-pipelineloop",
+			Controller:         &trueB,
+			BlockOwnerDeletion: &trueB,
+		}},
+		Labels: map[string]string{
+			"custom.tekton.dev/pipelineLoop":          "n-pipelineloop",
+			"tekton.dev/run":                          "run-pipelineloop",
+			"custom.tekton.dev/pipelineLoopIteration": "1",
+			"myTestLabel":                             "myTestLabelValue",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1beta1.PipelineRunSpec{
+		PipelineRef: &v1beta1.PipelineRef{Name: "n-pipeline"},
+		Params: []v1beta1.Param{{
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}, {
+			Name:  "iteration",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "1"},
+		}},
+	},
+}
+
+// Note: The pipelinerun for the second iteration has the same random suffix as the first due to the resetting of the seed on each test.
+var expectedPipelineRunIteration2 = &v1beta1.PipelineRun{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-00002-9l9zj",
+		Namespace: "foo",
+		OwnerReferences: []metav1.OwnerReference{{
+			APIVersion:         "tekton.dev/v1alpha1",
+			Kind:               "Run",
+			Name:               "run-pipelineloop",
+			Controller:         &trueB,
+			BlockOwnerDeletion: &trueB,
+		}},
+		Labels: map[string]string{
+			"custom.tekton.dev/pipelineLoop":          "a-pipelineloop",
+			"tekton.dev/run":                          "run-pipelineloop",
+			"custom.tekton.dev/pipelineLoopIteration": "2",
+			"myTestLabel":                             "myTestLabelValue",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1beta1.PipelineRunSpec{
+		PipelineRef: &v1beta1.PipelineRef{Name: "a-pipeline"},
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "item2"},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+	},
+}
+
+var expectedPipelineRunWithInlineTaskIteration1 = &v1beta1.PipelineRun{
+	ObjectMeta: metav1.ObjectMeta{
+		Name:      "run-pipelineloop-with-inline-task-00001-9l9zj",
+		Namespace: "foo",
+		OwnerReferences: []metav1.OwnerReference{{
+			APIVersion:         "tekton.dev/v1alpha1",
+			Kind:               "Run",
+			Name:               "run-pipelineloop-with-inline-task",
+			Controller:         &trueB,
+			BlockOwnerDeletion: &trueB,
+		}},
+		Labels: map[string]string{
+			"custom.tekton.dev/pipelineLoop":          "a-pipelineloop-with-inline-task",
+			"tekton.dev/run":                          "run-pipelineloop-with-inline-task",
+			"custom.tekton.dev/pipelineLoopIteration": "1",
+			"myTestLabel":                             "myTestLabelValue",
+		},
+		Annotations: map[string]string{
+			"myTestAnnotation": "myTestAnnotationValue",
+		},
+	},
+	Spec: v1beta1.PipelineRunSpec{
+		PipelineSpec: &v1beta1.PipelineSpec{
+			Tasks: []v1beta1.PipelineTask{{
+				Name: "mytask",
+				TaskSpec: &v1beta1.EmbeddedTask{
+					TaskSpec: v1beta1.TaskSpec{
+						Params: []v1beta1.ParamSpec{{
+							Name: "current-item",
+							Type: v1beta1.ParamTypeString,
+						}, {
+							Name: "additional-parameter",
+							Type: v1beta1.ParamTypeString,
+						}},
+						Steps: []v1beta1.Step{{
+							Container: corev1.Container{Name: "foo", Image: "bar"},
+						}},
+					},
+				},
+			}},
+		},
+		Params: []v1beta1.Param{{
+			Name:  "current-item",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "item1"},
+		}, {
+			Name:  "additional-parameter",
+			Value: v1beta1.ArrayOrString{Type: v1beta1.ParamTypeString, StringVal: "stuff"},
+		}},
+		Timeout: &metav1.Duration{Duration: 5 * time.Minute},
+	},
+}
+
+func TestReconcilePipelineLoopRun(t *testing.T) {
+
+	testcases := []struct {
+		name                 string
+		pipeline             *v1beta1.Pipeline
+		pipelineloop         *pipelineloopv1alpha1.PipelineLoop
+		run                  *v1alpha1.Run
+		pipelineruns         []*v1beta1.PipelineRun
+		expectedStatus       corev1.ConditionStatus
+		expectedReason       pipelineloopv1alpha1.PipelineLoopRunReason
+		expectedPipelineruns []*v1beta1.PipelineRun
+		expectedEvents       []string
+	}{{
+		name:                 "Reconcile a new run with a pipelineloop that references a pipeline",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  runPipelineLoop,
+		pipelineruns:         []*v1beta1.PipelineRun{},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{expectedPipelineRunIteration1},
+		expectedEvents:       []string{"Normal Started", "Normal Running Iterations completed: 0"},
+	}, {
+		name:                 "Reconcile a new run with a pipelineloop and a string params",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  runPipelineLoopWithInStringParams,
+		pipelineruns:         []*v1beta1.PipelineRun{},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{expectedPipelineRunIteration1},
+		expectedEvents:       []string{"Normal Started", "Normal Running Iterations completed: 0"},
+	}, {
+		name:                 "Reconcile a run with condition pipelinerun, and the first PipelineRun condition check failed",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  loopRunning(conditionRunPipelineLoop),
+		pipelineruns:         []*v1beta1.PipelineRun{successfulWithSkipedTasks(expectedConditionPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionTrue,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonSucceeded,
+		expectedPipelineruns: []*v1beta1.PipelineRun{successfulWithSkipedTasks(expectedConditionPipelineRunIteration1)},
+		expectedEvents:       []string{"Normal Succeeded PipelineRuns completed successfully with the conditions are met"},
+	}, {
+		name:                 "Reconcile a new run with iterateNumeric defined",
+		pipeline:             nPipeline,
+		pipelineloop:         nPipelineLoop,
+		run:                  runPipelineLoopWithIterateNumeric,
+		pipelineruns:         []*v1beta1.PipelineRun{},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{expectedPipelineRunIterateNumeric1},
+		expectedEvents:       []string{"Normal Started", "Normal Running Iterations completed: 0"},
+	}, {
+		name:                 "Reconcile a new run with a pipelineloop that contains an inline task",
+		pipelineloop:         aPipelineLoopWithInlineTask,
+		run:                  runPipelineLoopWithInlineTask,
+		pipelineruns:         []*v1beta1.PipelineRun{},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{expectedPipelineRunWithInlineTaskIteration1},
+		expectedEvents:       []string{"Normal Started", "Normal Running Iterations completed: 0"},
+	}, {
+		name:                 "Reconcile a run after the first PipelineRun has succeeded.",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  loopRunning(runPipelineLoop),
+		pipelineruns:         []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1), expectedPipelineRunIteration2},
+		expectedEvents:       []string{"Normal Running Iterations completed: 1"},
+	}, {
+		name:                 "Reconcile a run after all PipelineRuns have succeeded",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  loopRunning(runPipelineLoop),
+		pipelineruns:         []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1), successful(expectedPipelineRunIteration2)},
+		expectedStatus:       corev1.ConditionTrue,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonSucceeded,
+		expectedPipelineruns: []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1), successful(expectedPipelineRunIteration2)},
+		expectedEvents:       []string{"Normal Succeeded All PipelineRuns completed successfully"},
+	}, {
+		name:                 "Reconcile a run after the first PipelineRun has failed",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  loopRunning(runPipelineLoop),
+		pipelineruns:         []*v1beta1.PipelineRun{failed(expectedPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionFalse,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonFailed,
+		expectedPipelineruns: []*v1beta1.PipelineRun{failed(expectedPipelineRunIteration1)},
+		expectedEvents:       []string{"Warning Failed PipelineRun " + expectedPipelineRunIteration1.Name + " has failed"},
+	}, {
+		name:                 "Reconcile a cancelled run while the first PipelineRun is running",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  requestCancel(loopRunning(runPipelineLoop)),
+		pipelineruns:         []*v1beta1.PipelineRun{running(expectedPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionUnknown,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonRunning,
+		expectedPipelineruns: []*v1beta1.PipelineRun{running(expectedPipelineRunIteration1)},
+		expectedEvents:       []string{"Normal Running Cancelling PipelineRun " + expectedPipelineRunIteration1.Name},
+	}, {
+		name:                 "Reconcile a cancelled run after the first PipelineRun has failed",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  requestCancel(loopRunning(runPipelineLoop)),
+		pipelineruns:         []*v1beta1.PipelineRun{failed(expectedPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionFalse,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonCancelled,
+		expectedPipelineruns: []*v1beta1.PipelineRun{failed(expectedPipelineRunIteration1)},
+		expectedEvents:       []string{"Warning Failed Run " + runPipelineLoop.Namespace + "/" + runPipelineLoop.Name + " was cancelled"},
+	}, {
+		name:                 "Reconcile a cancelled run after the first PipelineRun has succeeded",
+		pipeline:             aPipeline,
+		pipelineloop:         aPipelineLoop,
+		run:                  requestCancel(loopRunning(runPipelineLoop)),
+		pipelineruns:         []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1)},
+		expectedStatus:       corev1.ConditionFalse,
+		expectedReason:       pipelineloopv1alpha1.PipelineLoopRunReasonCancelled,
+		expectedPipelineruns: []*v1beta1.PipelineRun{successful(expectedPipelineRunIteration1)},
+		expectedEvents:       []string{"Warning Failed Run " + runPipelineLoop.Namespace + "/" + runPipelineLoop.Name + " was cancelled"},
+	}}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+			names.TestingSeed()
+
+			optionalPipeline := []*v1beta1.Pipeline{tc.pipeline}
+			if tc.pipeline == nil {
+				optionalPipeline = nil
+			}
+
+			d := test.Data{
+				Runs:         []*v1alpha1.Run{tc.run},
+				Pipelines:    optionalPipeline,
+				PipelineRuns: tc.pipelineruns,
+			}
+
+			testAssets, _ := getPipelineLoopController(t, d, []*pipelineloopv1alpha1.PipelineLoop{tc.pipelineloop})
+			c := testAssets.Controller
+			clients := testAssets.Clients
+
+			if err := c.Reconciler.Reconcile(ctx, getRunName(tc.run)); err != nil {
+				t.Fatalf("Error reconciling: %s", err)
+			}
+
+			// Fetch the updated Run
+			reconciledRun, err := clients.Pipeline.TektonV1alpha1().Runs(tc.run.Namespace).Get(ctx, tc.run.Name, metav1.GetOptions{})
+			if err != nil {
+				t.Fatalf("Error getting reconciled run from fake client: %s", err)
+			}
+
+			// Verify that the Run has the expected status and reason.
+			checkRunCondition(t, reconciledRun, tc.expectedStatus, tc.expectedReason)
+
+			// Verify that a PipelineRun was or was not created depending on the test.
+			// If the number of expected PipelineRuns is greater than the original number of PipelineRuns
+			// then the test expects a new PipelineRun to be created.  The new PipelineRun must be the
+			// last one in the list of expected PipelineRuns.
+			createdPipelinerun := getCreatedPipelinerun(t, clients)
+			if len(tc.expectedPipelineruns) > len(tc.pipelineruns) {
+				if createdPipelinerun == nil {
+					t.Errorf("A PipelineRun should have been created but was not")
+				} else {
+					if d := cmp.Diff(tc.expectedPipelineruns[len(tc.expectedPipelineruns)-1], createdPipelinerun); d != "" {
+						t.Errorf("Expected PipelineRun was not created. Diff %s", diff.PrintWantGot(d))
+					}
+				}
+			} else {
+				if createdPipelinerun != nil {
+					t.Errorf("A PipelineRun was created which was not expected")
+				}
+			}
+
+			// Verify Run status contains status for all PipelineRuns.
+			expectedPipelineRuns := map[string]pipelineloopv1alpha1.PipelineLoopPipelineRunStatus{}
+			for i, pr := range tc.expectedPipelineruns {
+				expectedPipelineRuns[pr.Name] = pipelineloopv1alpha1.PipelineLoopPipelineRunStatus{Iteration: i + 1, Status: &pr.Status}
+			}
+			checkRunStatus(t, reconciledRun, expectedPipelineRuns)
+
+			// Verify expected events were created.
+			if err := checkEvents(testAssets.Recorder, tc.name, tc.expectedEvents); err != nil {
+				t.Errorf(err.Error())
+			}
+		})
+	}
+}
+
+func TestReconcilePipelineLoopRunFailures(t *testing.T) {
+	testcases := []struct {
+		name         string
+		pipelineloop *pipelineloopv1alpha1.PipelineLoop
+		run          *v1alpha1.Run
+		reason       pipelineloopv1alpha1.PipelineLoopRunReason
+		wantEvents   []string
+	}{{
+		name:   "missing PipelineLoop name",
+		run:    runWithMissingPipelineLoopName,
+		reason: pipelineloopv1alpha1.PipelineLoopRunReasonCouldntGetPipelineLoop,
+		wantEvents: []string{
+			"Normal Started ",
+			"Warning Failed Missing spec.ref.name for Run",
+		},
+	}, {
+		name:   "nonexistent PipelineLoop",
+		run:    runWithNonexistentPipelineLoop,
+		reason: pipelineloopv1alpha1.PipelineLoopRunReasonCouldntGetPipelineLoop,
+		wantEvents: []string{
+			"Normal Started ",
+			"Warning Failed Error retrieving PipelineLoop",
+		},
+	}, {
+		name:         "missing iterate parameter",
+		pipelineloop: aPipelineLoop,
+		run:          runWithMissingIterateParam,
+		reason:       pipelineloopv1alpha1.PipelineLoopRunReasonFailedValidation,
+		wantEvents: []string{
+			"Normal Started ",
+			`Warning Failed Cannot determine number of iterations: The iterate parameter "current-item" was not found`,
+		},
+	}, {
+		name:         "iterate parameter not an array",
+		pipelineloop: aPipelineLoop,
+		run:          runWithIterateParamNotAnArray,
+		reason:       pipelineloopv1alpha1.PipelineLoopRunReasonFailedValidation,
+		wantEvents: []string{
+			"Normal Started ",
+			`Warning Failed Cannot determine number of iterations: The value of the iterate parameter "current-item" is not an array`,
+		},
+	}}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			d := test.Data{
+				Runs: []*v1alpha1.Run{tc.run},
+			}
+
+			optionalPipelineLoop := []*pipelineloopv1alpha1.PipelineLoop{tc.pipelineloop}
+			if tc.pipelineloop == nil {
+				optionalPipelineLoop = nil
+			}
+
+			testAssets, _ := getPipelineLoopController(t, d, optionalPipelineLoop)
+			c := testAssets.Controller
+			clients := testAssets.Clients
+
+			if err := c.Reconciler.Reconcile(ctx, getRunName(tc.run)); err != nil {
+				t.Fatalf("Error reconciling: %s", err)
+			}
+
+			// Fetch the updated Run
+			reconciledRun, err := clients.Pipeline.TektonV1alpha1().Runs(tc.run.Namespace).Get(ctx, tc.run.Name, metav1.GetOptions{})
+			if err != nil {
+				t.Fatalf("Error getting reconciled run from fake client: %s", err)
+			}
+
+			// Verify that the Run is in Failed status and both the start time and the completion time are set.
+			checkRunCondition(t, reconciledRun, corev1.ConditionFalse, tc.reason)
+			if reconciledRun.Status.StartTime == nil {
+				t.Fatalf("Expected Run start time to be set but it wasn't")
+			}
+			if reconciledRun.Status.CompletionTime == nil {
+				t.Fatalf("Expected Run completion time to be set but it wasn't")
+			}
+
+			if err := checkEvents(testAssets.Recorder, tc.name, tc.wantEvents); err != nil {
+				t.Errorf(err.Error())
+			}
+		})
+	}
+}
diff --git a/tekton-catalog/pipeline-loops/test/build_logs.go b/tekton-catalog/pipeline-loops/test/build_logs.go
new file mode 100644
index 0000000000..e2db2d4b9b
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/build_logs.go
@@ -0,0 +1,74 @@
+/*
+Copyright 2019 The Knative 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"
+	"io/ioutil"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"knative.dev/pkg/test/logging"
+)
+
+// CollectPodLogs will get the logs for all containers in a Pod
+func CollectPodLogs(ctx context.Context, c *clients, podName, namespace string, logf logging.FormatLogger) {
+	logs, err := getContainersLogsFromPod(ctx, c.KubeClient.Kube, podName, namespace)
+	if err != nil {
+		logf("Could not get logs for pod %s: %s", podName, err)
+	}
+	logf("build logs %s", logs)
+}
+
+func getContainersLogsFromPod(ctx context.Context, c kubernetes.Interface, pod, namespace string) (string, error) {
+	p, err := c.CoreV1().Pods(namespace).Get(ctx, pod, metav1.GetOptions{})
+	if err != nil {
+		return "", err
+	}
+
+	sb := strings.Builder{}
+	for _, container := range p.Spec.Containers {
+		sb.WriteString(fmt.Sprintf("\n>>> Container %s:\n", container.Name))
+		logs, err := getContainerLogsFromPod(ctx, c, pod, container.Name, namespace)
+		if err != nil {
+			return "", err
+		}
+		sb.WriteString(logs)
+	}
+	return sb.String(), nil
+}
+
+func getContainerLogsFromPod(ctx context.Context, c kubernetes.Interface, pod, container, namespace string) (string, error) {
+	sb := strings.Builder{}
+	// Do not follow, which will block until the Pod terminates, and potentially deadlock the test.
+	// If done in the wrong order, this could actually block things and prevent the Pod from being
+	// deleted at all.
+	req := c.CoreV1().Pods(namespace).GetLogs(pod, &corev1.PodLogOptions{Follow: false, Container: container})
+	rc, err := req.Stream(ctx)
+	if err != nil {
+		return "", err
+	}
+	bs, err := ioutil.ReadAll(rc)
+	if err != nil {
+		return "", err
+	}
+	sb.Write(bs)
+	return sb.String(), nil
+}
diff --git a/tekton-catalog/pipeline-loops/test/clients.go b/tekton-catalog/pipeline-loops/test/clients.go
new file mode 100644
index 0000000000..6ba546b856
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/clients.go
@@ -0,0 +1,101 @@
+/*
+Copyright 2019 The Knative 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.
+*/
+
+/*
+Get access to client objects
+
+To initialize client objects you can use the setup function. It returns a clients struct
+that contains initialized clients for accessing:
+
+	- Kubernetes objects
+	- Pipelines (https://github.com/tektoncd/pipeline#pipeline)
+
+For example, to create a Pipeline
+
+	_, err = clients.PipelineClient.Pipelines.Create(test.Pipeline(namespaceName, pipelineName))
+
+And you can use the client to clean up resources created by your test
+
+	func tearDown(clients *test.Clients) {
+	    if clients != nil {
+	        clients.Delete([]string{routeName}, []string{configName})
+	    }
+	}
+
+*/
+package test
+
+import (
+	"testing"
+
+	"github.com/tektoncd/pipeline/pkg/client/clientset/versioned"
+	"github.com/tektoncd/pipeline/pkg/client/clientset/versioned/typed/pipeline/v1alpha1"
+	"github.com/tektoncd/pipeline/pkg/client/clientset/versioned/typed/pipeline/v1beta1"
+	resourceversioned "github.com/tektoncd/pipeline/pkg/client/resource/clientset/versioned"
+	resourcev1alpha1 "github.com/tektoncd/pipeline/pkg/client/resource/clientset/versioned/typed/resource/v1alpha1"
+	knativetest "knative.dev/pkg/test"
+)
+
+// clients holds instances of interfaces for making requests to the Pipeline controllers.
+type clients struct {
+	KubeClient *knativetest.KubeClient
+
+	PipelineClient         v1beta1.PipelineInterface
+	ClusterTaskClient      v1beta1.ClusterTaskInterface
+	TaskClient             v1beta1.TaskInterface
+	TaskRunClient          v1beta1.TaskRunInterface
+	PipelineRunClient      v1beta1.PipelineRunInterface
+	PipelineResourceClient resourcev1alpha1.PipelineResourceInterface
+	ConditionClient        v1alpha1.ConditionInterface
+	RunClient              v1alpha1.RunInterface
+}
+
+// newClients instantiates and returns several clientsets required for making requests to the
+// Pipeline cluster specified by the combination of clusterName and configPath. Clients can
+// make requests within namespace.
+func newClients(t *testing.T, configPath, clusterName, namespace string) *clients {
+	t.Helper()
+	var err error
+	c := &clients{}
+
+	c.KubeClient, err = knativetest.NewKubeClient(configPath, clusterName)
+	if err != nil {
+		t.Fatalf("failed to create kubeclient from config file at %s: %s", configPath, err)
+	}
+
+	cfg, err := knativetest.BuildClientConfig(configPath, clusterName)
+	if err != nil {
+		t.Fatalf("failed to create configuration obj from %s for cluster %s: %s", configPath, clusterName, err)
+	}
+
+	cs, err := versioned.NewForConfig(cfg)
+	if err != nil {
+		t.Fatalf("failed to create pipeline clientset from config file at %s: %s", configPath, err)
+	}
+	rcs, err := resourceversioned.NewForConfig(cfg)
+	if err != nil {
+		t.Fatalf("failed to create pipeline clientset from config file at %s: %s", configPath, err)
+	}
+	c.PipelineClient = cs.TektonV1beta1().Pipelines(namespace)
+	c.ClusterTaskClient = cs.TektonV1beta1().ClusterTasks()
+	c.TaskClient = cs.TektonV1beta1().Tasks(namespace)
+	c.TaskRunClient = cs.TektonV1beta1().TaskRuns(namespace)
+	c.PipelineRunClient = cs.TektonV1beta1().PipelineRuns(namespace)
+	c.PipelineResourceClient = rcs.TektonV1alpha1().PipelineResources(namespace)
+	c.ConditionClient = cs.TektonV1alpha1().Conditions(namespace)
+	c.RunClient = cs.TektonV1alpha1().Runs(namespace)
+	return c
+}
diff --git a/tekton-catalog/pipeline-loops/test/controller.go b/tekton-catalog/pipeline-loops/test/controller.go
new file mode 100644
index 0000000000..50d649d54e
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/controller.go
@@ -0,0 +1,310 @@
+/*
+Copyright 2019 The Knative 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"
+	"sync/atomic"
+	"testing"
+
+	// Link in the fakes so they get injected into injection.Fake
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
+	"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
+	fakepipelineclientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/fake"
+	informersv1alpha1 "github.com/tektoncd/pipeline/pkg/client/informers/externalversions/pipeline/v1alpha1"
+	informersv1beta1 "github.com/tektoncd/pipeline/pkg/client/informers/externalversions/pipeline/v1beta1"
+	fakepipelineclient "github.com/tektoncd/pipeline/pkg/client/injection/client/fake"
+	fakeconditioninformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1alpha1/condition/fake"
+	fakeruninformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1alpha1/run/fake"
+	fakeclustertaskinformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/clustertask/fake"
+	fakepipelineinformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/pipeline/fake"
+	fakepipelineruninformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/pipelinerun/fake"
+	faketaskinformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/task/fake"
+	faketaskruninformer "github.com/tektoncd/pipeline/pkg/client/injection/informers/pipeline/v1beta1/taskrun/fake"
+	fakeresourceclientset "github.com/tektoncd/pipeline/pkg/client/resource/clientset/versioned/fake"
+	resourceinformersv1alpha1 "github.com/tektoncd/pipeline/pkg/client/resource/informers/externalversions/resource/v1alpha1"
+	fakeresourceclient "github.com/tektoncd/pipeline/pkg/client/resource/injection/client/fake"
+	fakeresourceinformer "github.com/tektoncd/pipeline/pkg/client/resource/injection/informers/resource/v1alpha1/pipelineresource/fake"
+	cloudeventclient "github.com/tektoncd/pipeline/pkg/reconciler/events/cloudevent"
+	"go.uber.org/zap"
+	corev1 "k8s.io/api/core/v1"
+	apierrs "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	coreinformers "k8s.io/client-go/informers/core/v1"
+	fakekubeclientset "k8s.io/client-go/kubernetes/fake"
+	ktesting "k8s.io/client-go/testing"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/record"
+	fakekubeclient "knative.dev/pkg/client/injection/kube/client/fake"
+	fakeconfigmapinformer "knative.dev/pkg/client/injection/kube/informers/core/v1/configmap/fake"
+	fakepodinformer "knative.dev/pkg/client/injection/kube/informers/core/v1/pod/fake"
+	fakeserviceaccountinformer "knative.dev/pkg/client/injection/kube/informers/core/v1/serviceaccount/fake"
+	"knative.dev/pkg/controller"
+)
+
+// Data represents the desired state of the system (i.e. existing resources) to seed controllers
+// with.
+type Data struct {
+	PipelineRuns      []*v1beta1.PipelineRun
+	Pipelines         []*v1beta1.Pipeline
+	TaskRuns          []*v1beta1.TaskRun
+	Tasks             []*v1beta1.Task
+	ClusterTasks      []*v1beta1.ClusterTask
+	PipelineResources []*v1alpha1.PipelineResource
+	Conditions        []*v1alpha1.Condition
+	Runs              []*v1alpha1.Run
+	Pods              []*corev1.Pod
+	Namespaces        []*corev1.Namespace
+	ConfigMaps        []*corev1.ConfigMap
+	ServiceAccounts   []*corev1.ServiceAccount
+}
+
+// Clients holds references to clients which are useful for reconciler tests.
+type Clients struct {
+	Pipeline    *fakepipelineclientset.Clientset
+	Resource    *fakeresourceclientset.Clientset
+	Kube        *fakekubeclientset.Clientset
+	CloudEvents cloudeventclient.CEClient
+}
+
+// Informers holds references to informers which are useful for reconciler tests.
+type Informers struct {
+	PipelineRun      informersv1beta1.PipelineRunInformer
+	Pipeline         informersv1beta1.PipelineInformer
+	TaskRun          informersv1beta1.TaskRunInformer
+	Run              informersv1alpha1.RunInformer
+	Task             informersv1beta1.TaskInformer
+	ClusterTask      informersv1beta1.ClusterTaskInformer
+	PipelineResource resourceinformersv1alpha1.PipelineResourceInformer
+	Condition        informersv1alpha1.ConditionInformer
+	Pod              coreinformers.PodInformer
+	ConfigMap        coreinformers.ConfigMapInformer
+	ServiceAccount   coreinformers.ServiceAccountInformer
+}
+
+// Assets holds references to the controller, logs, clients, and informers.
+type Assets struct {
+	Logger     *zap.SugaredLogger
+	Controller *controller.Impl
+	Clients    Clients
+	Informers  Informers
+	Recorder   *record.FakeRecorder
+	Ctx        context.Context
+}
+
+func AddToInformer(t *testing.T, store cache.Store) func(ktesting.Action) (bool, runtime.Object, error) {
+	return func(action ktesting.Action) (bool, runtime.Object, error) {
+		switch a := action.(type) {
+		case ktesting.CreateActionImpl:
+			if err := store.Add(a.GetObject()); err != nil {
+				t.Fatal(err)
+			}
+
+		case ktesting.UpdateActionImpl:
+			objMeta, err := meta.Accessor(a.GetObject())
+			if err != nil {
+				return true, nil, err
+			}
+
+			// Look up the old copy of this resource and perform the optimistic concurrency check.
+			old, exists, err := store.GetByKey(objMeta.GetNamespace() + "/" + objMeta.GetName())
+			if err != nil {
+				return true, nil, err
+			} else if !exists {
+				// Let the client return the error.
+				return false, nil, nil
+			}
+			oldMeta, err := meta.Accessor(old)
+			if err != nil {
+				return true, nil, err
+			}
+			// If the resource version is mismatched, then fail with a conflict.
+			if oldMeta.GetResourceVersion() != objMeta.GetResourceVersion() {
+				return true, nil, apierrs.NewConflict(
+					a.Resource.GroupResource(), objMeta.GetName(),
+					fmt.Errorf("resourceVersion mismatch, got: %v, wanted: %v",
+						objMeta.GetResourceVersion(), oldMeta.GetResourceVersion()))
+			}
+
+			// Update the store with the new object when it's fine.
+			if err := store.Update(a.GetObject()); err != nil {
+				t.Fatal(err)
+			}
+		}
+		return false, nil, nil
+	}
+}
+
+// SeedTestData returns Clients and Informers populated with the
+// given Data.
+// nolint: golint
+func SeedTestData(t *testing.T, ctx context.Context, d Data) (Clients, Informers) {
+	c := Clients{
+		Kube:        fakekubeclient.Get(ctx),
+		Pipeline:    fakepipelineclient.Get(ctx),
+		Resource:    fakeresourceclient.Get(ctx),
+		CloudEvents: cloudeventclient.Get(ctx),
+	}
+	// Every time a resource is modified, change the metadata.resourceVersion.
+	PrependResourceVersionReactor(&c.Pipeline.Fake)
+
+	i := Informers{
+		PipelineRun:      fakepipelineruninformer.Get(ctx),
+		Pipeline:         fakepipelineinformer.Get(ctx),
+		TaskRun:          faketaskruninformer.Get(ctx),
+		Run:              fakeruninformer.Get(ctx),
+		Task:             faketaskinformer.Get(ctx),
+		ClusterTask:      fakeclustertaskinformer.Get(ctx),
+		PipelineResource: fakeresourceinformer.Get(ctx),
+		Condition:        fakeconditioninformer.Get(ctx),
+		Pod:              fakepodinformer.Get(ctx),
+		ConfigMap:        fakeconfigmapinformer.Get(ctx),
+		ServiceAccount:   fakeserviceaccountinformer.Get(ctx),
+	}
+
+	// Attach reactors that add resource mutations to the appropriate
+	// informer index, and simulate optimistic concurrency failures when
+	// the resource version is mismatched.
+	c.Pipeline.PrependReactor("*", "pipelineruns", AddToInformer(t, i.PipelineRun.Informer().GetIndexer()))
+	for _, pr := range d.PipelineRuns {
+		pr := pr.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1beta1().PipelineRuns(pr.Namespace).Create(ctx, pr, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "pipelines", AddToInformer(t, i.Pipeline.Informer().GetIndexer()))
+	for _, p := range d.Pipelines {
+		p := p.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1beta1().Pipelines(p.Namespace).Create(ctx, p, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "taskruns", AddToInformer(t, i.TaskRun.Informer().GetIndexer()))
+	for _, tr := range d.TaskRuns {
+		tr := tr.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1beta1().TaskRuns(tr.Namespace).Create(ctx, tr, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "tasks", AddToInformer(t, i.Task.Informer().GetIndexer()))
+	for _, ta := range d.Tasks {
+		ta := ta.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1beta1().Tasks(ta.Namespace).Create(ctx, ta, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "clustertasks", AddToInformer(t, i.ClusterTask.Informer().GetIndexer()))
+	for _, ct := range d.ClusterTasks {
+		ct := ct.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1beta1().ClusterTasks().Create(ctx, ct, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Resource.PrependReactor("*", "pipelineresources", AddToInformer(t, i.PipelineResource.Informer().GetIndexer()))
+	for _, r := range d.PipelineResources {
+		r := r.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Resource.TektonV1alpha1().PipelineResources(r.Namespace).Create(ctx, r, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "conditions", AddToInformer(t, i.Condition.Informer().GetIndexer()))
+	for _, cond := range d.Conditions {
+		cond := cond.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1alpha1().Conditions(cond.Namespace).Create(ctx, cond, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.PrependReactor("*", "runs", AddToInformer(t, i.Run.Informer().GetIndexer()))
+	for _, run := range d.Runs {
+		run := run.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Pipeline.TektonV1alpha1().Runs(run.Namespace).Create(ctx, run, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Kube.PrependReactor("*", "pods", AddToInformer(t, i.Pod.Informer().GetIndexer()))
+	for _, p := range d.Pods {
+		p := p.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Kube.CoreV1().Pods(p.Namespace).Create(ctx, p, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	for _, n := range d.Namespaces {
+		n := n.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Kube.CoreV1().Namespaces().Create(ctx, n, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Kube.PrependReactor("*", "configmaps", AddToInformer(t, i.ConfigMap.Informer().GetIndexer()))
+	for _, cm := range d.ConfigMaps {
+		cm := cm.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Kube.CoreV1().ConfigMaps(cm.Namespace).Create(ctx, cm, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Kube.PrependReactor("*", "serviceaccounts", AddToInformer(t, i.ServiceAccount.Informer().GetIndexer()))
+	for _, sa := range d.ServiceAccounts {
+		sa := sa.DeepCopy() // Avoid assumptions that the informer's copy is modified.
+		if _, err := c.Kube.CoreV1().ServiceAccounts(sa.Namespace).Create(ctx, sa, metav1.CreateOptions{}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	c.Pipeline.ClearActions()
+	c.Kube.ClearActions()
+	return c, i
+}
+
+type ResourceVersionReactor struct {
+	count int64
+}
+
+func (r *ResourceVersionReactor) Handles(action ktesting.Action) bool {
+	body := func(o runtime.Object) bool {
+		objMeta, err := meta.Accessor(o)
+		if err != nil {
+			return false
+		}
+		val := atomic.AddInt64(&r.count, 1)
+		objMeta.SetResourceVersion(fmt.Sprintf("%05d", val))
+		return false
+	}
+
+	switch o := action.(type) {
+	case ktesting.CreateActionImpl:
+		return body(o.GetObject())
+	case ktesting.UpdateActionImpl:
+		return body(o.GetObject())
+	default:
+		return false
+	}
+}
+
+// React is noop-function
+func (r *ResourceVersionReactor) React(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
+	return false, nil, nil
+}
+
+var _ ktesting.Reactor = (*ResourceVersionReactor)(nil)
+
+// PrependResourceVersionReactor will instrument a client-go testing Fake
+// with a reactor that simulates resourceVersion changes on mutations.
+// This does not work with patches.
+func PrependResourceVersionReactor(f *ktesting.Fake) {
+	f.ReactionChain = append([]ktesting.Reactor{&ResourceVersionReactor{}}, f.ReactionChain...)
+}
diff --git a/tekton-catalog/pipeline-loops/test/e2e-common.sh b/tekton-catalog/pipeline-loops/test/e2e-common.sh
new file mode 100755
index 0000000000..511c4d1d57
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/e2e-common.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+
+# Copyright 2019 The Knative 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.
+
+# Helper functions for E2E tests.
+
+source $(git rev-parse --show-toplevel)/vendor/github.com/tektoncd/plumbing/scripts/e2e-tests.sh
+
+function install_pipeline_crd() {
+  echo ">> Deploying Tekton Pipelines"
+  ko resolve -f config/ \
+      | sed -e 's%"level": "info"%"level": "debug"%' \
+      | sed -e 's%loglevel.controller: "info"%loglevel.controller: "debug"%' \
+      | sed -e 's%loglevel.webhook: "info"%loglevel.webhook: "debug"%' \
+      | kubectl apply -f - || fail_test "Build pipeline installation failed"
+  verify_pipeline_installation
+}
+
+# Install the Tekton pipeline crd based on the release number
+function install_pipeline_crd_version() {
+  echo ">> Deploying Tekton Pipelines of Version $1"
+  kubectl apply -f "https://github.com/tektoncd/pipeline/releases/download/$1/release.yaml" || fail_test "Build pipeline installation failed of Version $1"
+  verify_pipeline_installation
+}
+
+function verify_pipeline_installation() {
+  # Make sure that everything is cleaned up in the current namespace.
+  delete_pipeline_resources
+
+  # Wait for pods to be running in the namespaces we are deploying to
+  wait_until_pods_running tekton-pipelines || fail_test "Tekton Pipeline did not come up"
+}
+
+function uninstall_pipeline_crd() {
+  echo ">> Uninstalling Tekton Pipelines"
+  ko delete --ignore-not-found=true -f config/
+
+  # Make sure that everything is cleaned up in the current namespace.
+  delete_pipeline_resources
+}
+
+function uninstall_pipeline_crd_version() {
+  echo ">> Uninstalling Tekton Pipelines of version $1"
+  kubectl delete --ignore-not-found=true -f "https://github.com/tektoncd/pipeline/releases/download/$1/release.yaml"
+
+  # Make sure that everything is cleaned up in the current namespace.
+  delete_pipeline_resources
+}
+
+function delete_pipeline_resources() {
+  for res in conditions pipelineresources tasks clustertasks pipelines taskruns pipelineruns; do
+    kubectl delete --ignore-not-found=true ${res}.tekton.dev --all
+  done
+}
diff --git a/tekton-catalog/pipeline-loops/test/e2e-taskloops.sh b/tekton-catalog/pipeline-loops/test/e2e-taskloops.sh
new file mode 100755
index 0000000000..b61d9caf94
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/e2e-taskloops.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+
+# Copyright 2020 The Knative 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.
+
+# Helper functions for E2E tests.
+
+source $(git rev-parse --show-toplevel)/vendor/github.com/tektoncd/plumbing/scripts/e2e-tests.sh
+
+function install_pipelineloop_crd() {
+  echo ">> Deploying PipelineLoop custom task"
+  ko resolve -f config/ \
+      | sed -e 's%"level": "info"%"level": "debug"%' \
+      | sed -e 's%loglevel.controller: "info"%loglevel.controller: "debug"%' \
+      | sed -e 's%loglevel.webhook: "info"%loglevel.webhook: "debug"%' \
+      | kubectl apply -f - || fail_test "Build pipeline installation failed"
+  verify_pipelineloop_installation
+}
+
+function verify_pipelineloop_installation() {
+  # Make sure that everything is cleaned up in the current namespace.
+  delete_pipelineloop_resources
+
+  # Wait for pods to be running in the namespaces we are deploying to
+  wait_until_pods_running tekton-pipelines || fail_test "PipelineLoop controller or webhook did not come up"
+}
+
+function uninstall_pipelineloop_crd() {
+  echo ">> Uninstalling PipelineLoop custom task"
+  ko delete --ignore-not-found=true -f ${REPO_ROOT_DIR}/task-loops/config/
+
+  # Make sure that everything is cleaned up in the current namespace.
+  delete_pipelineloop_resources
+}
+
+function delete_pipelineloop_resources() {
+  # TODO: Runs should be cleaned up by e2e-common script in pipeline
+  for res in run; do
+    kubectl delete --ignore-not-found=true ${res}.tekton.dev --all
+  done
+  for res in pipelineloop; do
+    kubectl delete --ignore-not-found=true ${res}.custom.tekton.dev --all
+  done
+}
diff --git a/tekton-catalog/pipeline-loops/test/e2e-tests.sh b/tekton-catalog/pipeline-loops/test/e2e-tests.sh
new file mode 100755
index 0000000000..7dc9b2981c
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/e2e-tests.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+
+# Copyright 2020 The Knative 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.
+
+# This script calls out to scripts in tektoncd/plumbing to setup a cluster
+# and deploy Tekton Pipelines to it for running integration tests.
+
+source $(git rev-parse --show-toplevel)/task-loops/test/e2e-common.sh
+source $(git rev-parse --show-toplevel)/task-loops/test/e2e-pipelineloops.sh
+
+# Script entry point.
+
+initialize $@
+
+# initialize function does a CD to REPO_ROOT_DIR so we have to CD back here.
+cd ${REPO_ROOT_DIR}/task-loops
+
+header "Setting up environment"
+
+install_pipeline_crd_version v0.18.0
+
+install_pipelineloop_crd
+
+failed=0
+
+# Run the integration tests
+header "Running Go e2e tests"
+go_test_e2e -timeout=20m ./test/... || failed=1
+
+(( failed )) && fail_test
+success
diff --git a/tekton-catalog/pipeline-loops/test/presubmit-tests.sh b/tekton-catalog/pipeline-loops/test/presubmit-tests.sh
new file mode 100755
index 0000000000..f8c07f4bfd
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/presubmit-tests.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+
+# Copyright 2018 The Knative 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.
+
+# This script runs the presubmit tests; it is started by prow for each PR.
+# For convenience, it can also be executed manually.
+# Running the script without parameters, or with the --all-tests
+# flag, causes all tests to be executed, in the right order.
+# Use the flags --build-tests, --unit-tests and --integration-tests
+# to run a specific set of tests.
+
+# Markdown linting failures don't show up properly in Gubernator resulting
+# in a net-negative contributor experience.
+export DISABLE_MD_LINTING=1
+export TEST_FOLDER=$(pwd)
+export GO111MODULE=on
+
+source $(dirname $0)/../../vendor/github.com/tektoncd/plumbing/scripts/presubmit-tests.sh
+
+function pre_build_tests() {
+    pushd ${TEST_FOLDER}
+}
+
+function pre_unit_tests() {
+    pushd ${TEST_FOLDER}
+}
+
+function pre_integration_tests() {
+    pushd ${TEST_FOLDER}
+}
+
+function post_build_tests() {
+    popd
+}
+
+function post_unit_tests() {
+    popd
+}
+
+function post_integration_tests() {
+    popd
+}
+
+# June 28th 2019: work around https://github.com/tektoncd/plumbing/issues/44
+function unit_tests() {
+  local failed=0
+  echo "Using overridden unit_tests"
+  go test -v -race ./... || failed=1
+  echo "unit_tests returning $@"
+  return ${failed}
+}
+
+# We use the default build, unit and integration test runners.
+main $@
diff --git a/tekton-catalog/pipeline-loops/test/wait.go b/tekton-catalog/pipeline-loops/test/wait.go
new file mode 100644
index 0000000000..c6b6ddaa68
--- /dev/null
+++ b/tekton-catalog/pipeline-loops/test/wait.go
@@ -0,0 +1,299 @@
+/*
+Copyright 2019 The Knative 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.
+*/
+
+/*
+Poll Pipeline resources
+
+After creating Pipeline resources or making changes to them, you will need to
+wait for the system to realize those changes. You can use polling methods to
+check the resources reach the desired state.
+
+The WaitFor* functions use the kubernetes
+wait package (https://godoc.org/k8s.io/apimachinery/pkg/util/wait). To poll
+they use
+PollImmediate (https://godoc.org/k8s.io/apimachinery/pkg/util/wait#PollImmediate)
+and the return values of the function you provide behave the same as
+ConditionFunc (https://godoc.org/k8s.io/apimachinery/pkg/util/wait#ConditionFunc):
+a boolean to indicate if the function should stop or continue polling, and an
+error to indicate if there has been an error.
+
+
+For example, you can poll a TaskRun object to wait for it to have a Status.Condition:
+
+	err = WaitForTaskRunState(c, hwTaskRunName, func(tr *v1alpha1.TaskRun) (bool, error) {
+		if len(tr.Status.Conditions) > 0 {
+			return true, nil
+		}
+		return false, nil
+	}, "TaskRunHasCondition")
+
+*/
+package test
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"go.opencensus.io/trace"
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"knative.dev/pkg/apis"
+)
+
+const (
+	interval = 1 * time.Second
+	timeout  = 10 * time.Minute
+)
+
+// ConditionAccessorFn is a condition function used polling functions
+type ConditionAccessorFn func(ca apis.ConditionAccessor) (bool, error)
+
+func pollImmediateWithContext(ctx context.Context, fn func() (bool, error)) error {
+	return wait.PollImmediate(interval, timeout, func() (bool, error) {
+		select {
+		case <-ctx.Done():
+			return true, ctx.Err()
+		default:
+		}
+		return fn()
+	})
+}
+
+// WaitForTaskRunState polls the status of the TaskRun called name from client every
+// interval until inState returns `true` indicating it is done, returns an
+// error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForTaskRunState(ctx context.Context, c *clients, name string, inState ConditionAccessorFn, desc string) error {
+	metricName := fmt.Sprintf("WaitForTaskRunState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		r, err := c.TaskRunClient.Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(&r.Status)
+	})
+}
+
+// WaitForDeploymentState polls the status of the Deployment called name
+// from client every interval until inState returns `true` indicating it is done,
+// returns an  error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForDeploymentState(ctx context.Context, c *clients, name string, namespace string, inState func(d *appsv1.Deployment) (bool, error), desc string) error {
+	metricName := fmt.Sprintf("WaitForDeploymentState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		d, err := c.KubeClient.Kube.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(d)
+	})
+}
+
+// WaitForPodState polls the status of the Pod called name from client every
+// interval until inState returns `true` indicating it is done, returns an
+// error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForPodState(ctx context.Context, c *clients, name string, namespace string, inState func(r *corev1.Pod) (bool, error), desc string) error {
+	metricName := fmt.Sprintf("WaitForPodState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		r, err := c.KubeClient.Kube.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(r)
+	})
+}
+
+// WaitForPipelineRunState polls the status of the PipelineRun called name from client every
+// interval until inState returns `true` indicating it is done, returns an
+// error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForPipelineRunState(ctx context.Context, c *clients, name string, polltimeout time.Duration, inState ConditionAccessorFn, desc string) error {
+	metricName := fmt.Sprintf("WaitForPipelineRunState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	ctx, cancel := context.WithTimeout(ctx, polltimeout)
+	defer cancel()
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		r, err := c.PipelineRunClient.Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(&r.Status)
+	})
+}
+
+// WaitForRunState polls the status of the Run called name from client every
+// interval until inState returns `true` indicating it is done, returns an
+// error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForRunState(ctx context.Context, c *clients, name string, polltimeout time.Duration, inState ConditionAccessorFn, desc string) error {
+	metricName := fmt.Sprintf("WaitForRunState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	ctx, cancel := context.WithTimeout(ctx, polltimeout)
+	defer cancel()
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		r, err := c.RunClient.Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(&r.Status)
+	})
+}
+
+// WaitForServiceExternalIPState polls the status of the a k8s Service called name from client every
+// interval until an external ip is assigned indicating it is done, returns an
+// error or timeout. desc will be used to name the metric that is emitted to
+// track how long it took for name to get into the state checked by inState.
+func WaitForServiceExternalIPState(ctx context.Context, c *clients, namespace, name string, inState func(s *corev1.Service) (bool, error), desc string) error {
+	metricName := fmt.Sprintf("WaitForServiceExternalIPState/%s/%s", name, desc)
+	_, span := trace.StartSpan(context.Background(), metricName)
+	defer span.End()
+
+	return pollImmediateWithContext(ctx, func() (bool, error) {
+		r, err := c.KubeClient.Kube.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return true, err
+		}
+		return inState(r)
+	})
+}
+
+// Succeed provides a poll condition function that checks if the ConditionAccessor
+// resource has successfully completed or not.
+func Succeed(name string) ConditionAccessorFn {
+	return func(ca apis.ConditionAccessor) (bool, error) {
+		c := ca.GetCondition(apis.ConditionSucceeded)
+		if c != nil {
+			if c.Status == corev1.ConditionTrue {
+				return true, nil
+			} else if c.Status == corev1.ConditionFalse {
+				return true, fmt.Errorf("%q failed", name)
+			}
+		}
+		return false, nil
+	}
+}
+
+// Failed provides a poll condition function that checks if the ConditionAccessor
+// resource has failed or not.
+func Failed(name string) ConditionAccessorFn {
+	return func(ca apis.ConditionAccessor) (bool, error) {
+		c := ca.GetCondition(apis.ConditionSucceeded)
+		if c != nil {
+			if c.Status == corev1.ConditionTrue {
+				return true, fmt.Errorf("%q succeeded", name)
+			} else if c.Status == corev1.ConditionFalse {
+				return true, nil
+			}
+		}
+		return false, nil
+	}
+}
+
+// FailedWithReason provides a poll function that checks if the ConditionAccessor
+// resource has failed with the TimeoudOut reason
+func FailedWithReason(reason, name string) ConditionAccessorFn {
+	return func(ca apis.ConditionAccessor) (bool, error) {
+		c := ca.GetCondition(apis.ConditionSucceeded)
+		if c != nil {
+			if c.Status == corev1.ConditionFalse {
+				if c.Reason == reason {
+					return true, nil
+				}
+				return true, fmt.Errorf("%q completed with the wrong reason: %s (message: %s)", name, c.Reason, c.Message)
+			} else if c.Status == corev1.ConditionTrue {
+				return true, fmt.Errorf("%q completed successfully, should have been failed with reason %q", name, reason)
+			}
+		}
+		return false, nil
+	}
+}
+
+// FailedWithMessage provides a poll function that checks if the ConditionAccessor
+// resource has failed with the TimeoudOut reason
+func FailedWithMessage(message, name string) ConditionAccessorFn {
+	return func(ca apis.ConditionAccessor) (bool, error) {
+		c := ca.GetCondition(apis.ConditionSucceeded)
+		if c != nil {
+			if c.Status == corev1.ConditionFalse {
+				if strings.Contains(c.Message, message) {
+					return true, nil
+				}
+				return true, fmt.Errorf("%q completed with the wrong message: %s", name, c.Message)
+			} else if c.Status == corev1.ConditionTrue {
+				return true, fmt.Errorf("%q completed successfully, should have been failed with message %q", name, message)
+			}
+		}
+		return false, nil
+	}
+}
+
+// Running provides a poll condition function that checks if the ConditionAccessor
+// resource is currently running.
+func Running(name string) ConditionAccessorFn {
+	return func(ca apis.ConditionAccessor) (bool, error) {
+		c := ca.GetCondition(apis.ConditionSucceeded)
+		if c != nil {
+			if c.Status == corev1.ConditionTrue || c.Status == corev1.ConditionFalse {
+				return true, fmt.Errorf(`%q already finished`, name)
+			} else if c.Status == corev1.ConditionUnknown && (c.Reason == "Running" || c.Reason == "Pending") {
+				return true, nil
+			}
+		}
+		return false, nil
+	}
+}
+
+// TaskRunSucceed provides a poll condition function that checks if the TaskRun
+// has successfully completed.
+func TaskRunSucceed(name string) ConditionAccessorFn {
+	return Succeed(name)
+}
+
+// TaskRunFailed provides a poll condition function that checks if the TaskRun
+// has failed.
+func TaskRunFailed(name string) ConditionAccessorFn {
+	return Failed(name)
+}
+
+// PipelineRunSucceed provides a poll condition function that checks if the PipelineRun
+// has successfully completed.
+func PipelineRunSucceed(name string) ConditionAccessorFn {
+	return Succeed(name)
+}
+
+// PipelineRunFailed provides a poll condition function that checks if the PipelineRun
+// has failed.
+func PipelineRunFailed(name string) ConditionAccessorFn {
+	return Failed(name)
+}