Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Kubebuilder — Claude Skills 360 Blog
Blog / DevOps / Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Kubebuilder
DevOps

Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Kubebuilder

Published: November 28, 2026
Read time: 10 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free