Logo

Documentation

This is the documentation for the latest development version of Cartographer. Both code and docs may be unstable and these docs are not guaranteed to be up to date or correct. See the latest version.

Lifecycle: Templating Objects That Cannot Update

Overview

Most kubernetes objects are updateable. But some useful objects, like tekton taskruns, are not. Because Tekton can be useful for creating your own actions in your supply chain, we’ll explore how to use a new template field lifecycle to easily create tekton taskruns.

Environment setup

For this tutorial you will need a kubernetes cluster with Cartographer and Tekton installed. You can find Cartographer’s installation instructions here and Tekton’s installation instructions are here.

Alternatively, you may choose to use the ./hack/setup.sh script to install a kind cluster with Cartographer and Tekton. 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

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: 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 .

In this tutorial we’ll see how Cartographer gives us easy updating behavior of Tekton (no need for Tekton Triggers and Github Webhooks).

Steps

Tekton Basics

Before using Cartographer, let’s think about how we would use Tekton on its own to ensure a repo passes lint checks. First we would define a pipeline:

---
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: linter-pipeline
spec:
  params:
    - name: repository
      type: string
    - name: revision
      type: string
  workspaces:
    - name: shared-workspace
  tasks:
    - name: fetch-repository
      taskRef:
        name: git-clone
      workspaces:
        - name: output
          workspace: shared-workspace
      params:
        - name: url
          value: $(params.repository)
        - name: revision
          value: $(params.revision)
        - name: subdirectory
          value: ""
        - name: deleteExisting
          value: "true"
    - name: md-lint-run #lint markdown
      taskRef:
        name: markdown-lint
      runAfter:
        - fetch-repository
      workspaces:
        - name: shared-workspace
          workspace: shared-workspace
      params:
        - name: args
          value: ["."]

We would apply this pipeline to the cluster, along with the tasks. Those tasks are in the TektonCD Catalog:

$ kubectl apply -f https://raw.githubusercontent.com/tektoncd/catalog/main/task/git-clone/0.3/git-clone.yaml
$ kubectl apply -f https://raw.githubusercontent.com/tektoncd/catalog/main/task/markdown-lint/0.1/markdown-lint.yaml

Finally, we need to create a pipeline-run object. This object provides the param and workspace values defined at the top level .spec field of the pipeline.

---
apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
  name: linter-pipeline-run
spec:
  pipelineRef:
    name: linter-pipeline
  params:
    - name: repository
      value: https://github.com/waciumawanjohi/demo-hello-world
    - name: revision
      value: main
  workspaces:
    - name: shared-workspace
      volumeClaimTemplate:
        spec:
          accessModes:
            - ReadWriteOnce
          resources:
            requests:
              storage: 256Mi

Importantly, this pipeline-run object will kick off a single run of the pipeline. The run will either succeed or fail. The outcome will be written into the pipeline-run’s status. No later changes to the pipeline-run object will change those outcomes; the run happens once.

To see this in action, let’s apply the above pipeline-run. If we watch the object, we’ll soon see that it succeeds.

$ watch 'kubectl get -o yaml pipelinerun linter-pipeline-run | yq .status.conditions'

Eventually yields the result:

- lastTransitionTime: ...
  message: "Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 0"
  reason: "Succeeded"
  status: "True"
  type: "Succeeded"

Templating Pipeline Runs

Now let’s do this in a supply chain. First, we’ll ensure that the Tekton tasks and pipeline defined above remain available in our namespace. Then we’ll write a ClusterSourceTemplate to that will template out the tekton pipelinerun. We’ll ensure that the lifecycle field on the ClusterSourceTemplate is set to tekton. Then we’ll alter a supply chain to use our new template. And finally we’ll apply a workload.

ClusterSourceTemplate

Let’s start by simply copying the PipelineRun above into a ClusterSourceTemplate and then look at the values that will need to change:

apiVersion: carto.run/v1alpha1
kind: ClusterSourceTemplate
metadata:
  name: source-linter
spec:
  template:
    apiVersion: tekton.dev/v1beta1
    kind: PipelineRun
    metadata:
      name: linter-pipeline-run # <=== Multiple objects can’t all have the same name
    spec:
      pipelineRef:
        name: linter-pipeline
      params: # <=== These param values will change
        - name: repository
          value: https://github.com/waciumawanjohi/demo-hello-world
        - name: revision
          value: main
      workspaces:
        - name: shared-workspace
          volumeClaimTemplate:
            spec:
              accessModes:
                - ReadWriteOnce
              resources:
                requests:
                  storage: 256Mi

Most fields are fine. The name field is not. Why not? When our inputs change, we’re not going to update the templated object, instead we’re going to create an entirely new object. And of course that new object can’t have the same hardcoded name. To handle this, every object templated with lifecycle: tekton should specify a generateName rather than a name. We can use linter-pipeline-run- and kubernetes will handle putting a unique suffix on the name of each pipeline-run.

    metadata:
      generateName: linter-pipeline-run-

The other change we want to make is to the values on the params. It doesn’t do much good to hardcode https://github.com/waciumawanjohi/demo-hello-world into the repository param; we want each team’s repo to be templated here.

      params:
        - name: repository
          value: $(workload.spec.source.git.url)$
        - name: revision
          value: $(workload.spec.source.git.ref.branch)$

Next, let’s specify the output of this template. As a ClusterSourceTemplate we must output a url and a revision. We don’t expect this task to mutate anything, the output value will be the same as the input value, and the output will only be made available if the linting succeeds.

spec:
  template: ...
  urlPath: .status.pipelineSpec.tasks[0].params[0].value
  revisionPath: .status.pipelineSpec.tasks[0].params[1].value

Finally, we’re ready to add the lifecycle field:

spec:
  template: ...
  urlPath: ...
  revisionPath: ...
  lifecycle: tekton

Let’s look at our complete template:

---
apiVersion: carto.run/v1alpha1
kind: ClusterSourceTemplate
metadata:
  name: source-linter
spec:
  template:
    apiVersion: tekton.dev/v1beta1
    kind: PipelineRun
    metadata:
      generateName: linter-pipeline-run-
    spec:
      pipelineRef:
        name: linter-pipeline
      params:
        - name: repository
          value: $(workload.spec.source.git.url)$
        - name: revision
          value: $(workload.spec.source.git.ref.branch)$
      workspaces:
        - name: shared-workspace
          volumeClaimTemplate:
            spec:
              accessModes:
                - ReadWriteOnce
              resources:
                requests:
                  storage: 256Mi
  urlPath: .status.pipelineSpec.tasks[0].params[0].value
  revisionPath: .status.pipelineSpec.tasks[0].params[1].value
  lifecycle: tekton

Great! Let’s deploy this object.

ClusterSupplyChain

Next, let’s think through where our new template 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.

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 Tekton pipelinerun.
  • 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 the tekton pipelinerun. We can simply reuse the service account from the Extending a Supply Chain tutorial and add an additional role and role binding.

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: testing-role-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: pipeline-run-management-role
subjects:
  - kind: ServiceAccount
    name: cartographer-from-source-sa

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pipeline-run-management-role
rules:
  - apiGroups:
      - tekton.dev
    resources:
      - pipelineruns
    verbs:
      - list
      - create
      - update
      - delete
      - patch
      - watch
      - get

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 is a new template that needs to be written, image-builder-from-previous-step. Creating this template will be left as an exercise for the reader. Refer to the Extending a Supply Chain tutorial for help.

Boilerplate

Recall from the the Extending a Supply Chain tutorial that there are kpack dependencies and service accounts that are necessary for our supply chain to run properly.

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

Stamped Object

Let’s observe the pipeline-run objects in the cluster:

$ kubectl get pipelineruns

We can see that a new pipelinerun has been created with the linter-pipeline-run- prefix:

NAME                        SUCCEEDED   REASON      STARTTIME   COMPLETIONTIME
linter-pipeline-run-123az   True        Succeeded   2m48s       2m35s

Examining the created object it’s a non-trivial 300 lines:

$ kubectl get -o yaml pipelineruns linter-pipeline-run-123az

In the metadata we can see familiar labels indicating Carto objects were used to create this templated object. We can also see that the object is owned by the workload.

apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
  name: linter-pipeline-run-123az
  generateName: linter-pipeline-run-
  labels:
    carto.run/cluster-template-name: source-linter
    carto.run/resource-name: lint-source
    carto.run/supply-chain-name: source-code-supply-chain
    carto.run/template-kind: ClusterSourceTemplate
    carto.run/workload-name: hello-again
    carto.run/workload-namespace: default
    tekton.dev/pipeline: linter-pipeline
  ownerReferences:
    - apiVersion: carto.run/v1alpha1
      blockOwnerDeletion: true
      controller: true
      kind: Workload
      name: hello-again
      uid: ...
  ...

The spec contains the spec that we templated out. Looks great.

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: {}

The status contains fields expected of Tekton, including the condition indicating successful completion:

status:
  completionTime: ...
  conditions:
    - lastTransitionTime: "2022-03-07T19:24:35Z"
      message: "Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 0"
      reason: Succeeded
      status: "True"
      type: Succeeded
  pipelineSpec: ...
  startTime: ...
  taskRuns: ...

To learn more about Tekton’s behavior, readers will want to refer to Tekton documentation.

Workload and children

Using kubectl tree we can see our workload is parent to a pipeline-run.

NAMESPACE  NAME                                                      READY  REASON        AGE
default    Workload/hello-again                                      True   Ready
default    ├─Deployment/hello-again-deployment                       -
default    │ └─ReplicaSet/hello-again-deployment-675ff9765d          -
default    │   ├─Pod/hello-again-deployment-675ff9765d-b8mfb         True
default    │   ├─Pod/hello-again-deployment-675ff9765d-h8jx5         True
default    │   └─Pod/hello-again-deployment-675ff9765d-lz4fv         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    └─PipelineRun/linter-pipeline-run-k2n7d                   -
default      ├─PersistentVolumeClaim/pvc-48d61fa98f                  -
default      ├─TaskRun/linter-pipeline-run-k2n7d-fetch-repository    -
default      │ └─Pod/linter-pipeline-run-k2n7d-fetch-repository-pod  False  PodCompleted
default      └─TaskRun/linter-pipeline-run-k2n7d-md-lint-run         -
default        └─Pod/linter-pipeline-run-k2n7d-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.

Running app

We can port-forward our app and see how it serves traffic:

$ kubectl port-forward deployment/hello-deployment 3000:80

We can curl our application:

curl localhost:3000

And the result is:

I'm glad I spend Fridays with TGIK!

(Have you watched the many presentations explaining the philosophy and workings of Cartographer?)

Updating

Failing a test

Let’s update our workload with a new repository, this time a repository that won’t pass our linting test.

---
apiVersion: carto.run/v1alpha1
kind: Workload
metadata:
  name: hello-again
  labels:
    workload-type: source-code
spec:
  source:
    git:
      ref:
        branch: master                               # <=== new revision
      url: https://github.com/kelseyhightower/nocode # <=== new repo

When we apply this to the cluster, we can observe:

  • The spec of our workload is updated
  • The workload causes the creation of a new pipelinerun
  • The new pipeline run fails because the new repo does not pass linting

Because the pipelinerun has failed, the values passed forward through the supply chain to the next step come from the previous pipelinerun.

If we examine the workload, we can see that the first resource is our tekton pipeline. It continues to reflect the value of the previous pipelinerun which succeeded, rather than the more recent failed pipelinerun. With that value unchanged, the kpack image and the deployments remain the same.

$ kubectl get -o yaml workload hello-again
status:
  conditions: ...
  observedGeneration: ...
  resources:
    - conditions:
        - lastTransitionTime: "2022-11-22T16:17:04Z"
          message: ""
          reason: ResourceSubmissionComplete
          status: "True"
          type: ResourceSubmitted
        - lastTransitionTime: "2022-11-22T16:17:04Z"
          message: ""
          reason: OutputsAvailable
          status: "True"
          type: Healthy
        - lastTransitionTime: "2022-11-22T16:17:04Z"
          message: ""
          reason: Ready
          status: "True"
          type: Ready
      name: lint-source
      outputs:
        - digest: ...
          lastTransitionTime: ...
          name: url
          preview: |
                        https://github.com/waciumawanjohi/demo-hello-world
        - digest: ...
          lastTransitionTime: ...
          name: revision
          preview: |
                        main
      stampedRef:
        apiVersion: tekton.dev/v1beta1
        kind: PipelineRun
        name: linter-pipeline-run-abc12
        namespace: default
        resource: pipelineruns.tekton.dev
      templateRef: ...
  supplyChainRef: ...

Since the result of the new pipeline run is failure, no new value is passed forward to the kpack image. The deployment remains the same. When we curl our deployment, we get the same output:

I'm glad I spend Fridays with TGIK!

Passing the test

Let’s update the workload with new code that will pass linting:

---
apiVersion: carto.run/v1alpha1
kind: Workload
metadata:
  name: hello-again
  labels:
    workload-type: source-code
spec:
  source:
    git:
      ref:
        branch: bye
      url: https://github.com/waciumawanjohi/demo-hello-world

After giving the workload time to re-reconcile, we see that our curl results in a new message:

It's been good talking!

Wrap Up

In this tutorial you learned:

  • Tekton taskruns and pipelineruns cannot be updated
  • The lifecycle: tekton field allows you to use a template to create and recreate these immutable objects