Documentation
Using Runnable in a Supply Chain
Overview
In the previous tutorial we saw how Runnable brings updateable behavior to immutable kubernetes objects. In this tutorial, we’ll see how we can use Runnable in our supply chains for common behavior like linting, scanning, and testing.
Environment setup
For this tutorial you will need a kubernetes cluster with Cartographer, kpack and Tekton installed. You can find Cartographer’s installation instructions here, kpack’s here and Tekton’s here.
You will also need an image registry for which you have read and write permission.
You may choose to use the ./hack/setup.sh script to install a kind cluster with Cartographer, Tekton, kpack and a local registry. This script is meant for our end-to-end testing and while we rely on it working in that role, no user guarantees are made about the script.
Command to run from the Cartographer directory:
$ ./hack/setup.sh cluster cartographer-latest example-dependencies
If you later wish to tear down this generated cluster, run
$ ./hack/setup.sh teardown
Scenario
Continuing the scenario from the previous tutorial, we remember that our CTO is interested in putting quality controls in place; only code that passes certain checks should be built and deployed. They want to start small, and have decided all source code repositories that are built must pass markdown linting. In order to do this we’re going to leverage the markdown linting pipeline in the TektonCD catalog.
Last tutorial we saw how to use Cartographer’s Runnable to give us easy updating behavior of Tekton (no need for Tekton Triggers and Github Webhooks). In this following tutorial we’ll complete the scenario by using Runnable in a supply chain.
Steps
App Operator Steps
Much of our work from the previous tutorial remains the same. We deployed a Tekton pipeline and two Tekton Tasks in the cluster. These objects will be applied in this tutorial with no change.
We deployed a ClusterRunTemplate that templated a Tekton PipelineRun. This will stay largely the same, with only a change to the outputs (We’re going to define outputs that are slightly more useful than the ones we chose last tutorial). We will still deploy this object directly.
The object that we’ll deploy differently is the Runnable. To use Runnable in a supply chain we’ll wrap it in a Cartographer template. This template will be referenced in our supply chain. This work should feel very familiar to the steps we took in the Build Your First Supply Chain tutorial!
Supply Chain
Let’s begin by thinking through what template we need and where it will go in our supply chain. Our goal is to ensure that the only repos that are built and deployed are those that pass linting. So we’ll need our new step to be the first step in a supply chain. This step will receive the location of a source code and if the source code passes linting it will pass that location information to the next step in the supply chain. Do you remember what template is meant to expose information about the location of source code? That’s right, the ClusterSourceTemplate.
Let’s define our supply chain now. We’ll start with the supply chain we created in the Extending a Supply Chain tutorial. The resources then looked like this:
resources:
- name: build-image
templateRef:
kind: ClusterImageTemplate
name: image-builder
- name: deploy
templateRef:
kind: ClusterTemplate
name: app-deploy-from-sc-image
images:
- resource: build-image
name: built-image
We’ll add a new first step, lint source code. As we determined before, this will refer to a ClusterSourceTemplate. Our second step will remain a ClusterImageTemplate, but it will have to be a new template. This is because it will consume the source code from the previous step rather than directly from the workload. The rest of the resources will remain the same.
resources:
- name: lint-source
templateRef:
kind: ClusterSourceTemplate
Name: source-linter
- name: build-image
templateRef:
kind: ClusterImageTemplate
name: image-builder-from-previous-step
sources:
- resource: lint-source
name: source
- name: deploy
templateRef:
kind: ClusterTemplate
name: app-deploy-from-sc-image
images:
- resource: build-image
name: built-image
Our final step with the supply chain will be referring to a service-account. Let’s think through what permissions we need:
- the
source-linter
template will create a runnable - the
image-builder-from-previous-step
template will create a kpack image (just as the supply chain from the Extending a Supply Chain tutorial) - the
app-deploy-from-sc-image
template will create a deployment (just as the supply chain from the Extending a Supply Chain tutorial)
The only new object created here is a runnable, which is a Cartographer resource. The Cartographer controller already has permission to manipulate Cartographer resources. So we do not need to do any alterations, we can simply reuse the service account (and roles and role bindings) from the Extending a Supply Chain tutorial.
Note that while the supply chain refers to a service account, the Runnable itself also refers to a service account. More on that in a moment.
Here is our complete supply chain.
---
apiVersion: carto.run/v1alpha1
kind: ClusterSupplyChain
metadata:
name: source-code-supply-chain
spec:
selector:
workload-type: source-code
resources:
- name: lint-source
templateRef:
kind: ClusterSourceTemplate
name: source-linter
- name: build-image
templateRef:
kind: ClusterImageTemplate
name: image-builder-from-previous-step
sources:
- resource: lint-source
name: source
- name: deploy
templateRef:
kind: ClusterTemplate
name: app-deploy-from-sc-image
images:
- resource: build-image
name: built-image
serviceAccountRef:
name: cartographer-from-source-sa
namespace: default
Templates
There are two new templates that need to be written, image-builder-from-previous-step
and source-linter
. Creating
the image-builder-from-previous-step
will be left as an exercise for the reader. Refer to the Extending a Supply Chain
tutorial for help. Let’s turn to creating the source-linter
template.
We know we’ll wrap our Runnable in a ClusterSourceTemplate. We’ll begin as always, taking our previously created Runnable and simply pasting it into a ClusterSourceTemplate:
apiVersion: carto.run/v1alpha1
kind: ClusterSourceTemplate
metadata:
name: source-linter
spec:
template:
apiVersion: carto.run/v1alpha1
kind: Runnable
metadata:
name: linter
spec:
runTemplateRef:
name: md-linting-pipelinerun
inputs:
repository: https://github.com/waciumawanjohi/demo-hello-world
revision: main
serviceAccountName: pipeline-run-management-sa
urlPath: ???
revisionPath: ???
We can quickly see that there are hardcoded values that we’ll want to replace with references to the workload (so that
our supply chain can work with many different workloads). These are the inputs
values. Remember, these fields are
inputs to the ClusterRunTemplate. If a value could be hardcoded, it should not be a field in the Runnable’s inputs at
all, it should simply be hardcoded in the ClusterRunTemplate (e.g. every field in the runnable spec.inputs
should
become a Cartographer variable). Let’s look at how we’ll change inputs
:
inputs:
repository: $(workload.spec.source.git.url)$
revision: $(workload.spec.source.git.revision)$
Quite straightforward and familiar. Simply pull the requisite values from the workload. The next step will be straightforward as well; we need to make sure that multiple apps don’t overwrite one Runnable object. We need to template the Runnable’s name:
metadata:
name: $(workload.metadata.name)$-linter
Finally, we need to fill the urlPath
and revisionPath
to tell the ClusterSourceTemplate what field of the Runnable
to expose for the url
and revision
. Remember, that’s the contract of a ClusterSourceTemplate, it exposes those two
values to the rest of the supply chain. Runnables have a .status
field, which we’ll use. The contents of that field
are determined by fields on the ClusterRunTemplate. In the
previous tutorial the ClusterRunTemplate
declared that the output would be called lastTransitionTime
. Let’s declare our intention now to change the
ClusterRunTemplate. In a moment we’ll alter it to set new outputs named url
and revision
. That will allow us to
finish the ClusterSourceTemplate wrapping our runnable.
spec:
template: ...
urlPath: .status.outputs.url
revisionPath: .status.outputs.revision
Wonderful. Our ClusterSourceTemplate is complete:
---
apiVersion: carto.run/v1alpha1
kind: ClusterSourceTemplate
metadata:
name: source-linter
spec:
template:
apiVersion: carto.run/v1alpha1
kind: Runnable
metadata:
name: $(workload.metadata.name)$-linter
spec:
runTemplateRef:
name: md-linting-pipelinerun
inputs:
repository: $(workload.spec.source.git.url)$
revision: $(workload.spec.source.git.ref.branch)$
serviceAccountName: pipeline-run-management-sa
urlPath: .status.outputs.url
revisionPath: .status.outputs.revision
ClusterRunTemplate
Our supply-chain will now stamp out a Runnable. But we still have to change the ClusterRunTemplate to ensure that the
status of the Runnable has the fields we want. Just a moment ago we decided that these fields should be url
and
revision
. Before we alter the previous ClusterRunTemplate from the previous tutorial, let’s look at it:
apiVersion: carto.run/v1alpha1
kind: ClusterRunTemplate
metadata:
name: md-linting-pipelinerun
spec:
template:
apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
generateName: linter-pipeline-run-
spec:
pipelineRef:
name: linter-pipeline
params:
- name: repository
value: $(runnable.spec.inputs.repository)$
- name: revision
value: $(runnable.spec.inputs.revision)$
workspaces:
- name: shared-workspace
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 256Mi
outputs:
lastTransitionTime: .status.conditions[0].lastTransitionTime
We can see that the spec.outputs
field was used to prescribe a lastTransitionTime
field. We’ll change that to a
url
and revision
field:
outputs:
url: ???
revision: ???
Great. Now let’s think; where on the object that’s created will we find these values. Actually, why think about it; let’s take a look at the pipelinerun that was created in the last tutorial!
apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
name: linter-pipeline-run-123az
generateName: linter-pipeline-run-
labels:
carto.run/run-template-name: md-linting-pipelinerun
carto.run/runnable-name: linter
tekton.dev/pipeline: linter-pipeline
ownerReferences:
- apiVersion: carto.run/v1alpha1
blockOwnerDeletion: true
controller: true
kind: Runnable
name: linter
uid: ...
...
spec:
params:
- name: repository
value: https://github.com/waciumawanjohi/demo-hello-world
- name: revision
value: main
pipelineRef:
name: linter-pipeline
serviceAccountName: default
timeout: 1h0m0s
workspaces:
- name: shared-workspace
volumeClaimTemplate:
metadata:
creationTimestamp: null
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 256Mi
status: {}
status:
completionTime: ...
conditions: ...
pipelineSpec: ...
startTime: ...
taskRuns: ...
While it feels most natural to read outputs from the .status
of objects, in this case the value we want is in the
.spec
. We can see that the .spec.params
of this object has the url and revision of the source code. That’s the value
we wish to pass on if the pipeline-run is successful. We’ll use jsonpath in our outputs to grab these values:
outputs:
url: spec.params[?(@.name=="repository")].value
revision: spec.params[?(@.name=="revision")].value
Great, our outputs are complete.
There’s one more thing that we can do to make things easy on ourselves, differentiate the name of pipeline-runs of one workload from those of another. Technically, we do not have to do this. Cartographer is smart enough to stamp out these objects and differentiate objects created from the same prefix from Runnable 1 and Runnable 2. But we’re human and it’ll be nice for us to quickly be able to distinguish.
spec:
template:
metadata:
generateName: $(runnable.metadata.name)$-pipeline-run-
Let’s pull it all together! Our ClusterRunTemplate now reads:
---
apiVersion: carto.run/v1alpha1
kind: ClusterRunTemplate
metadata:
name: md-linting-pipelinerun
spec:
template:
apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
generateName: $(runnable.metadata.name)$-pipeline-run-
spec:
pipelineRef:
name: linter-pipeline
params:
- name: repository
value: $(runnable.spec.inputs.repository)$
- name: revision
value: $(runnable.spec.inputs.revision)$
workspaces:
- name: shared-workspace
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 256Mi
outputs:
url: spec.params[?(@.name=="repository")].value
revision: spec.params[?(@.name=="revision")].value
Runnable’s Service Account
The last object to mention is the serviceAccount object referred to in the ClusterSourceTemplate wrapped Runnable. We could refer to the same service account referred to in the supply chain. Then we would simply bind an additional role to the account, one that allows creation of a Tekton PipelineRun. Or we can refer to a different service account with these permissions. We created such a service account in the previous tutorial and our runnable still refers to that name. We will simply deploy that service account.
Now we’re ready. Let’s submit all these objects and step into our role as App Devs.
App Dev Steps
As devs, our work is easy! We submit a workload. We’re being asked for the same information as ever from the templates, a url and a revision for the location of the source code. We can submit the same workload from the Extending a Supply Chain tutorial:
---
apiVersion: carto.run/v1alpha1
kind: Workload
metadata:
name: hello-again
labels:
workload-type: source-code
spec:
source:
git:
ref:
branch: main
url: https://github.com/waciumawanjohi/demo-hello-world
Observe
Using kubectl tree we can see our workload is parent to a runnable which in turn is parent to a pipeline-run.
$ kubectl tree workload hello-again
NAMESPACE NAME READY REASON AGE
default Workload/hello-again True Ready
default ├─Deployment/hello-again-deployment -
default │ └─ReplicaSet/hello-again-deployment-67b7dc6d5 -
default │ ├─Pod/hello-again-deployment-67b7dc6d5-djtph True
default │ ├─Pod/hello-again-deployment-67b7dc6d5-f2nkv True
default │ └─Pod/hello-again-deployment-67b7dc6d5-p4m9c True
default ├─Image/hello-again True
default │ ├─Build/hello-again-build-1 -
default │ │ └─Pod/hello-again-build-1-build-pod False PodCompleted
default │ ├─PersistentVolumeClaim/hello-again-cache -
default │ └─SourceResolver/hello-again-source True
default └─Runnable/hello-again-linter True Ready
default └─PipelineRun/hello-again-linter-pipeline-run-x123x -
default ├─PersistentVolumeClaim/pvc-1a89a7e201 -
default ├─TaskRun/hello-again-linter-pipeline-run-x123x-fetch-repository -
default │ └─Pod/hello-again-linter-pipeline-run-x123x-fetch-repository-pod False PodCompleted
default └─TaskRun/hello-again-linter-pipeline-run-x123x-md-lint-run -
default └─Pod/hello-again-linter-pipeline-run-x123x-md-lint-run-pod False PodCompleted
We also see that the workload is in a ready state, as are all of the pods of our deployment.
Wrap Up
You’ve now built a supply chain leveraging runnables. Your app platform can now provide testing, scanning, linting and more to all the applications brought by your dev teams. Let’s look at what you learned in this tutorial:
- How to wrap a Runnable in a Cartographer template
- How to align the outputs from a ClusterRunTemplate with the values exposed by the template wrapping the Runnable
Before we leave this tutorial, we should note that the supply chain that we’ve created deals very well with new apps that are brought to the platform. That is, when a workload is submitted, the app will be linted and upon success will proceed to be built. But this supply chain is not resilient to changes made to the source code of said app. What will happen if the code was good, but is changed to a bad state? The linting step won’t rerun, as from the tekton perspective, no value has changed; it still has the same url and revision.
In order to address this problem, production supply chains should leverage a resource like
fluxCD’s source controller. This kubernetes resource will translate a
revision like main
into the revision of the current commit on main (e.g. commit abc123
). When main is updated,
source controller will ensure that it outputs the new commit that is the head of main. Leveraging this resource, we can
avoid the dilemma presented above.
Users can see supply chains that use fluxCD’s source controller in Cartographer’s examples.