Testing Crossplane Packages with Kuttl.

Crossplane Package Test Loop

The test loop for Crossplane Packages is always going to be an end-2-end test in a working kubernetes cluster. In order to validate that a package behaves as desired, we must:

  1. Access a Kubernetes Cluster
  2. Install Crossplane and required providers on that cluster
  3. Configure our Composite Resources (XRs) on that cluster
  4. Submit a Composite Resource or Claim
  5. Validate that we got the desired Resources in response
  6. Repeat the last 3 steps for each Resource

Kuttl

kuttl (The KUbernetes Test TooL) is a toolkit for writing tests against Kubernetes. Using kuttl, engineers can apply manifests, helm charts, or run kubectl commands against a kubernetes cluster, then compare the cluster state against a yaml file to validate the result.

This is exactly what we need when testing our configuration packages. Unlike custom operators, crossplane compositions will always output declarative yaml objects. So we can validate the output of our Composite Resources with simple yaml comparisons.

We’ve been able to satisfy all of the phases of the test loop above with kuttl. In this post I will show you how we did it.

Let’s get Started!

You can find a complete example of this tutorial in the A Tour of Crossplane repo.

Prerequisites

We’re going to assume you have the following installed:

Installing kuttl

I am running Mac OS X, so I installed kuttl using homebrew:

brew tap kudobuilder/tap
brew install kuttl-cli

You can find installation instructions for other platforms on kuttl’s install page.

Step One: Launch Kind

We’re going to create a kuttl TestSuite and use it to start a kind cluster. Create the following file in the root of your workspace.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
startKIND: true
kindContext: kuttl-test

You have now completed Step One. That was easy!

At the start of each test run kuttl will create a kind cluster with the name defined in kindContext.

If you don’t care about the name of the cluster, you can omit this. Kuttl will just create a cluster with the default name “kind”.

Step Two: Install Crossplane

We need Crossplane running on the cluster to provision Composite Resources and render the Compositions. Kuttl supports a commands argument which accepts a list of commands to run. Each command must start with valid binary. If you want to use shell built-ins or scripting functionality you can wrap those in a script called by command or you can use the script command type.

commands is supported in both TestSuites and TestSteps.

The commands argument is really powerful. We can use it to run any command or script required to setup our tests. Because we will need Crossplane to keep running for all tests, we’re going to set it up in the TestSuite, rather than any individual TestStep.

Let’s add commands to install Crossplane to our Test Suite.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace
startKIND: true
kindContext: kuttl-test

We use universal-crossplane at Upbound, but you can use vanilla crossplane. They will both work for this demo.

Step Three: Add Composite Resources to the Control Plane

We’re going to source the Composite Resource configuration from the platform-ref-aws package on github. When testing our local providers, we cannot depend on crossplane’s native dependency resolution. We must install our package dependencies ourselves.

For this demo we need provider-aws. The helm chart for crossplane supports passing in a list of provider packages during install. We’ll do this using a values file.

Using a values file will make it easier to keep the test dependencies up to date with the actual dependencies, declared in a crossplane.yaml. We’ll keep the values file under a new folder called tests, which will be the home for our test cases as well.

tests/uxp-values.yaml:

provider:
  packages:
    - crossplane/provider-aws:v0.19.0

Now update the command in your TestSuite.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml
startKIND: true
kindContext: kuttl-test

We’re going to want to make sure we delay any testing until the provider is healthy. So let’s add a kubectl wait command.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml
  # Wait for provider-aws to become healthy.
  - command: kubectl wait --for condition=healthy  --timeout=300s provider/crossplane-provider-aws
startKIND: true
kindContext: kuttl-test

Creating a TestStep

Now that we’re working with specific resources, we’re ready to define Test Cases. First, we’ll create a folder for our composition tests and a folder for our test case (we’ll be using the CompositeNetwork Resource from platform-ref-aws).

mkdir -p tests/compositions/compositenetwork

We’ll add tests/compositions/ to the list of testDirs in our TestSuite.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml
  # Wait for provider-aws to become healthy.
  - command: kubectl wait --for condition=healthy  --timeout=300s provider/crossplane-provider-aws
testDirs:
  - tests/compositions/
startKIND: true
kindContext: kuttl-test

Composite Resource Order of Operations

This is not a tutorial on writing compositions, or a technical walkthrough of how compositions work. But I want to clearly establish the order of operations required to get a new XR installed on a cluster.

A Crossplane Composite Resource (XR) defines a new Type in the Kubernetes API. Composite Resources are defined by two other types: Composite Resource Definitions (XRD), and Compositions.

To install a new XR, you must apply both an XRD and a valid Composition that satisfies that XRD.

When an XRD has successfully added the new type to Kubernetes, it will have the status “established=true”.

You can only submit an XR or a Claim for one if its XRD is “established.”

So the first step our test case will install the XRD and Composite.

tests/compositions/compositenetwork/00-install.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
  # Install the XRD
  - command: kubectl apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/network/definition.yaml
  # Install the Composition
  - command: kubectl apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/network/composition.yaml

We’re using the same commands argument that we saw in the TestSuite. If we were working with a local package these commands would reference files in our package/ folder. But for the sake of this demo, we are applying them directly from github.

(If you want to play around with local files, feel free to copy all of those down to your workspace and apply them from a local package/ folder.)

A brief detour: The No Money Down ProviderConfig

If you ran the test at this point, you most likely got an error on the XR. This is because Crossplane cannot find the ProviderConfig required by one of the resources in our Composition. Without that ProviderConfig, Crossplane will not render composition.

We don’t need to actually create any AWS infrastructure to run these tests. We only want to validate that our Composition renders properly. So we’ll create a “no money down” ProviderConfig for AWS in an init folder under tests.

tests/init/providerConfig.yaml:

---
apiVersion: v1
kind: Secret
metadata:
  name: aws-creds
  namespace: upbound-system
type: Opaque
stringData:
  key: nocreds
---
apiVersion: aws.crossplane.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: upbound-system
      name: aws-creds
      key: key

And we’ll make sure every test case can use this by adding it ot the TestSuite.

kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml
  # Wait for provider-aws to become healthy.
  - command: kubectl wait --for condition=healthy  --timeout=300s provider/crossplane-provider-aws
  # Install ProviderConfig for test.
  - command: kubectl apply -f tests/init/
testDirs:
  - tests/compositions/
startKIND: true
kindContext: kuttl-test

Step Four: Create the CompositeResource

We are now going to submit a CompositeResource for Crossplane to render. Note that we are making this a new Step by incrementing the numerical prefix. Kuttl will run all of the 00-* test steps before proceeding to 01-*.)

We will add a kubectl wait command to make sure the XRD is established before applying our resource.

tests/compositions/compositenetwork/01-network.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
  # Wait for XRD to become "established"
  - command: kubectl wait --for condition=established --timeout=20s xrd/compositenetworks.aws.platformref.crossplane.io
  # Create the XR/Claim
  - command: kubectly apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/examples/network.yaml

Step Five: Validate Managed Resources

At this point, Crossplane should be rendering our Network Resource. We will now validate that using an *-assert.yaml file. Warning, CompositeNetwork creates 8 managed resources. We’ve limited this test to checking only for the existence of two subnets and some tags on the VPC.

tests/compositions/compositenetwork/01-assert.yaml:

---
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: Subnet
metadata:
  labels:
    access: private
    crossplane.io/claim-name: network
    networks.aws.platformref.crossplane.io/network-id: platform-ref-aws-network
    zone: us-west-2a
---
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: Subnet
metadata:
  labels:
    access: public
    crossplane.io/claim-name: network
    networks.aws.platformref.crossplane.io/network-id: platform-ref-aws-network
    zone: us-west-2a
---
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: VPC
metadata:
  labels:
    crossplane.io/claim-name: network
    networks.aws.platformref.crossplane.io/network-id: platform-ref-aws-network
spec:
  deletionPolicy: Delete
  forProvider:
    tags:
    - key: crossplane-kind
      value: vpc.ec2.aws.crossplane.io
    - key: crossplane-name
    - key: crossplane-providerconfig

Parallelism

Kuttl will run your Test Cases in Parallel. This can cause problems if you have multiple resources that depend on one another, or on a common resource. (For example, both PostgresDB and Cluster rely on Network in platform-ref-aws). This could create conditions where cleanup of one test case destroys a dependency for another.

Keep this in mind when designing your Test Suite. You may wish to apply all of the Composite Resources in your package/ folder at the beginning of the Test Suite to ensure all dependencies are in place.

Running the test

Now that everything is setup, run the tests from the root of your workspace.

kubectl kuttle test

=== RUN   kuttl
    harness.go:457: starting setup
    harness.go:245: running tests with KIND.
    harness.go:156: Starting KIND cluster
    kind.go:67: Adding Containers to KIND...
    harness.go:285: Successful connection to cluster at: https://127.0.0.1:50478
    logger.go:42: 20:44:36 |  | running command: [helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml]
    ...
    logger.go:42: 20:46:21 |  | running command: [kubectl wait --for condition=healthy --timeout=300s provider/crossplane-provider-aws]
    logger.go:42: 20:47:30 |  | provider.pkg.crossplane.io/crossplane-provider-aws condition met
    logger.go:42: 20:47:30 |  | running command: [kubectl apply -f tests/init/]
    logger.go:42: 20:47:39 |  | secret/aws-creds created
    logger.go:42: 20:48:00 |  | providerconfig.aws.crossplane.io/default created
    harness.go:353: running tests
    harness.go:74: going to run test suite with timeout of 30 seconds for each step
    harness.go:365: testsuite: tests/compositions/ has 1 tests
=== RUN   kuttl/harness
=== RUN   kuttl/harness/compositenetwork
=== PAUSE kuttl/harness/compositenetwork
=== CONT  kuttl/harness/compositenetwork
    logger.go:42: 20:48:00 | compositenetwork | Creating namespace: kuttl-test-enormous-albacore
    logger.go:42: 20:48:00 | compositenetwork/0-install | starting test step 0-install
    logger.go:42: 20:48:00 | compositenetwork/0-install | running command: [kubectl apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/network/definition.yaml]
    logger.go:42: 20:48:02 | compositenetwork/0-install | compositeresourcedefinition.apiextensions.crossplane.io/compositenetworks.aws.platformref.crossplane.io created
    logger.go:42: 20:48:02 | compositenetwork/0-install | running command: [kubectl apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/network/composition.yaml]
    logger.go:42: 20:48:04 | compositenetwork/0-install | composition.apiextensions.crossplane.io/compositenetworks.aws.platformref.crossplane.io created
    logger.go:42: 20:48:05 | compositenetwork/0-install | test step completed 0-install
    logger.go:42: 20:48:05 | compositenetwork/1-network | starting test step 1-network
    logger.go:42: 20:48:05 | compositenetwork/1-network | running command: [kubectl wait --for condition=established --timeout=20s xrd/compositenetworks.aws.platformref.crossplane.io]
    logger.go:42: 20:48:06 | compositenetwork/1-network | compositeresourcedefinition.apiextensions.crossplane.io/compositenetworks.aws.platformref.crossplane.io condition met
    logger.go:42: 20:48:06 | compositenetwork/1-network | running command: [kubectl apply -f https://raw.githubusercontent.com/upbound/platform-ref-aws/main/examples/network.yaml]
    logger.go:42: 20:48:12 | compositenetwork/1-network | network.aws.platformref.crossplane.io/network created
    logger.go:42: 20:48:23 | compositenetwork/1-network | test step completed 1-network
    logger.go:42: 20:48:23 | compositenetwork | compositenetwork events from ns kuttl-test-enormous-albacore:
    logger.go:42: 20:48:23 | compositenetwork | Deleting namespace: kuttl-test-enormous-albacore
=== CONT  kuttl
    harness.go:399: run tests finished
    harness.go:508: cleaning up
    harness.go:569: tearing down kind cluster
--- PASS: kuttl (362.88s)
    --- PASS: kuttl/harness (0.00s)
        --- PASS: kuttl/harness/compositenetwork (23.70s)
PASS

The Finished Product

In the end, our folder structure for a configuration package including kuttl tests will look like the following:

.
├── examples # CompositeResource examples (not demonstrated)
├── kuttl-test.yaml # TestSuite configuration
├── package # XRDs and Composites (not demonstrated)
│   └── crossplane.yaml # Configuration Package metadata file
└── tests # TestSteps
    ├── compositions
    │   └── compositenetwork
    │       ├── 00-install.yaml
    │       ├── 01-assert.yaml
    │       └── 01-network.yaml
    ├── init # Manifests to initialize test environment
    │   └── providerConfig.yaml
    └── uxp-values.yaml # Values for Universal-Crossplane

And this is the complete kuttl-test.yaml:

apiVersion: kuttl.dev/v1beta1
kind: TestSuite
commands:
  # Install Univerasal Crossplane (uxp).
  - command: helm install universal-crossplane https://charts.upbound.io/stable/universal-crossplane-1.5.1-up.1.tgz --namespace upbound-system --wait --create-namespace --values tests/uxp-values.yaml
  # Wait for provider-aws to become healthy.
  - command: kubectl wait --for condition=healthy  --timeout=300s provider/crossplane-provider-aws
  # Install ProviderConfig for test.
  - command: kubectl apply -f tests/init/
testDirs:
  # Test Cases
  - tests/compositions/
startKIND: true
kindContext: kuttl-test