Conformance Tests for Crossplane Configuration Packages
When crossplane graduated to Incubation status we published a conformance for crossplane and its providers. I started to wonder if we could use the same tools and framework for testing Configuration Packages, as well as Provider Packages.
A unified testing strategy for all crossplane packages could simplify our build and test pipelines. And it would provide a method for testing and validating external packages. Especially if we adopted a tool and framework which were already community standards.
Sonobuoy
sonobuoy is “a diagnostic tool that makes it easier to understand the state of a Kubernetes cluster by running a choice of configuration tests in an accessible and non-destructive manner.”
As we mentioned before, testing a crossplane configuration package will always be an e2e test of a kubernetes cluster running crossplane. And sonobuoy was built around the Kubernetes e2e-framework.
We developed a demo of sonobuoy and the e2e-framework for our internal teams. And along the way we found a solution to writing golang tests against untyped Resources in the cluster. This is 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 sonobuoy
I am running Mac OS X, so I installed sonobuoy using homebrew:
brew install sonobuoy
You can find installation instructions for other platforms on sonobuoy’s install page.
Step One: Launch Kind
Let’s get started in a fresh repo. First, bring up a kind cluster and get crossplane and your providers running on it:
-
Create a ‘uxp-values.yaml’ file that installs the platform-ref-aws package. (provider-aws will install automatically as a dependency.)
uxp-values.yaml:
--- configuration: packages: - registry.upbound.io/upbound/platform-ref-aws:v0.2.1
-
Stat the cluster and install crossplane.
kind create cluster --name sonobuoy-test up uxp install -f uxp-values.yaml
With our development cluster in place, we can move on to the coding.
Do I need to clone platform-ref-aws?
No. Sonobuoy, and the kubernetes e2e-framework, are built on top of the golang testing library. As such, all of the test components – including test cases and test resoruces – will be defined in the code. At the end, the entire suite will be built into a docker image and pushed to a repo.
This creates a test tool which can be run on a local cluster, a remote environment, or even made available to the public to confirm your package compatibily with other systems.
Step Two: Download the e2e-example Plugin Starter
Sonobuoy offers several plugin examples we can use for starters. We’re going to use the ’e2e-skeleton.’ (If you want to know more, you can check out sonobuoy’s blog on it.)
Run the following to get the starter plugin:
git clone https://github.com/vmware-tanzu/sonobuoy-plugins
cp -r sonobuoy-plugins/examples/e2e-skeleton/ ./plugin
rm -rf sonobuoy-plugins
# The next line will update the name of your module
export MODULE_HEAD="module <your-module-git-url-here>"
sed -i '' "1 s,.*,${MODULE_HEAD}," plugin/go.mod
pushd plugin/pkg
You are now in the root of your code pkg.
Running from the command line
The plugin is designed run within a cluster. If we try to run the code now, we would see:
envconfig: client failed: unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be defined
FAIL github.com/aaronme/ATourOfCrossplane/crossplane-package-testing-with-sonobuoy/pkg 0.312s
FAIL
Let’s enable our pluging to use a kubeconfig so we can run it from the commandline.
- Add an ENV_VAR for the kubeconfig:
main_test.go:
const ( ProgressReporterCtxKey = "SONOBUOY_PROGRESS_REPORTER" NamespacePrefixKey = "NS_PREFIX" ExternalClusterKubeconfig = "EXTERNAL_KUBECONFIG" // Accept a Kubeconfig )
- Replace the config.NewClient() call on line 53.
main_test.go:
// Try and create the client; doing it before all the tests allows the tests to assume // it can be created without error and they can just use config.Client(). // If EXTERNAL_KUBECONFIG is set, create a client based on the kubeconfig externalKubeConfig := os.Getenv(ExternalClusterKubeconfig) var err error if externalKubeConfig != "" { _, err = config.WithKubeconfigFile(externalKubeConfig).NewClient() } else { _, err = config.NewClient() }
- Export a kubeconfig from kind and setup the ENV_VARs:
kind export kubeconfig --name sonobuoy-test --kubeconfig kubeconfig export EXTERNAL_KUBECONFIG=kubeconfig export NS_PREFIX=tour # We also want set a prefix for our namespace names
- Run the tests
go test ./...
Success! You should be looking at your first test result:
ok github.com/aaronme/ATourOfCrossplane/crossplane-package-testing-with-sonobuoy/pkg 25.337s
Let’s go ahead and create our own test file now. Because we are using the go ’testing’ framework the file name must end with ‘_test.go’.
touch platform_ref_aws_test.go
Step Three: Our First Test
This is what our code needs to do:
- Create a Composite Resource Claim
- Confirm the claim generated a Composite Resource (XR)
- Confirm that we got all the Managed Resources (MRs) form the XR that we expect
Our Big Problem
Here’s how custom_test.go accesses resources in the cluster:
var pods corev1.PodList
err := cfg.Client().Resources("kube-system").List(context.TODO(), &pods)
if err != nil {
t.Fatal(err)
}
They create a corev1.PodList object, then use a client to populate that object with data from the api. The object type is imported from k8s.io/api/core/v1.
Unfortunately, we cannot import types from our configuration packages.
XRDs, CRDs, and Types
A Crossplane Composite Resource Definition (XRD) defines the schema for a Composite Resource. Crossplane creates a CustomResourceDefinition based on this schema, and installs it on the cluster.
Unlike managed resources in an open-source provider, we do not have access to the type library for a given package. Because it was never defined in go.
If we want to test Composites and Claims in our cluster, we’re going to need some way to access objects without knowing their types.
Learning about the Dynamic Client
After spending a week with runtime.Object{} I was able to teach myself that interfaces are not as magical as I thought they were. Interfaces are a way to use multiple types in a common method, but the types must be written to the interface.
We aren’t trying to work with multiple types. We’re trying to work with no types. We’ll never have them.
Finally, I found The Kubernetes Dynamic Client. And then I found Unstructured Kubernetes Components with client-go’s Dynamic Client.
We are not the first to encounter this problem.
Step Four: Implement the Dynamic Client
We’re not going to switch out the current client e2e uses, because this is just a demo. We don’t want to fall down the rabbit hole of needing to re-write all of the functions that are currently working. So we’ll write a quick function to get a Dynamic Client when we need one:
Note: We are also going to support the external kubeconfig with this client.
main_test.go:
...
// Add these to your list of imports
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
...
// Add this to the end of your file
func newDynamicClient() (dynamic.Interface, error) {
externalKubeConfig := os.Getenv(ExternalClusterKubeconfig)
if externalKubeConfig != "" {
config, err := clientcmd.BuildConfigFromFlags("", "./kubeconfig")
if err != nil {
fmt.Printf("error getting Kubernetes config: %v\n", err)
return nil, err
}
dynClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, err
}
return dynClient, nil
} else {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
dynClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, err
}
return dynClient, nil
}
}
NOTE: I’m trusting you to run go mod download as needed when we add modules.
Step Three Five: Let’s write that test!
Declare package and imports.
platform_ref_aws_test.go:
package pkg
import (
"context"
"fmt"
"testing"
"time"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/e2e-framework/pkg/envconf"
"sigs.k8s.io/e2e-framework/pkg/features"
"sigs.k8s.io/yaml"
)
Define GroupVersionResource and Claim for Test
The Dynamic Client works like the regular, typed client in custom_test.go. But instead of passing an object of the Kind we want, we pass it an explicit definition of the Group, Version, and Resource.
We are going to be working with both a Claim and XR, so we’ll define GroupVersionResources for both. And we’ll define the Claim we want to test:
platform_ref_aws_test.go:
func TestPlatformRefAWS(t *testing.T) {
// We need to declare the GVR for both our XRs and Claims
// This is the XR GroupVersionResource
compositeResource := schema.GroupVersionResource{
Group: "aws.platformref.crossplane.io",
Version: "v1alpha1",
Resource: "compositenetworks", // remember to use the plural
}
// This is the Claim GroupVersionResource
claimResource := schema.GroupVersionResource{
Group: "aws.platformref.crossplane.io",
Version: "v1alpha1",
Resource: "networks", // remember to use the plural
}
// This is a claim from https://github.com/upbound/platform-ref-aws/blob/main/examples/network.yaml
claim := `---
apiVersion: aws.platformref.crossplane.io/v1alpha1
kind: Network
metadata:
name: network
spec:
id: platform-ref-aws-network
clusterRef:
id: platform-ref-aws-cluster
`
// Unmarshal the Claim yaml into an Unstructured Resource.
// This requires going through Json.
unstructuredClaim := unstructured.Unstructured{}
json, err := yaml.YAMLToJSON([]byte(claim))
if err != nil {
t.Fatal(err)
}
err = unstructuredClaim.UnmarshalJSON(json)
if err != nil {
t.Fatal(err)
}
Test Step 1: Create the Claim
Claims are namespace-scoped, and our namespaces are being dynamically generated by the e2e-framework. We can access the namespace name in the context, but we must grab the lookup key before we run our first Feature test. This is because the name of each test is a ‘/’ joined concatenation of the Test Suite Name, Feature Name, and Test Step Name.
So, first we add this:
// We need to capture the namespace key name here because the test name
// changes inside Features and Assess methods
namespaceKey := nsKey(t)
And then we can set the name of our resource in the Test Step:
f := features.New("Rendered").
Assess("Managed Resources", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
// We will name our claim after the namespace for the parent test.
ns := fmt.Sprint(ctx.Value(namespaceKey))
claimName := fmt.Sprintf("%s-claim", ns)
unstructuredClaim.SetName(claimName)
unstructuredClaim.SetNamespace(ns)
Now we get a Dynamic Client and create the Claim. We will wait three seconds before confirming the claim was created:
// The e2e-skeleton comes with a client based on their klient type.
// We want to use a dynamic client, which enables working with
// unstructured resources
dynClient, err := newDynamicClient()
if err != nil {
t.Fatal(err)
}
// Create the Claim
createClaim, err := dynClient.Resource(claimResource).Namespace(ns).Create(context.TODO(), &unstructuredClaim, v1.CreateOptions{})
if err != nil {
t.Logf("creating Claim failed: +%v", createClaim)
t.Fatal(err)
}
time.Sleep(3 * time.Second)
// Retrieve the claim. This confirms our resource created and is also
// necessary to lookup the XR name.
getClaim, err := dynClient.Resource(claimResource).Namespace(ns).Get(context.TODO(), claimName, v1.GetOptions{})
if err != nil {
t.Logf("getting Claim failed: +%v", getClaim)
t.Fatal(err)
}
Test Step 2: Confirm we Generated an XR
After our claim is created, we want to confirm we got a Composite Resource (XR) for it. If we do not find an XR, we will throw an error:
// Get the XR
compositeName, exists, err := unstructured.NestedString(getClaim.UnstructuredContent(), "spec", "resourceRef", "name")
if err != nil {
t.Fatal(err)
}
if exists != true {
t.Log(getClaim.UnstructuredContent())
t.Fatal("No composite name found.")
}
// Our first test: confirm that an XR was created.
// This failure will indicate whether a successful composition template
// was selected or not
t.Run("Did create XR", func(t *testing.T) {
t.Logf("Fetching XR %s", compositeName)
getXR, err := dynClient.Resource(compositeResource).Get(context.TODO(), compositeName, v1.GetOptions{})
if err != nil {
t.Logf("getting XR failed: +%v", getXR)
t.Fatal(err)
}
})
Test Step 3: Confirm we Generated Expected Managed Resources
Now that we are dealing with managed resources, we can import the types library from provider-aws types. However, for the sake of clarity, we’ll continue working with unstructured objects in this demo.
Table-Driven Tests
Crossplane Contributing guidelines make it clear: “Crossplane encourages the use of table driven unit tests.” At Upbound, we maintain our open source principles in-house, so let’s make sure our tests honor the guidelines.
I found this article was a helpful reference.
Create a table of test cases:
// MRs we expect to be created by a Claim.
// For the purpose of this demo, we only verify we have the correct number
// of each resource type.
var mrs = []struct {
name string
gvr schema.GroupVersionResource
count int
}{
{"VPC", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "vpcs"}, 1},
{"InternetGateway", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "internetgateways"}, 1},
{"SecurityGroup", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "securitygroups"}, 1},
{"Subnet", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "subnets"}, 4},
{"RouteTable", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "routetables"}, 1},
}
And loop through them:
// MR Test Case Runs
for _, mr := range mrs {
mr := mr // rebind mr into this lexical scope
t.Run(mr.name, func(t *testing.T) {
got, err := dynClient.Resource(mr.gvr).List(context.TODO(), v1.ListOptions{})
if err != nil {
t.Errorf("error retrieving %q: %q", mr.name, err)
}
count := len(got.Items)
if count != mr.count {
t.Errorf("resource %q count is wrong.", mr.name)
}
})
}
NOTE: This is a terrible test. Do not do this in actual practice.
Finally, return and run:
return ctx
})
testenv.Test(t, f.Feature())
}
Step Five: Test the Test
We can delete custom_test.go now. We’re not going to need it.
rm -rf custom_test.go
Run the suite:
go test ./... -v
And you should see:
...
--- PASS: TestPlatformRefAWS (3.14s)
--- PASS: TestPlatformRefAWS/Rendered (3.10s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources (3.10s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/Did_create_XR (0.00s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/VPC (0.00s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/InternetGateway (0.00s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/SecurityGroup (0.00s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/Subnet (0.00s)
--- PASS: TestPlatformRefAWS/Rendered/Managed_Resources/RouteTable (0.01s)
PASS
ok github.com/aaronme/ATourOfCrossplane/crossplane-package-testing-with-sonobuoy/pkg 28.767s
Step Six: Sonobuoy Run
Building and running the container requires enabling buildx on your docker daemon, which we won’t cover here. But you can change to the plugin/ folder and run the following to test the containerized solution.
export REPO_NAME=<your repo here>
export CONTAINER_TAG=<your tag here>
export CONTAINER_NAME=${REPO_NAME}:${CONTAINER_TAG}
cat <<EOF > plugin.yaml
sonobuoy-config:
driver: Job
plugin-name: custom-e2e
result-format: gojson
source_url: https://raw.githubusercontent.com/aaronme/ATourOfCrossplane/main/crossplane-package-testing-with-sonobuoy/plugin/plugin.yaml
description: e2e test of the crossplane configuration package platform-ref-aws.
spec:
command:
- bash
args: ["-c","go tool test2json ./custom.test -test.v | tee \${SONOBUOY_RESULTS_DIR}/out.json ; echo \${SONOBUOY_RESULTS_DIR}/out.json > \${SONOBUOY_RESULTS_DIR}/done"]
image: ${CONTAINER_NAME}
env:
- name: NS_PREFIX
value: custom
- name: SONOBUOY_PROGRESS_PORT
value: "8099"
name: plugin
resources: {}
volumeMounts:
- mountPath: /tmp/sonobuoy/results
name: results
EOF
docker build -t ${CONTAINER_NAME} ./
kind load docker-image ${CONTAINER_NAME} --name sonobuoy-test
sonobuoy run --plugin plugin.yaml
sonobuoy wait
sonobuoy retrieve -f results.tar.gz
sonobuoy results results.tar.gz --mode dump
Your results should look something like this:
name: custom-e2e
status: passed
meta:
type: summary
items:
- name: out.json
status: passed
meta:
file: results/global/out.json
type: file
items:
- name: TestPlatformRefAWS/Rendered/Managed_Resources/Did_create_XR
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources/VPC
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources/InternetGateway
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources/SecurityGroup
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources/Subnet
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources/RouteTable
status: passed
- name: TestPlatformRefAWS/Rendered/Managed_Resources
status: passed
- name: TestPlatformRefAWS/Rendered
status: passed
- name: TestPlatformRefAWS
status: passed
The Finished Product
In the end, our folder structure for a configuration package including kuttl tests will look like the following:
.
├── plugin
│ ├── Dockerfile
│ ├── README.md
│ ├── build.sh
│ ├── go.mod
│ ├── go.sum
│ ├── pkg
│ │ ├── kubeconfig
│ │ ├── main_test.go
│ │ └── platform_ref_aws_test.go
│ └── plugin.yaml
└── uxp-values.yaml
And your complete test file looks like this:
platform_ref_aws_test.go:
package pkg
import (
"context"
"fmt"
"testing"
"time"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/e2e-framework/pkg/envconf"
"sigs.k8s.io/e2e-framework/pkg/features"
"sigs.k8s.io/yaml"
)
func TestPlatformRefAWS(t *testing.T) {
// We need to declare the GVR for both our XRs and Claims
// This is the XR GroupVersionResource
compositeResource := schema.GroupVersionResource{
Group: "aws.platformref.crossplane.io",
Version: "v1alpha1",
Resource: "compositenetworks", // remember to use the plural
}
// This is the Claim GroupVersionResource
claimResource := schema.GroupVersionResource{
Group: "aws.platformref.crossplane.io",
Version: "v1alpha1",
Resource: "networks", // remember to use the plural
}
// This is a claim from https://github.com/upbound/platform-ref-aws/blob/main/examples/network.yaml
claim := `---
apiVersion: aws.platformref.crossplane.io/v1alpha1
kind: Network
metadata:
name: network
spec:
id: platform-ref-aws-network
clusterRef:
id: platform-ref-aws-cluster
`
// Unmarshal the Yaml into an Unstructured Resource.
// This requires going through Json.
unstructuredClaim := unstructured.Unstructured{}
json, err := yaml.YAMLToJSON([]byte(claim))
if err != nil {
t.Fatal(err)
}
err = unstructuredClaim.UnmarshalJSON(json)
if err != nil {
t.Fatal(err)
}
// We need to capture the namespace key name here because the test name
// changes inside Features and Assess methods
namespaceKey := nsKey(t)
f := features.New("Rendered").
Assess("Managed Resources", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
// We will name our claim after the namespace for the parent test.
ns := fmt.Sprint(ctx.Value(namespaceKey))
claimName := fmt.Sprintf("%s-claim", ns)
unstructuredClaim.SetName(claimName)
unstructuredClaim.SetNamespace(ns)
// The e2e-skeleton comes with a client based on their klient type.
// We want to use a dynamic client, which enables working with
// unstructured resources
dynClient, err := newDynamicClient()
if err != nil {
t.Fatal(err)
}
// Create the Claim
createClaim, err := dynClient.Resource(claimResource).Namespace(ns).Create(context.TODO(), &unstructuredClaim, v1.CreateOptions{})
if err != nil {
t.Logf("creating Claim failed: +%v", createClaim)
t.Fatal(err)
}
time.Sleep(3 * time.Second)
// Retrieve the claim. This confirms our resource created and is also
// necessary to lookup the XR name.
getClaim, err := dynClient.Resource(claimResource).Namespace(ns).Get(context.TODO(), claimName, v1.GetOptions{})
if err != nil {
t.Logf("getting Claim failed: +%v", getClaim)
t.Fatal(err)
}
// Get the XR
compositeName, exists, err := unstructured.NestedString(getClaim.UnstructuredContent(), "spec", "resourceRef", "name")
if err != nil {
t.Fatal(err)
}
if exists != true {
t.Log(getClaim.UnstructuredContent())
t.Fatal("No composite name found.")
}
// Our first test: confirm that an XR was created.
// This failure will indicate whether a successful composition template
// was selected or not
t.Run("Did create XR", func(t *testing.T) {
t.Logf("Fetching XR %s", compositeName)
getXR, err := dynClient.Resource(compositeResource).Get(context.TODO(), compositeName, v1.GetOptions{})
if err != nil {
t.Logf("getting XR failed: +%v", getXR)
t.Fatal(err)
}
})
// MRs we expect to be created by a Claim.
// For the purpose of this demo, we only verify we have the correct number
// of each resource type.
var mrs = []struct {
name string
gvr schema.GroupVersionResource
count int
}{
{"VPC", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "vpcs"}, 1},
{"InternetGateway", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "internetgateways"}, 1},
{"SecurityGroup", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "securitygroups"}, 1},
{"Subnet", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "subnets"}, 4},
{"RouteTable", schema.GroupVersionResource{Group: "ec2.aws.crossplane.io", Version: "v1beta1", Resource: "routetables"}, 1},
}
// MR Test Case Runs
for _, mr := range mrs {
mr := mr // rebind mr into this lexical scope
t.Run(mr.name, func(t *testing.T) {
got, err := dynClient.Resource(mr.gvr).List(context.TODO(), v1.ListOptions{})
if err != nil {
t.Errorf("error retrieving %q: %q", mr.name, err)
}
count := len(got.Items)
if count != mr.count {
t.Errorf("resource %q count is wrong.", mr.name)
}
})
}
return ctx
})
testenv.Test(t, f.Feature())
}