Skip to main content

controller-runtime

controller-runtime needs nothing from petri but a *rest.Config: pass cp.Config() to ctrl.NewManager and run your manager as usual.

The optional github.com/yyewolf/petri/controllerruntime module saves the code every suite ends up writing around that:

  • starting the manager, waiting for its caches, and stopping it when the test ends;
  • waiting on objects through a controller-runtime client, which is how most operators read their own API types;
  • an envtest drop-in for existing kubebuilder suites.
go get github.com/yyewolf/petri/controllerruntime@v0.37

It has the same version as petri and requires the controller-runtime release built for the same Kubernetes minor. It is a separate module so that petri itself does not depend on controller-runtime, and so petri can move to a new Kubernetes minor before controller-runtime supports it.

Running a manager​

manager_test.go
package manager_test

import (
"context"
"testing"

corev1 "k8s.io/api/core/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/predicate"

"github.com/yyewolf/petri"
petricr "github.com/yyewolf/petri/controllerruntime"
)

// stamper is the reconciler under test: it marks every ConfigMap it sees.
type stamper struct {
client.Client
}

func (s *stamper) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
cm := &corev1.ConfigMap{}
if err := s.Get(ctx, req.NamespacedName, cm); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if cm.Annotations["stamped"] == "true" {
return ctrl.Result{}, nil
}

patch := client.MergeFrom(cm.DeepCopy())
if cm.Annotations == nil {
cm.Annotations = map[string]string{}
}
cm.Annotations["stamped"] = "true"

return ctrl.Result{}, s.Patch(ctx, cm, patch)
}

func TestStamperMarksConfigMaps(t *testing.T) {
t.Parallel()

cp := petri.Start(t)
ns := cp.Namespace(t)

// Metrics and health probes are off, so parallel tests never fight over
// ports. Pass petricr.WithScheme for your own API types.
mgr := petricr.NewManager(t, cp.Config())

// On the shared plane the manager sees every test's objects; this test's
// controller only takes its own namespace's.
err := ctrl.NewControllerManagedBy(mgr).
Named("stamper-" + ns).
For(&corev1.ConfigMap{}).
WithEventFilter(predicate.NewPredicateFuncs(func(o client.Object) bool {
return o.GetNamespace() == ns
})).
Complete(&stamper{Client: mgr.GetClient()})
if err != nil {
t.Fatal(err)
}

// Returns once the caches have synced; stops the manager when the test
// ends, and fails the test if it exits early.
petricr.StartManager(t, mgr)

// Check results with a direct client: the manager's client reads from its
// cache, which lags behind, and cannot watch.
c, err := client.NewWithWatch(cp.Config(), client.Options{})
if err != nil {
t.Fatal(err)
}

cm := &corev1.ConfigMap{}
cm.Name, cm.Namespace = "settings", ns
err = c.Create(t.Context(), cm)
if err != nil {
t.Fatal(err)
}

// The condition gets nil while the object does not exist.
petricr.WaitFor(t, c, cm, func(cm *corev1.ConfigMap) (bool, error) {
return cm != nil && cm.Annotations["stamped"] == "true", nil
})
}
  • petricr.NewManager(t, cfg, options...) builds a manager for tests. It turns the metrics server off, so managers in parallel tests never compete for :8080, and registers the built-in Kubernetes types. petricr.WithScheme(scheme) sets the scheme for your API types. petricr.WithWebhookServer(server) attaches a webhook server, for example one that serves from cp.ServingCertDir(t).
  • petricr.StartManager(t, mgr) starts the manager and returns once its caches have synced. It stops the manager when the test ends, and fails the test if the manager exits early. A stopped manager leaves no goroutines behind.
  • petricr.WaitFor(t, client, obj, condition) is petri.WaitFor for controller-runtime clients. It follows obj by name and namespace and returns it once the condition holds. As in petri, the condition receives nil while the object does not exist. petricr.WaitUntil(ctx, ...) returns an error instead of failing.

Give WaitFor a direct client from client.New or client.NewWithWatch, not mgr.GetClient(). The manager's client reads from a cache that lags behind your test's writes, and it cannot watch.

On the shared plane, a manager sees every test's objects. Limit your controller to the test's namespace, as the example does, or run it on a dedicated plane.

Existing envtest suites​

A suite that already uses envtest's Environment does not need a rewrite; see migrating from envtest.