Kubernetes operators encode operational knowledge as code: instead of runbooks, an operator automatically provisions databases, handles backups, manages upgrades, and responds to failures. Claude Code generates operator code correctly — understanding the reconciliation loop model, proper status conditions, finalizer patterns, and the watch-based controller logic.
This guide covers Kubernetes operators with Claude Code: CRD design, controller-runtime reconciliation, status updates, finalizers, and testing with envtest.
What an Operator Does
An operator extends Kubernetes with a custom resource type and a controller that watches those resources and reconciles desired state to actual state.
I want to build an operator that manages PostgreSQL databases.
When I create a PostgresDatabase resource, it should:
1. Create a PostgreSQL user and database
2. Create a Kubernetes secret with connection credentials
3. Update the resource status with the connection URL
Walk me through the structure.
Custom Resource Definition
# config/crd/bases/db.example.com_postgresdatabases.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: postgresdatabases.db.example.com
spec:
group: db.example.com
names:
kind: PostgresDatabase
listKind: PostgresDatabaseList
plural: postgresdatabases
singular: postgresdatabase
shortNames:
- pgdb
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [instance, databaseName]
properties:
instance:
type: string
description: Name of the PostgresInstance resource to create the DB on
databaseName:
type: string
pattern: '^[a-z][a-z0-9_]*$'
maxLength: 63
owner:
type: string
description: Database owner username (defaults to databaseName)
extensions:
type: array
items:
type: string
description: PostgreSQL extensions to install (e.g., pgcrypto, uuid-ossp)
status:
type: object
properties:
phase:
type: string
enum: [Pending, Provisioning, Ready, Failed]
connectionSecret:
type: string
description: Name of the Secret containing connection credentials
conditions:
type: array
items:
type: object
required: [type, status, lastTransitionTime, reason, message]
properties:
type:
type: string
status:
type: string
enum: ["True", "False", "Unknown"]
lastTransitionTime:
type: string
format: date-time
reason:
type: string
message:
type: string
subresources:
status: {} # Enable status subresource — updates don't conflict with spec updates
additionalPrinterColumns:
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
Reconciler Implementation
// internal/controller/postgresdatabase_controller.go
package controller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
dbv1alpha1 "example.com/postgres-operator/api/v1alpha1"
"example.com/postgres-operator/internal/postgres"
)
const finalizerName = "db.example.com/finalizer"
type PostgresDatabaseReconciler struct {
client.Client
Scheme *runtime.Scheme
PostgresAdmin postgres.AdminClient
}
func (r *PostgresDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// Fetch the resource
db := &dbv1alpha1.PostgresDatabase{}
if err := r.Get(ctx, req.NamespacedName, db); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil // Resource deleted — nothing to do
}
return ctrl.Result{}, err
}
// Handle deletion: run cleanup via finalizer
if !db.DeletionTimestamp.IsZero() {
return r.handleDeletion(ctx, db)
}
// Ensure finalizer is present
if !controllerutil.ContainsFinalizer(db, finalizerName) {
controllerutil.AddFinalizer(db, finalizerName)
if err := r.Update(ctx, db); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil
}
// Main reconciliation
return r.reconcileDatabase(ctx, db)
}
func (r *PostgresDatabaseReconciler) reconcileDatabase(ctx context.Context, db *dbv1alpha1.PostgresDatabase) (ctrl.Result, error) {
log := log.FromContext(ctx)
// Update phase to Provisioning
if err := r.updateStatus(ctx, db, dbv1alpha1.PhaseProvisioning, "Provisioning", "Starting database provisioning"); err != nil {
return ctrl.Result{}, err
}
// Fetch the PostgresInstance to get connection details
instance := &dbv1alpha1.PostgresInstance{}
if err := r.Get(ctx, client.ObjectKey{
Namespace: db.Namespace,
Name: db.Spec.Instance,
}, instance); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, r.setFailedCondition(ctx, db, "InstanceNotFound",
fmt.Sprintf("PostgresInstance %s not found", db.Spec.Instance))
}
return ctrl.Result{}, err
}
// Idempotent: create database if it doesn't exist
owner := db.Spec.Owner
if owner == "" {
owner = db.Spec.DatabaseName
}
if err := r.PostgresAdmin.CreateDatabaseIfNotExists(ctx, postgres.CreateDatabaseParams{
Host: instance.Status.Host,
AdminSecret: instance.Status.AdminSecret,
DatabaseName: db.Spec.DatabaseName,
Owner: owner,
Extensions: db.Spec.Extensions,
}); err != nil {
log.Error(err, "Failed to create database")
return ctrl.Result{}, r.setFailedCondition(ctx, db, "ProvisioningFailed", err.Error())
}
// Create or update the connection Secret
secretName := fmt.Sprintf("%s-credentials", db.Name)
secret := r.buildConnectionSecret(db, instance, secretName, owner)
if err := controllerutil.SetControllerReference(db, secret, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.createOrUpdate(ctx, secret); err != nil {
return ctrl.Result{}, err
}
// Update status to Ready
db.Status.ConnectionSecret = secretName
return ctrl.Result{}, r.updateStatus(ctx, db, dbv1alpha1.PhaseReady, "Ready", "Database provisioned successfully")
}
func (r *PostgresDatabaseReconciler) handleDeletion(ctx context.Context, db *dbv1alpha1.PostgresDatabase) (ctrl.Result, error) {
if controllerutil.ContainsFinalizer(db, finalizerName) {
// Drop the database before removing the finalizer
if err := r.PostgresAdmin.DropDatabase(ctx, db.Spec.DatabaseName); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(db, finalizerName)
if err := r.Update(ctx, db); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
func (r *PostgresDatabaseReconciler) updateStatus(ctx context.Context, db *dbv1alpha1.PostgresDatabase, phase, reason, message string) error {
db.Status.Phase = dbv1alpha1.Phase(phase)
// Add condition
condition := metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
LastTransitionTime: metav1.Now(),
Reason: reason,
Message: message,
}
if phase == dbv1alpha1.PhaseReady {
condition.Status = metav1.ConditionTrue
} else {
condition.Status = metav1.ConditionFalse
}
// Use SetStatusCondition to handle transition time correctly
// (only updates transition time when status actually changes)
apimeta.SetStatusCondition(&db.Status.Conditions, condition)
return r.Status().Update(ctx, db)
}
// SetupWithManager registers the controller and sets up watches
func (r *PostgresDatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&dbv1alpha1.PostgresDatabase{}).
Owns(&corev1.Secret{}). // Also reconcile when owned Secrets change
Complete(r)
}
Testing with envtest
// internal/controller/suite_test.go
package controller_test
import (
"context"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
var (
cfg *rest.Config
k8sClient client.Client
testEnv *envtest.Environment
ctx context.Context
cancel context.CancelFunc
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
ctx, cancel = context.WithCancel(context.Background())
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
}
var err error
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
mgr, err := ctrl.NewManager(cfg, ctrl.Options{})
Expect(err).NotTo(HaveOccurred())
// Register the controller with a mock PostgresAdmin
err = (&PostgresDatabaseReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
PostgresAdmin: &mockPostgresAdmin{},
}).SetupWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
k8sClient = mgr.GetClient()
go func() {
defer GinkgoRecover()
Expect(mgr.Start(ctx)).To(Succeed())
}()
})
var _ = AfterSuite(func() {
cancel()
Expect(testEnv.Stop()).To(Succeed())
})
For deploying operators on real clusters with Helm, see the Helm charts guide. For GitOps management of operator deployments via ArgoCD, see the GitOps guide. The Claude Skills 360 bundle includes Kubernetes skill sets covering operator development, custom controllers, and RBAC design. Start with the free tier to try Kubernetes operator code generation.