Built-in controllers
petri runs the upstream controllers from kube-controller-manager inside your test process. It does not run kube-controller-manager itself. Instead, a small manager starts each controller, with shared informers, when a test asks for it.
On by default
Every plane runs two controllers:
- the namespace controller, which empties and removes a deleted namespace;
- the garbage collector, which deletes dependents when their owner is deleted, with background, foreground and orphan propagation, for built-in kinds and custom resources.
Start and New return once both have synced, so they see the first object a
test creates. This is the main difference from envtest. A dedicated plane
started with petri.WithoutDefaultControllers() runs neither.
Enabling the others
cp.EnableControllers(t, controllers...) starts controllers and returns once
they have synced, so they see an object created on the next line.
package controllers_test
import (
"context"
"testing"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/yyewolf/petri"
)
func TestReplicaSetCreatesPendingPods(t *testing.T) {
t.Parallel()
cp := petri.Start(t)
ns := cp.Namespace(t)
cs := cp.Client()
// Enabling a controller never enables another: Deployment alone would
// create the ReplicaSet and stop there.
cp.EnableControllers(t, petri.DeploymentController, petri.ReplicaSetController)
labels := map[string]string{"app": "web"}
_, err := cs.AppsV1().Deployments(ns).Create(t.Context(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "web"},
Spec: appsv1.DeploymentSpec{
Replicas: new(int32(3)),
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "web", Image: "nginx"}}},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}
// The shared plane has no Nodes, so the three Pods exist but stay Pending.
petri.Retry(t, func(ctx context.Context) (bool, error) {
pods, listErr := cs.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{LabelSelector: "app=web"})
if listErr != nil {
return false, listErr
}
for _, pod := range pods.Items {
if pod.Status.Phase != corev1.PodPending {
return false, nil
}
}
return len(pods.Items) == 3, nil
})
}
- Parallel tests can enable the same controller safely. On the shared plane, a controller stays on for every later test once one test enables it.
- Enabling a controller never enables another. The Deployment controller on its own creates ReplicaSets but no Pods.
petri.WorkloadControllers()returns all the workload controllers.
| Controller | What it does |
|---|---|
DeploymentController | Rolls Deployments out through ReplicaSets. |
ReplicaSetController | Keeps spec.replicas Pods per ReplicaSet. |
StatefulSetController | Creates StatefulSet Pods in order. |
DaemonSetController | Places one Pod per eligible Node. |
JobController | Runs Jobs' Pods and marks Jobs complete or failed. |
CronJobController | Creates Jobs on a schedule. |
TTLAfterFinishedController | Deletes finished Jobs after ttlSecondsAfterFinished. |
EndpointSliceController | Keeps an EndpointSlice per Service with a selector. |
ServiceAccountController | Keeps a default ServiceAccount in every namespace. |
ServiceAccountTokenController | Fills legacy service-account token Secrets with a token the API server accepts. |
ResourceQuotaController | Keeps quotas' status.used current. Quota admission is on regardless, so hard limits are enforced even with the controller off. |
DisruptionController | Computes PodDisruptionBudget statuses. |
SchedulerController | kube-scheduler; see Nodes and scheduling. |
Without Nodes
The shared plane has no Nodes, and a dedicated plane has none until a test
adds them. Without Nodes, Pods stay Pending, and each workload controller
stops at the point where it needs a running Pod:
| Controller | On a plane without Nodes |
|---|---|
| Deployment, ReplicaSet | Create Pods, which stay Pending; Deployments never become Available. |
| StatefulSet | With the default OrderedReady policy, creates Pod 0 and waits for it forever. |
| DaemonSet | Creates no Pods: there is no Node to run them on. |
| Job | Creates Pods up to parallelism; the Job never completes. |
| EndpointSlice | Creates EndpointSlices with no endpoints: no Pod is ever Ready. |
| Disruption | Every budget settles on zero disruptionsAllowed. |
Many tests need no more than this. If your operator creates Deployments, you can check what it created without running anything. To test further, add Nodes on a dedicated plane. Deployments then become Available and StatefulSets roll out, and Jobs complete when the test completes their Pods.
CronJobs
The CronJob controller follows the wall clock, and upstream has no way to
inject a fake one. To test what a CronJob runs, create a Job from its
spec.jobTemplate in the test instead of waiting for the schedule.