Lesson 08 of 9 · Part 3 — Go and Kubernetes
A small controller with Kubebuilder
Write a small Kubernetes controller with Kubebuilder: a TeamNamespace custom resource that creates a namespace with quotas and RBAC, a reconcile loop that's idempotent, status that tells users what happened, owner references and finalizers for clean-up, and tests with envtest.
When a controller is the right tool
A controller turns a declarative request into reality and keeps it that way. Write one when the platform needs continuous reconciliation of something Kubernetes doesn't model, for example one TeamNamespace object that creates a namespace, a ResourceQuota and a RoleBinding for a team's group. Before writing one, check whether Helm, Kustomize, a policy engine's generate rules, or Config Connector/Crossplane already solve it.
A controller is a thermostat. You set the temperature you want (the custom resource). It keeps checking the room (the cluster), and whenever it's too cold or too hot it switches the heater on or off (creates, updates, deletes), as many times as needed, without you asking again.
Scaffold
$ kubebuilder init --domain platform.example.com --repo github.com/me/team-operator
$ kubebuilder create api --group platform --version v1alpha1 --kind TeamNamespace
Kubebuilder generates the type file, the controller, CRD and RBAC manifests (from markers), a Makefile, and envtest wiring.
The API
// api/v1alpha1/teamnamespace_types.go (the parts you edit)
type TeamNamespaceSpec struct {
// Google/OIDC group that gets edit access
Group string `json:"group"`
// CPU quota, e.g. "20"
CPU string `json:"cpu"`
// Memory quota, e.g. "64Gi"
Memory string `json:"memory"`
}
type TeamNamespaceStatus struct {
Ready bool `json:"ready"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
Markers above the type add validation and a status subresource (+kubebuilder:subresource:status, +kubebuilder:validation:Pattern=...); make manifests turns them into the CRD.
The reconcile loop
// +kubebuilder:rbac:groups=platform.platform.example.com,resources=teamnamespaces,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=platform.platform.example.com,resources=teamnamespaces/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=namespaces;resourcequotas,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch
func (r *TeamNamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var tn platformv1alpha1.TeamNamespace
if err := r.Get(ctx, req.NamespacedName, &tn); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err) // deleted: owner references clean up the rest
}
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: tn.Name}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
if ns.Labels == nil {
ns.Labels = map[string]string{}
}
ns.Labels["pod-security.kubernetes.io/enforce"] = "restricted"
return controllerutil.SetControllerReference(&tn, ns, r.Scheme)
}); err != nil {
return ctrl.Result{}, fmt.Errorf("namespace: %w", err)
}
quota := &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: "team", Namespace: tn.Name}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, quota, func() error {
quota.Spec.Hard = corev1.ResourceList{
corev1.ResourceRequestsCPU: resource.MustParse(tn.Spec.CPU),
corev1.ResourceRequestsMemory: resource.MustParse(tn.Spec.Memory),
}
return controllerutil.SetControllerReference(&tn, quota, r.Scheme)
}); err != nil {
return ctrl.Result{}, fmt.Errorf("quota: %w", err)
}
rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "team-edit", Namespace: tn.Name}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, rb, func() error {
rb.Subjects = []rbacv1.Subject{{Kind: "Group", Name: tn.Spec.Group, APIGroup: rbacv1.GroupName}}
rb.RoleRef = rbacv1.RoleRef{Kind: "ClusterRole", Name: "edit", APIGroup: rbacv1.GroupName}
return controllerutil.SetControllerReference(&tn, rb, r.Scheme)
}); err != nil {
return ctrl.Result{}, fmt.Errorf("rolebinding: %w", err)
}
tn.Status.Ready = true
meta.SetStatusCondition(&tn.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionTrue, Reason: "Reconciled", Message: "namespace, quota and access in place",
})
if err := r.Status().Update(ctx, &tn); err != nil {
return ctrl.Result{}, err
}
log.Info("reconciled", "team", tn.Name)
return ctrl.Result{}, nil
}
func (r *TeamNamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&platformv1alpha1.TeamNamespace{}).
Owns(&corev1.Namespace{}).
Owns(&corev1.ResourceQuota{}).
Owns(&rbacv1.RoleBinding{}).
Complete(r)
}
What makes it correct:
- Idempotent:
CreateOrUpdatemakes each child match the spec, whether it's missing, drifted or already right. Reconcile can run any number of times. - Owner references: deleting the TeamNamespace deletes its children (garbage collection), and
Owns(...)re-triggers reconcile when someone edits or deletes a child. - Status and conditions tell users and other tools what happened; errors are returned so controller-runtime retries with backoff.
- RBAC markers generate exactly the permissions the controller uses.
(Real code would also set a Ready=False condition on errors and validate quantities before MustParse.)
Finalizers, for things outside the cluster
If reconcile creates something outside Kubernetes (a DNS zone, a cloud IAM binding, a Git repo), add a finalizer: on deletion, the object stays until the controller has cleaned up and removed the finalizer (controllerutil.AddFinalizer, RemoveFinalizer, and a check on DeletionTimestamp).
Testing with envtest
envtest starts a real API server and etcd (no nodes) for tests: create a TeamNamespace, then Eventually assert that the namespace, quota and RoleBinding exist with the right fields. It catches RBAC-free logic bugs quickly; test in a kind cluster for the full picture.
Try it: TeamNamespace end to end
- Install Kubebuilder, scaffold the project and add the spec fields and reconcile code above.
make install runagainst a kind cluster; apply a TeamNamespace forshopand check the namespace, quota and RoleBinding.- Delete the RoleBinding by hand and watch the controller recreate it; change the CPU quota in the spec and watch it update.
- Delete the TeamNamespace and confirm the children are garbage-collected.
- Write an envtest that checks the quota values, and run
make test.
Going deeper: controllers in production
- Run with leader election (two replicas, one active) and expose metrics (controller-runtime does both).
- Version CRDs carefully (
v1alpha1→v1beta1→v1), with conversion webhooks when fields change. - Watch the reconcile error rate and queue depth; a controller that silently fails is worse than none.
- Prefer composing existing tools (GitOps + templates, policy generate rules, Crossplane) when they're enough; every controller is software you maintain.
Recap
- A controller reconciles desired state (a custom resource) into actual state, continuously.
- Kubebuilder scaffolds types, controllers, CRDs and RBAC from markers.
- Reconcile must be idempotent; use CreateOrUpdate, owner references, status conditions.
- Finalizers for external clean-up; envtest for fast tests.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.