Kubernetes operators encode operational knowledge as code: they extend the Kubernetes API with Custom Resource Definitions (CRDs) and watch those resources with controllers that drive the cluster toward the desired state. Kubebuilder provides the scaffolding — code generation, RBAC markers, and the test framework (envtest) that runs a real API server against your controllers. Claude Code generates CRD schemas, reconciliation loops with proper error handling and requeue logic, admission webhook validation, and the complete operator project structure.
CLAUDE.md for Kubernetes Operators
## Operator Stack
- Kubebuilder 4.x with controller-runtime 0.19
- Language: Go 1.23+
- CRD generation: controller-gen via `make generate` / `make manifests`
- RBAC: marker annotations on reconciler methods (generate with `make manifests`)
- Testing: envtest (real API server, no mocks) in suite_test.go
- Webhook: defaulting + validation webhooks for all CRDs
- Status: use metav1.Conditions (not custom status fields) for standard tooling compatibility
- Finalizers: register before side effects, remove only after cleanup completes
CRD Type Definition
// api/v1alpha1/appdeployment_types.go
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1 "k8s.io/api/core/v1"
)
// AppDeploymentSpec: desired state
type AppDeploymentSpec struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Image string `json:"image"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100
// +kubebuilder:default=1
Replicas int32 `json:"replicas,omitempty"`
// +kubebuilder:validation:Enum=RollingUpdate;Recreate
// +kubebuilder:default=RollingUpdate
UpdateStrategy string `json:"updateStrategy,omitempty"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
// Environment variables injected into the deployment
Env []corev1.EnvVar `json:"env,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port int32 `json:"port,omitempty"`
}
// AppDeploymentStatus: observed state
type AppDeploymentStatus struct {
// Standard condition list — use metav1.Conditions for tooling compatibility
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
// +kubebuilder:validation:Optional
DeploymentName string `json:"deploymentName,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.readyReplicas
//+kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
//+kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type AppDeployment struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec AppDeploymentSpec `json:"spec,omitempty"`
Status AppDeploymentStatus `json:"status,omitempty"`
}
Controller Reconciliation Loop
// internal/controller/appdeployment_controller.go
package controller
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
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"
apiv1alpha1 "github.com/myorg/myoperator/api/v1alpha1"
)
const (
finalizerName = "appdeployments.myorg.io/finalizer"
conditionReady = "Ready"
conditionAvailable = "Available"
)
type AppDeploymentReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// RBAC markers — generate with `make manifests`
//+kubebuilder:rbac:groups=api.myorg.io,resources=appdeployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=api.myorg.io,resources=appdeployments/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=api.myorg.io,resources=appdeployments/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
func (r *AppDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := ctrl.LoggerFrom(ctx)
// Fetch the AppDeployment instance
app := &apiv1alpha1.AppDeployment{}
if err := r.Get(ctx, req.NamespacedName, app); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil // Object deleted before reconcile — ignore
}
return ctrl.Result{}, err
}
// Handle deletion: cleanup before removing finalizer
if !app.DeletionTimestamp.IsZero() {
return r.handleDeletion(ctx, app)
}
// Register finalizer if missing
if !controllerutil.ContainsFinalizer(app, finalizerName) {
controllerutil.AddFinalizer(app, finalizerName)
if err := r.Update(ctx, app); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil
}
// Reconcile the Deployment
deployment := &appsv1.Deployment{}
deploymentName := fmt.Sprintf("%s-deployment", app.Name)
op, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error {
// Set owner reference for garbage collection
if err := ctrl.SetControllerReference(app, deployment, r.Scheme); err != nil {
return err
}
r.buildDeployment(deployment, app, deploymentName)
return nil
})
if err != nil {
r.setCondition(app, conditionReady, metav1.ConditionFalse, "DeploymentFailed", err.Error())
if statusErr := r.Status().Update(ctx, app); statusErr != nil {
log.Error(statusErr, "Failed to update status")
}
return ctrl.Result{}, err
}
log.Info("Deployment reconciled", "operation", op)
// Update status from deployment
app.Status.DeploymentName = deploymentName
app.Status.ReadyReplicas = deployment.Status.ReadyReplicas
app.Status.AvailableReplicas = deployment.Status.AvailableReplicas
app.Status.ObservedGeneration = app.Generation
isReady := deployment.Status.ReadyReplicas == app.Spec.Replicas
if isReady {
r.setCondition(app, conditionReady, metav1.ConditionTrue, "AllReplicasReady", "All replicas are ready")
} else {
r.setCondition(app, conditionReady, metav1.ConditionFalse, "ReplicasNotReady",
fmt.Sprintf("%d/%d replicas ready", deployment.Status.ReadyReplicas, app.Spec.Replicas))
}
if err := r.Status().Update(ctx, app); err != nil {
return ctrl.Result{}, err
}
// Requeue to check replica status if not yet ready
if !isReady {
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
return ctrl.Result{}, nil
}
func (r *AppDeploymentReconciler) handleDeletion(ctx context.Context, app *apiv1alpha1.AppDeployment) (ctrl.Result, error) {
if controllerutil.ContainsFinalizer(app, finalizerName) {
// Run cleanup logic here (external resources, etc.)
// The owned Deployment is garbage-collected automatically via owner reference
controllerutil.RemoveFinalizer(app, finalizerName)
if err := r.Update(ctx, app); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
func (r *AppDeploymentReconciler) setCondition(app *apiv1alpha1.AppDeployment, condType string,
status metav1.ConditionStatus, reason, message string) {
meta.SetStatusCondition(&app.Status.Conditions, metav1.Condition{
Type: condType,
Status: status,
Reason: reason,
Message: message,
ObservedGeneration: app.Generation,
})
}
func (r *AppDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&apiv1alpha1.AppDeployment{}).
Owns(&appsv1.Deployment{}). // Watch owned Deployments — trigger reconcile on changes
Complete(r)
}
Admission Webhook
// api/v1alpha1/appdeployment_webhook.go
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
//+kubebuilder:webhook:path=/validate-api-myorg-io-v1alpha1-appdeployment,mutating=false,failurePolicy=fail,sideEffects=None,groups=api.myorg.io,resources=appdeployments,verbs=create;update,versions=v1alpha1,name=vappdeployment.kb.io,admissionReviewVersions=v1
var _ webhook.Validator = &AppDeployment{}
func (r *AppDeployment) ValidateCreate() (admission.Warnings, error) {
return nil, r.validateAppDeployment()
}
func (r *AppDeployment) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
oldApp := old.(*AppDeployment)
var errs field.ErrorList
// Immutable field check
if r.Spec.Port != 0 && oldApp.Spec.Port != 0 && r.Spec.Port != oldApp.Spec.Port {
errs = append(errs, field.Forbidden(
field.NewPath("spec").Child("port"),
"port is immutable after creation",
))
}
if len(errs) > 0 {
return nil, apierrors.NewInvalid(GroupVersion.WithKind("AppDeployment").GroupKind(), r.Name, errs)
}
return nil, r.validateAppDeployment()
}
func (r *AppDeployment) validateAppDeployment() error {
var errs field.ErrorList
if r.Spec.Replicas > 0 && r.Spec.Replicas > 50 {
errs = append(errs, field.Invalid(
field.NewPath("spec").Child("replicas"),
r.Spec.Replicas,
"replicas must not exceed 50",
))
}
if r.Spec.Image == "" {
errs = append(errs, field.Required(field.NewPath("spec").Child("image"), "image is required"))
}
if len(errs) > 0 {
return apierrors.NewInvalid(GroupVersion.WithKind("AppDeployment").GroupKind(), r.Name, errs)
}
return nil
}
For the Kubernetes Helm charts that deploy these operators, see the Helm charts guide for chart structure and value templating. For the GitOps workflow managing operator upgrades, the GitOps with ArgoCD guide covers ApplicationSets and sync policies. The Claude Skills 360 bundle includes Kubernetes operator skill sets covering CRD design, reconciliation loops, and webhook validation. Start with the free tier to try operator controller generation.