Testing Kubernetes Controllers with the E2E-Framework

Using the features of the Kubernetes-SIGs/e2e-framework to create end-to-end testing of Kubernetes controllers and operators

Vladimir Vivien
Programming Kubernetes
8 min readNov 12, 2023

--

In a previous post, I introduced the E2E-Framework, a Kubernetes-SIGs project, for testing components running in a Kubernetes cluster. This post can be considered part two in which I will explore how to use the framework to test custom Kubernetes controllers running on a cluster.

End-to-End testing of a controller

If you are reading this, chances are you have written (or interested in writing) a Kubernetes controller/operator using either Kubernetes-SIGs/Kubebuilder or the Operator SDK. These are both frameworks that you can use to create your own Kubernetes controllers/operators.

This post assumes that you are familiar with the concept of Kubernetes controllers. It does not provide an in-depth coverage of controllers nor does it discuss how to create them.

The Kubebuilder project comes with a tutorial called Building CronJob which demonstrates how to create your own cron job controller (in case, you know, you are not happy with the one that comes with Kubernetes). This blog post shows you how to create end-to-end tests to exercise the components of that controller.

See the Github repository for complete source code listing of e2e-framework tests

Infrastructure setup

Running end-to-end tests of cluster components require several infrastructural steps that must be established before the controller is tested. As you will see below, the e2e-framework provide many constructs to facilitate these setup steps.

Define a Go TestMain

To get started, let’s define a test suite using a Go TestMain function to orchestrate the steps for the test. We will also create an e2e-framework env.Environment variable to define our setup steps:


var (
testEnv env.Environment
)

func TestMain(m *testing.M) {
...
}

Setup Kind

Next, we will use the Environment.Setup method to create a new Kind cluster and install a namespace on the cluster:


var (
testEnv env.Environment
namespace = "cronjob"
)

func TestMain(m *testing.M) {
testEnv = env.New()
kindClusterName := "kind-test"
kindCluster := kind.NewCluster(kindClusterName)

testEnv.Setup(
envfuncs.CreateCluster(kindCluster, kindClusterName),
envfuncs.CreateNamespace(namespace),
...
)
}

Install prometheus and cert-manager

Next, we will define a custom step function that is used to install prometheus and cert-manager. The step uses the e2e-framework’s wait package to wait for the cert manager deployment to be ready before continuing:

var (
...
certmgrVer = "v1.13.1"
certMgrUrl = fmt.Sprintf("https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml", certmgrVer)

promVer = "v0.60.0"
promUrl = fmt.Sprintf("https://github.com/prometheus-operator/prometheus-operator/releases/download/%s/bundle.yaml", promVer)
)

func TestMain(m *testing.M) {
...
testEnv.Setup(
func(ctx context.Context, cfg *envconf.Config) (context.Context, error) {
// install prometheus
if p := utils.RunCommand(
fmt.Sprintf("kubectl apply -f %s --server-side", promUrl),
); p.Err() != nil {
return ctx, p.Err()
}


// install cert-manager
if p := utils.RunCommand(
fmt.Sprintf("kubectl apply -f %s", certMgrUrl),
); p.Err() != nil {
return ctx, p.Err()
}

// wait for certmgr deployment to be ready
client := cfg.Client()
if err := wait.For(
conditions.New(client.Resources()).
DeploymentAvailable("cert-manager-webhook", "cert-manager"),
wait.WithTimeout(5*time.Minute),
wait.WithInterval(10*time.Second),
); err != nil {
return ctx, err
}
return ctx, nil
},
)
}

Install binary dependencies

The next function define steps to install binary dependencies (kustomize and controller-gen) needed to generate the required source and configuration files for the controller:

var (
...
kustomizeVer = "v5.1.1"
ctrlgenVer = "v0.13.0"
)

func TestMain(m *testing.M) {
...
testEnv.Setup(
...
func(ctx context.Context, cfg *envconf.Config) (context.Context, error) {
// install kubstomize binary
if p := utils.RunCommand(
fmt.Sprintf("go install sigs.k8s.io/kustomize/kustomize/v5@%s", kustomizeVer),
); p.Err() != nil {
return ctx, p.Err()
}

// install controller-gen binary
if p := utils.RunCommand(
fmt.Sprintf("go install sigs.k8s.io/controller-tools/cmd/controller-gen@%s", ctrlgenVer),
); p.Err() != nil {
return ctx, p.Err()
}
return ctx, nil
},
)
}

Generate and deploy components

Using the binaries installed in the previous step, we can now define a step function to do the followings:

  • Generate the configuration and source files for the controller
  • Build and deploy the controller container image to Docker
  • Load the built image into Kind
  • Deploy the controller components to the local Kind cluster

While it sounds complicated, the e2e-framework makes it easy. Let’s see how that is done:


func TestMain(m *testing.M) {
...
testEnv.Setup(
...
func(ctx context.Context, cfg *envconf.Config) (context.Context, error) {
// gen manifest files
if p := utils.RunCommand(
`controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases`,
); p.Err() != nil {
return ctx, p.Err()
}

// gen api objects
if p := utils.RunCommand(
`controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."`,
); p.Err() != nil {
return ctx, p.Err()
}

// Build the docker image
if p := utils.RunCommand(
fmt.Sprintf("docker build -t %s .", dockerImage),
); p.Err() != nil {
return ctx, p.Err()
}

// Load the docker image into kind
if err := kindCluster.LoadImage(ctx, dockerImage); err != nil {
return ctx, err
}

// Deploy the controller components
if p := utils.RunCommand(
`bash -c "kustomize build config/default | kubectl apply --server-side -f -"`,
); p.Err() != nil {
return ctx, p.Err()
}

// wait for the controller deployment to be ready
client := cfg.Client()
if err := wait.For(
conditions.New(client.Resources()).
DeploymentAvailable("cronjob-controller-manager", "cronjob-system"),
wait.WithTimeout(3*time.Minute),
wait.WithInterval(10*time.Second),
); err != nil {
return ctx, err
}
return ctx, nil
},
)
}

Teardown

Lastly, let’s use the e2e-framework Environment.Finish method to define some steps to tear down the infrastructure after all tests are completed.

testEnv.Finish(
// remove cluster components
func(ctx context.Context, cfg *envconf.Config) (context.Context, error) {
utils.RunCommand(fmt.Sprintf("kubectl delete -f %s", promUrl))
utils.RunCommand(fmt.Sprintf("kubectl delete -f %s", certMgrUrl))
utils.RunCommand(`bash -c "kustomize build config/default | kubectl delete -f -"`)
return ctx, nil
},
// delete namespace and destroy cluster
envfuncs.DeleteNamespace(namespace),
envfuncs.DestroyCluster(kindClusterName),
)

Launch the test

The last thing to do in TestMain is to trigger the Go test functions in the package. This is done using the Run method:

func TestMain(m *testing.M) {
...
os.Exit(testEnv.Run(m))
}

Controller unit tests

Now that we have setup an infrastructure with a Kubernetes cluster, we can use Go test functions to define unit tests for the controller.

But first, let’s define a CronJob struct literal that we will use in the tests:

var (
cronjobName = "cronjob-controller"

cronjob = &cronjobV1.CronJob{
TypeMeta: metaV1.TypeMeta{
APIVersion: "batch.tutorial.kubebuilder.io/v1",
Kind: "CronJob",
},
ObjectMeta: metaV1.ObjectMeta{
Name: cronjobName,
Namespace: namespace,
},
Spec: cronjobV1.CronJobSpec{
Schedule: "1 * * * *",
JobTemplate: batchV1.JobTemplateSpec{
Spec: batchV1.JobSpec{
Template: coreV1.PodTemplateSpec{
Spec: coreV1.PodSpec{
Containers: []coreV1.Container{{
Name: "test-container",
Image: "test-image",
}},
RestartPolicy: coreV1.RestartPolicyOnFailure,
},
},
},
},
},
}
)

The test function

Remember, the e2e-framework uses the standard built-in Go test framework. So to express your test, you start by defining a new Go test function:

func TestCron(t *testing.T) {

}

Test feature

Next we use the e2e-framework to define a test feature which allows you to express different setup and assessment steps for particular feature or functionality of your code:

func TestCron(t *testing.T) {
feature := features.New("Cronjob Controller")
}

Next, using the feature, we will start by setting up a watcher to watch for job pods created by our controller and puts them on channel podCreationSig to be processed later in the test:

func TestCron(t *testing.T) {
podCreationSig := make(chan *coreV1.Pod)

feature := features.New("Cronjob Controller")
feature.Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
client := cfg.Client()

// setup watcher for creation of job v1.Pods by cronjob-controller
if err := client.Resources(namespace).
Watch(&coreV1.PodList{}).
WithAddFunc(func(obj interface{}) {
pod := obj.(*coreV1.Pod)
if strings.HasPrefix(pod.Name, "cronjob-controller") {
podCreationSig <- pod
}
}).Start(ctx); err != nil {
t.Fatal(err)
}
return ctx
})
...
}

Assess CRD installation

Next, lets add an assessment to ensure the CronJob CRD is deployed on the cluster :

func TestCron(t *testing.T) {
...
feature.Assess("CRD installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
client := cfg.Client()
apiextensionsV1.AddToScheme(client.Resources().GetScheme())
name := "cronjobs.batch.tutorial.kubebuilder.io"
var crd apiextensionsV1.CustomResourceDefinition
if err := client.Resources().Get(ctx, name, "", &crd); err != nil {
t.Fatalf("CRD not found: %s", err)
}

if crd.Spec.Group != "batch.tutorial.kubebuilder.io" {
t.Fatalf("Cronjob CRD has unexpected group: %s", crd.Spec.Group)
}
return ctx
})
}

Assess CronJob creation

In the next assessment, we will create a new instance of a CronJob in the cluster and use the wait package to wait for it to be created by the controller or fail the test if it’s not created within the specified time:

func TestCron(t *testing.T) {
...
feature.Assess("Cronjob creation", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
client := cfg.Client()
cronjobV1.AddToScheme(client.Resources(namespace).GetScheme())

if err := client.Resources().Create(ctx, cronjob); err != nil {
t.Fatalf("Failed to crete cronjob: %s", err)
}

// wait for resource to be created
if err := wait.For(
conditions.New(client.Resources()).
ResourceMatch(cronjob, func(object k8s.Object) bool {
return true
},
),
wait.WithTimeout(3*time.Minute),
wait.WithInterval(30*time.Second),
); err != nil {
t.Fatal(err)
}

return ctx
})
}

Assess job pod creation

When the controller creates a new CronJob custom resource, it is expected to also create a pod that will run the scheduled job.

The next assessment uses the podCreationSig channel to block until it receives an instance of the created pod from the watcher (from earlier). If the pod is received within the specified time window, it is tested otherwise the test is failed:

func TestCron(t *testing.T) {
...
feature.Assess("Watcher received pod job", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
select {
case <-time.After(30 * time.Second):
t.Error("Timed out wating for job pod creation by cronjob contoller")
case pod := <-podCreationSig:
t.Log("Pod created by cronjob-controller")
refname := pod.GetOwnerReferences()[0].Name
if !strings.HasPrefix(refname, cronjobName) {
t.Fatalf("Job pod has unexpected owner ref: %#v", refname)
}
}
return ctx
})
}

Test the feature

The last step, in our test function, is to use the e2e-framework Environment.Test method to test the defined feature:

func TestCron(t *testing.T) {
...
testEnv.Test(t, feature.Feature())
}

Running the test

We can use the standard Go test tool to build and run the test in one command:

go test -v ./test

Because e2e-framework uses Go, running the test will automatically pull down all required package dependencies (no Make tasks required):

go test -v ./e2e-test
go: downloading sigs.k8s.io/e2e-framework v0.3.0
go: downloading k8s.io/api v0.28.3
go: downloading k8s.io/apimachinery v0.28.3
...

You can also build a test binary first:

go test -c -o controller.test ./test

And then run the test:

./controller.test -v

Conclusion

This blog post showcases some of the features of the Kubernetes-SIGs/e2e-framework that facilitate the end-to-end testing of Kubernetes controllers and operators. Using Kubebuilder’s CronJob controller as an example, this post show how to use the e2e-framework to automate the following:

  • Download Kind and setup a cluster
  • Install and configure prometheus and cert-manager
  • Build and deploy the controller on the cluster
  • Define test functions that exercise the controller and its custom resources

The features discussed here are only a small subset of the features supported in the e2e-framework project. Visit the project’s examples directory to explore other features and see why e2e-framework continues to gain traction.

References

--

--