<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Kubernetes Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/category/kubernetes/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/category/kubernetes/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Sat, 09 May 2026 18:40:47 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.2</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Kubernetes Archives - Linuxcent</title>
	<link>https://linuxcent.com/category/kubernetes/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Kubernetes CRDs in Production: Finalizers, Status Conditions, and RBAC Patterns</title>
		<link>https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/</link>
					<comments>https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:17:16 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Finalizers]]></category>
		<category><![CDATA[Owner References]]></category>
		<category><![CDATA[Production Kubernetes]]></category>
		<category><![CDATA[RBAC]]></category>
		<category><![CDATA[Status Conditions]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>Ship production-ready Kubernetes CRDs: finalizer design patterns, status condition conventions, owner references, RBAC for custom resources, and common failure modes.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/">Kubernetes CRDs in Production: Finalizers, Status Conditions, and RBAC Patterns</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 10</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · </em><em><a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Finalizers</strong> block deletion until cleanup completes — they prevent orphaned external resources but cause stuck objects if the controller crashes mid-cleanup; always implement a removal timeout</li>
<li><strong>Status conditions</strong> are the standard communication channel between controller and user: use <code class="" data-line="">type</code>, <code class="" data-line="">status</code>, <code class="" data-line="">reason</code>, <code class="" data-line="">message</code>, and <code class="" data-line="">observedGeneration</code> on every condition; never invent ad-hoc status fields</li>
<li><strong>Owner references</strong> wire automatic garbage collection — when the parent custom resource is deleted, Kubernetes deletes owned child objects; use them for every object your controller creates in the same namespace</li>
<li><strong>RBAC for CRDs in multi-tenant clusters</strong> must include separate ClusterRoles for controller, editor, and viewer; grant <code class="" data-line="">status</code> and <code class="" data-line="">finalizers</code> as separate sub-resources; never give application teams cluster-scoped create/delete on CRDs</li>
<li>The three most common Kubernetes CRD production failure modes: finalizer death loop, status thrash, and CRD deletion cascade — all avoidable with the patterns in this episode</li>
<li>Running <code class="" data-line="">kubectl get crds</code> on a healthy cluster should show <code class="" data-line="">Established: True</code> for every CRD; non-Established CRDs silently reject all create requests</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  PRODUCTION CRD LIFECYCLE: FULL PICTURE

  Create         Reconcile        Suspend/Resume      Delete
  ──────         ─────────        ──────────────      ──────
  User applies   Controller       User patches         User deletes
  BackupPolicy   creates CronJob, spec.suspended=true  BackupPolicy
      │          sets status          │                    │
      ▼              │                ▼                    ▼
  Admission      │           Controller          Finalizer blocks
  webhook        │           suspends CronJob     deletion
  (if any)       │                               Controller:
      │          │                                 1. Delete CronJob
      ▼          ▼                                 2. Remove external state
  Schema       Status                              3. Remove finalizer
  validation   conditions                          Object deleted from etcd
      │        updated
      ▼
  Controller
  reconcile
  triggered
</code></pre>
<p>Kubernetes CRD production readiness is not just about making the happy path work — it is about designing for the failure modes: controllers crashing mid-operation, deletion races, and status messages that confuse operators at 2am.</p>
<hr />
<h2 id="finalizers-controlled-deletion">Finalizers: Controlled Deletion</h2>
<p>A finalizer is a string in <code class="" data-line="">metadata.finalizers</code>. Kubernetes will not delete an object that has finalizers, regardless of who issues the delete command.</p>
<pre><code class="" data-line="">metadata:
  name: nightly
  namespace: demo
  finalizers:
    - storage.example.com/backup-cleanup  # ← your controller put this here
</code></pre>
<p>When <code class="" data-line="">kubectl delete bp nightly</code> runs:</p>
<pre><code class="" data-line="">  1. API server sets metadata.deletionTimestamp  (does NOT delete yet)
  2. Object is visible as &quot;Terminating&quot;
  3. Controller sees deletionTimestamp set
  4. Controller runs cleanup:
       - delete backup data from S3
       - delete CronJob (or let owner references handle it)
       - release any external locks
  5. Controller removes the finalizer:
       patch bp nightly --type=json \
         -p &#039;[{&quot;op&quot;:&quot;remove&quot;,&quot;path&quot;:&quot;/metadata/finalizers/0&quot;}]&#039;
  6. API server sees finalizers list is now empty → deletes the object
</code></pre>
<h3 id="adding-a-finalizer-in-go">Adding a finalizer in Go</h3>
<pre><code class="" data-line="">const finalizerName = &quot;storage.example.com/backup-cleanup&quot;

func (r *BackupPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    bp := &amp;storagev1alpha1.BackupPolicy{}
    if err := r.Get(ctx, req.NamespacedName, bp); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Deletion path
    if !bp.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(bp, finalizerName) {
            if err := r.cleanupExternalResources(ctx, bp); err != nil {
                return ctrl.Result{}, err
            }
            controllerutil.RemoveFinalizer(bp, finalizerName)
            if err := r.Update(ctx, bp); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // Normal path: ensure finalizer is present
    if !controllerutil.ContainsFinalizer(bp, finalizerName) {
        controllerutil.AddFinalizer(bp, finalizerName)
        if err := r.Update(ctx, bp); err != nil {
            return ctrl.Result{}, err
        }
    }

    // ... rest of reconcile
}
</code></pre>
<h3 id="finalizer-death-loop-and-the-timeout-pattern">Finalizer death loop and the timeout pattern</h3>
<p>If <code class="" data-line="">cleanupExternalResources</code> always returns an error (external system down, bug in cleanup code), the object gets stuck in <code class="" data-line="">Terminating</code> forever. The operator cannot delete it; <code class="" data-line="">kubectl delete --force</code> does not help with finalizers.</p>
<p>Prevention: add a cleanup deadline with status tracking.</p>
<pre><code class="" data-line="">func (r *BackupPolicyReconciler) cleanupExternalResources(ctx context.Context, bp *storagev1alpha1.BackupPolicy) error {
    // Check if we&#039;ve been trying to clean up for too long
    if bp.DeletionTimestamp != nil {
        deadline := bp.DeletionTimestamp.Add(10 * time.Minute)
        if time.Now().After(deadline) {
            // Log the failure, abandon cleanup, let the object be deleted.
            log.FromContext(ctx).Error(nil, &quot;cleanup deadline exceeded, removing finalizer anyway&quot;,
                &quot;name&quot;, bp.Name)
            return nil   // returning nil removes the finalizer
        }
    }
    // ... actual cleanup
}
</code></pre>
<p>Recovery for a stuck object (use only when cleanup truly cannot succeed):</p>
<pre><code class="" data-line="">kubectl patch bp nightly -n demo --type=json \
  -p &#039;[{&quot;op&quot;:&quot;remove&quot;,&quot;path&quot;:&quot;/metadata/finalizers&quot;}]&#039;
</code></pre>
<hr />
<h2 id="status-conditions-the-right-way">Status Conditions: The Right Way</h2>
<p>The Kubernetes standard condition format is defined in <code class="" data-line="">k8s.io/apimachinery/pkg/apis/meta/v1.Condition</code>:</p>
<pre><code class="" data-line="">type Condition struct {
    Type               string          // e.g. &quot;Ready&quot;, &quot;Synced&quot;, &quot;Degraded&quot;
    Status             ConditionStatus // &quot;True&quot;, &quot;False&quot;, &quot;Unknown&quot;
    ObservedGeneration int64           // the .metadata.generation this condition reflects
    LastTransitionTime metav1.Time     // when Status last changed
    Reason             string          // machine-readable, CamelCase, e.g. &quot;CronJobCreated&quot;
    Message            string          // human-readable, may contain details
}
</code></pre>
<h3 id="standard-condition-types">Standard condition types</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">Ready</code></td>
<td>The resource is fully reconciled and operational</td>
</tr>
<tr>
<td><code class="" data-line="">Synced</code></td>
<td>The resource has been synced with an external system</td>
</tr>
<tr>
<td><code class="" data-line="">Progressing</code></td>
<td>An operation is actively in progress</td>
</tr>
<tr>
<td><code class="" data-line="">Degraded</code></td>
<td>The resource is operating in a reduced capacity</td>
</tr>
</tbody>
</table>
<p>Use <code class="" data-line="">Ready: True</code> only when the full reconcile is complete and the resource is functional. Use <code class="" data-line="">Ready: False</code> with a clear <code class="" data-line="">Message</code> when reconcile fails or is blocked.</p>
<h3 id="setting-conditions-in-go">Setting conditions in Go</h3>
<pre><code class="" data-line="">meta.SetStatusCondition(&amp;bpCopy.Status.Conditions, metav1.Condition{
    Type:               &quot;Ready&quot;,
    Status:             metav1.ConditionFalse,
    ObservedGeneration: bp.Generation,
    Reason:             &quot;CronJobCreateFailed&quot;,
    Message:            fmt.Sprintf(&quot;failed to create CronJob: %v&quot;, err),
})
</code></pre>
<p><code class="" data-line="">meta.SetStatusCondition</code> handles deduplication — it updates an existing condition of the same <code class="" data-line="">Type</code> rather than appending a duplicate.</p>
<h3 id="observedgeneration-is-critical"><code class="" data-line="">observedGeneration</code> is critical</h3>
<pre><code class="" data-line="">metadata.generation      = 5   (increments on every spec change)
status.observedGeneration = 3  (set by controller on each reconcile)

If observedGeneration &lt; generation:
  → controller has not yet reconciled the latest spec change
  → status.conditions reflect an older state
  → do NOT alert based on conditions that lag generation
</code></pre>
<p>Always set <code class="" data-line="">ObservedGeneration: bp.Generation</code> when writing status conditions. Tooling (Argo CD, Flux, kubectl wait) depends on this to know whether status is current.</p>
<h3 id="kubectl-wait-uses-conditions"><code class="" data-line="">kubectl wait</code> uses conditions</h3>
<pre><code class="" data-line=""># Wait until BackupPolicy is Ready
kubectl wait bp/nightly -n demo \
  --for=condition=Ready \
  --timeout=60s
</code></pre>
<p>This works because <code class="" data-line="">kubectl wait</code> reads the <code class="" data-line="">status.conditions</code> array.</p>
<hr />
<h2 id="owner-references-automatic-garbage-collection">Owner References: Automatic Garbage Collection</h2>
<p>Owner references wire a parent-child relationship between Kubernetes objects. When the parent is deleted, Kubernetes garbage-collects all owned children automatically.</p>
<pre><code class="" data-line="">metadata:
  name: nightly-backup       # CronJob
  ownerReferences:
    - apiVersion: storage.example.com/v1alpha1
      kind: BackupPolicy
      name: nightly
      uid: a1b2c3d4-...
      controller: true          # only one owner can be the controller
      blockOwnerDeletion: true  # the GC waits for this owner before deleting child
</code></pre>
<p>Set in Go using <code class="" data-line="">ctrl.SetControllerReference</code>:</p>
<pre><code class="" data-line="">if err := ctrl.SetControllerReference(bp, cronJob, r.Scheme); err != nil {
    return ctrl.Result{}, err
}
</code></pre>
<h3 id="owner-reference-rules">Owner reference rules</h3>
<ul>
<li>Owner and owned object must be in the <strong>same namespace</strong> — cluster-scoped objects cannot own namespaced objects</li>
<li>Only one object can be the <code class="" data-line="">controller: true</code> owner; others can be non-controller owners</li>
<li>Deleting the owner cascades to deleting owned objects — this is garbage collection, not finalizer-based cleanup</li>
</ul>
<p>Without owner references, deleting a BackupPolicy leaves the CronJob as an orphan. This is hard to detect and accumulates over time.</p>
<hr />
<h2 id="rbac-patterns-for-multi-tenant-crd-usage">RBAC Patterns for Multi-Tenant CRD Usage</h2>
<p>A production CRD deployment needs three distinct RBAC roles:</p>
<pre><code class="" data-line=""># 1. Controller role — full access for the operator
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-controller
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;update&quot;, &quot;patch&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/status&quot;]
    verbs: [&quot;get&quot;, &quot;update&quot;, &quot;patch&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/finalizers&quot;]
    verbs: [&quot;update&quot;]
  - apiGroups: [&quot;batch&quot;]
    resources: [&quot;cronjobs&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
---
# 2. Editor role — for application teams (namespaced binding)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-editor
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
  # No status write — only the controller writes status
  # No finalizers write — prevents deletion blocking by non-controllers
---
# 3. Viewer role — for audit, monitoring
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-viewer
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;]
</code></pre>
<p>Bind editor/viewer roles at <strong>namespace</strong> scope, not cluster scope:</p>
<pre><code class="" data-line="">apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: team-alpha-backup-editor
  namespace: team-alpha
subjects:
  - kind: Group
    name: team-alpha
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: backuppolicy-editor
  apiGroup: rbac.authorization.k8s.io
</code></pre>
<p>This pattern gives team-alpha full control over BackupPolicies in their namespace but no access to other namespaces — standard Kubernetes multi-tenancy.</p>
<hr />
<h2 id="the-three-production-failure-modes">The Three Production Failure Modes</h2>
<h3 id="1-finalizer-death-loop">1. Finalizer death loop</h3>
<p><strong>Symptoms:</strong> Object stuck in <code class="" data-line="">Terminating</code> for hours; <code class="" data-line="">kubectl get bp nightly</code> shows <code class="" data-line="">DeletionTimestamp</code> set but object exists.</p>
<p><strong>Cause:</strong> <code class="" data-line="">cleanupExternalResources</code> always returns an error.</p>
<p><strong>Detection:</strong></p>
<pre><code class="" data-line="">kubectl get bp nightly -n demo -o jsonpath=&#039;{.metadata.deletionTimestamp}&#039;
# non-empty = stuck in termination
kubectl describe bp nightly -n demo
# look for repeated reconcile error events
</code></pre>
<p><strong>Fix:</strong> Add cleanup deadline in controller; use <code class="" data-line="">kubectl patch</code> to remove finalizer as last resort.</p>
<h3 id="2-status-thrash">2. Status thrash</h3>
<p><strong>Symptoms:</strong> Controller sets <code class="" data-line="">Ready: True</code>, then <code class="" data-line="">Ready: False</code>, then <code class="" data-line="">Ready: True</code> in a rapid loop. Alert noise, confusing dashboards.</p>
<p><strong>Cause:</strong> Each reconcile compares actual state incorrectly due to cache lag — it sees its own status write as a change, re-reconciles, and flips the status again.</p>
<p><strong>Fix:</strong> Set <code class="" data-line="">ObservedGeneration</code> on every condition. Compare <code class="" data-line="">generation</code> with <code class="" data-line="">observedGeneration</code> before re-reconciling. Use <code class="" data-line="">meta.IsStatusConditionTrue</code> to check current condition before overwriting it with the same value.</p>
<pre><code class="" data-line="">// Only update status if it changed
current := meta.FindStatusCondition(bp.Status.Conditions, &quot;Ready&quot;)
if current == nil || current.Status != desired.Status || current.Reason != desired.Reason {
    meta.SetStatusCondition(&amp;bpCopy.Status.Conditions, desired)
    r.Status().Update(ctx, bpCopy)
}
</code></pre>
<h3 id="3-crd-deletion-cascade">3. CRD deletion cascade</h3>
<p><strong>Symptoms:</strong> A team deletes a CRD for cleanup purposes; all instances across all namespaces disappear silently.</p>
<p><strong>Cause:</strong> <code class="" data-line="">kubectl delete crd backuppolicies.storage.example.com</code> — the API server cascades the deletion to all custom resources of that type.</p>
<p><strong>Prevention:</strong><br />
&#8211; Add a <code class="" data-line="">resourcelock</code> annotation on production CRDs managed by your operator<br />
&#8211; Use GitOps (Argo CD, Flux) to manage CRD installation — a deleted CRD is automatically re-applied from the Git source<br />
&#8211; Back up CRDs and instances with <code class="" data-line="">velero</code> or equivalent before any CRD management operations</p>
<hr />
<h2 id="production-readiness-checklist">Production Readiness Checklist</h2>
<pre><code class="" data-line="">CRD DEFINITION
  □ spec.versions has exactly one storage: true version
  □ Status subresource enabled (subresources.status: {})
  □ additionalPrinterColumns includes Ready column from status.conditions
  □ OpenAPI schema defines required fields and types
  □ CEL rules cover cross-field constraints

CONTROLLER
  □ Owner references set on all child resources
  □ Finalizer logic includes cleanup deadline
  □ Status conditions use standard format with observedGeneration
  □ Reconcile function is idempotent
  □ Not-found errors handled cleanly (return nil, not error)
  □ At least 2 replicas with leader election enabled

RBAC
  □ Three ClusterRoles: controller, editor, viewer
  □ Status and finalizers are separate RBAC sub-resources
  □ Editor/viewer bound at namespace scope, not cluster scope
  □ Controller ServiceAccount has only necessary permissions

OPERATIONS
  □ CRD installed via GitOps or Helm (not manual kubectl apply)
  □ Backup of CRDs and instances included in cluster backup
  □ kubectl get crds shows Established: True for all CRDs
  □ Monitoring for stuck Terminating objects (finalizer deadlock)
  □ Alert on controller reconcile error rate, not just pod health
</code></pre>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Granting <code class="" data-line="">update</code> on <code class="" data-line="">backuppolicies</code> but not <code class="" data-line="">backuppolicies/status</code> to the controller.</strong> If the controller cannot write status, status updates silently fail. The controller appears to run but status conditions never update. Grant both <code class="" data-line="">backuppolicies</code> (for spec/metadata writes) and <code class="" data-line="">backuppolicies/status</code> (for the status subresource path).</p>
<p><strong>Setting <code class="" data-line="">Ready: True</code> before all owned resources are healthy.</strong> If the controller sets <code class="" data-line="">Ready: True</code> after creating the CronJob but before verifying the CronJob is actually <code class="" data-line="">active</code>, users see a false-positive health signal. Only set <code class="" data-line="">Ready: True</code> when you have confirmed the desired state is actually achieved.</p>
<p><strong>Not setting <code class="" data-line="">observedGeneration</code> on status conditions.</strong> Tools like Argo CD and <code class="" data-line="">kubectl wait --for=condition=Ready</code> will report incorrect health status if <code class="" data-line="">observedGeneration</code> is stale. Always set <code class="" data-line="">ObservedGeneration: obj.Generation</code> in every condition write.</p>
<p><strong>Using <code class="" data-line="">kubectl delete crd</code> in a production cluster without a backup.</strong> This is irreversible. Treat CRDs as production-critical infrastructure — require GitOps review, backup verification, and team approval before any CRD deletion.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># Check for stuck Terminating objects
kubectl get backuppolicies -A --field-selector metadata.deletionTimestamp!=&#039;&#039;

# Force-remove a stuck finalizer (use only when cleanup is truly impossible)
kubectl patch bp nightly -n demo --type=json \
  -p &#039;[{&quot;op&quot;:&quot;remove&quot;,&quot;path&quot;:&quot;/metadata/finalizers/0&quot;}]&#039;

# Check all CRDs are Established
kubectl get crds -o jsonpath=&#039;{range .items[*]}{.metadata.name} {.status.conditions[?(@.type==&quot;Established&quot;)].status}{&quot;\n&quot;}{end}&#039;

# Watch status conditions update during reconcile
kubectl get bp nightly -n demo -w -o \
  jsonpath=&#039;{.status.conditions[?(@.type==&quot;Ready&quot;)].status} {.status.conditions[?(@.type==&quot;Ready&quot;)].message}{&quot;\n&quot;}&#039;

# Verify owner references are set on child CronJob
kubectl get cronjob nightly-backup -n demo \
  -o jsonpath=&#039;{.metadata.ownerReferences}&#039;

# List all objects owned by a BackupPolicy (by label)
kubectl get all -n demo -l backuppolicy=nightly
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Finalizers block deletion until cleanup completes — always implement a cleanup deadline to prevent permanent stuck objects</li>
<li>Status conditions must use the standard format with <code class="" data-line="">observedGeneration</code> — tooling depends on it for correctness</li>
<li>Owner references enable automatic garbage collection of child resources when the parent is deleted</li>
<li>RBAC needs three roles (controller, editor, viewer) with <code class="" data-line="">status</code> and <code class="" data-line="">finalizers</code> as separate sub-resources</li>
<li>The three production failure modes — finalizer death loop, status thrash, CRD deletion cascade — are all preventable with the patterns covered in this episode</li>
</ul>
<hr />
<h2 id="series-complete">Series Complete</h2>
<p>You now have the full picture of Kubernetes CRDs and Operators: from understanding what a CRD is (<a href="/what-is-kubernetes-crd/">EP01</a>), through real examples (<a href="/kubernetes-custom-resources-examples/">EP02</a>), schema design (<a href="/kubernetes-crd-schema-explained/">EP03</a>), hands-on YAML (<a href="/write-kubernetes-crd-yaml-walkthrough/">EP04</a>), CEL validation (<a href="/kubernetes-crd-cel-validation/">EP05</a>), the controller loop (<a href="/kubernetes-controller-reconcile-loop/">EP06</a>), building an operator (<a href="/build-kubernetes-operator-controller-runtime/">EP07</a>), versioning (<a href="/kubernetes-crd-versioning-conversion-webhook/">EP08</a>), admission webhooks (<a href="/kubernetes-admission-webhooks-explained/">EP09</a>), to production patterns in this episode.</p>
<p>The next series in the Kubernetes learning arc on linuxcent.com covers <strong>Kubernetes Networking Deep Dive</strong> — Services, Ingress, Gateway API, CNI, and eBPF networking. Subscribe below to get it when it launches.</p>
<p>Stay subscribed → <a href="https://linuxcent.com">linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&amp;linkname=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-production-finalizers-conditions-rbac%2F&#038;title=Kubernetes%20CRDs%20in%20Production%3A%20Finalizers%2C%20Status%20Conditions%2C%20and%20RBAC%20Patterns" data-a2a-url="https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/" data-a2a-title="Kubernetes CRDs in Production: Finalizers, Status Conditions, and RBAC Patterns"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/">Kubernetes CRDs in Production: Finalizers, Status Conditions, and RBAC Patterns</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-crd-production-finalizers-conditions-rbac/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1702</post-id>	</item>
		<item>
		<title>Admission Webhooks: Validating and Mutating Requests Before They Reach etcd</title>
		<link>https://linuxcent.com/kubernetes-admission-webhooks-explained/</link>
					<comments>https://linuxcent.com/kubernetes-admission-webhooks-explained/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:17:00 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Admission Webhooks]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Kyverno]]></category>
		<category><![CDATA[MutatingWebhookConfiguration]]></category>
		<category><![CDATA[OPA]]></category>
		<category><![CDATA[ValidatingWebhookConfiguration]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-admission-webhooks-explained/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Understand Kubernetes admission webhooks — validating vs mutating, when to use each, how they intercept API requests before etcd, and how they pair with CRDs.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-admission-webhooks-explained/">Admission Webhooks: Validating and Mutating Requests Before They Reach etcd</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 9</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · </em><em><a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a></em><em> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Kubernetes admission webhooks are HTTPS endpoints called by the API server synchronously on every create/update/delete — before the object reaches etcd<br />
  <em>(two types: mutating webhooks modify the object; validating webhooks approve or reject it — mutating runs first, then validating)</em></li>
<li>Use a <strong>validating webhook</strong> when you need to reject objects based on state you cannot express in CEL: checking if a referenced Secret exists, enforcing cross-resource quotas, consulting an external policy engine</li>
<li>Use a <strong>mutating webhook</strong> when you need to inject defaults or sidecar containers that depend on context you cannot express in the CRD schema (environment-specific defaults, sidecar injection)</li>
<li>Admission webhooks are an <strong>availability dependency</strong> — if your webhook is unreachable, the API requests it covers will fail. <code class="" data-line="">failurePolicy: Ignore</code> is the safety valve; use it only for non-critical webhooks</li>
<li>OPA/Gatekeeper and Kyverno are admission webhook platforms — they let you write policy as code (Rego, YAML) instead of writing Go webhook handlers</li>
<li>For CRD-specific validation that only depends on the object itself, prefer CEL (EP05) — webhooks are for rules that require external lookups or cross-resource checks</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  KUBERNETES ADMISSION CHAIN (full picture)

  kubectl apply -f backuppolicy.yaml
        │
        ▼
  API Server: authentication + authorization
        │
        ▼
  1. Mutating admission webhooks
     ┌───────────────────────────────────────┐
     │ Receive object, return modified object │
     │ Examples: inject annotations,          │
     │ set defaults, add sidecars            │
     └───────────────────────────────────────┘
        │
        ▼
  2. Schema validation (OpenAPI + CEL)
        │
        ▼
  3. Validating admission webhooks
     ┌───────────────────────────────────────┐
     │ Receive object, return allow/deny     │
     │ Examples: quota checks, cross-        │
     │ resource validation, policy engines   │
     └───────────────────────────────────────┘
        │
        ▼ (allowed)
  etcd storage
</code></pre>
<p>Kubernetes admission webhooks are how tools like Istio inject sidecars, Kyverno enforces policies, and OPA/Gatekeeper applies organizational guardrails — all without modifying Kubernetes source code. Understanding them completes the picture of how Kubernetes is extended beyond CRDs.</p>
<hr />
<h2 id="validating-vs-mutating-when-to-use-each">Validating vs Mutating: When to Use Each</h2>
<pre><code class="" data-line="">  DECISION TREE: CEL vs Validating Webhook vs Mutating Webhook

  &quot;I need to validate a field value&quot;
      │
      ├── Depends only on the object being submitted?
      │   → Use CEL (x-kubernetes-validations) — EP05
      │
      └── Needs to look up another resource, quota, or external system?
          → Use Validating Admission Webhook

  &quot;I need to set default values or inject content&quot;
      │
      ├── Defaults depend only on other fields in the same object?
      │   → Use OpenAPI schema defaults or CEL
      │
      └── Defaults depend on environment, namespace labels, or external config?
          → Use Mutating Admission Webhook
</code></pre>
<p>Practical examples:</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Right tool</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">retentionDays</code> must be ≤ 365</td>
<td>CEL</td>
</tr>
<tr>
<td><code class="" data-line="">if storageClass=premium then retentionDays ≤ 90</code></td>
<td>CEL</td>
</tr>
<tr>
<td>Referenced <code class="" data-line="">SecretStore</code> must exist in the same namespace</td>
<td>Validating webhook</td>
</tr>
<tr>
<td><code class="" data-line="">BackupPolicy</code> count per namespace must not exceed team quota</td>
<td>Validating webhook</td>
</tr>
<tr>
<td>Inject <code class="" data-line="">costCenter</code> annotation from namespace labels</td>
<td>Mutating webhook</td>
</tr>
<tr>
<td>Inject backup-agent sidecar into all Pods in labeled namespaces</td>
<td>Mutating webhook</td>
</tr>
<tr>
<td>Enforce that all BackupPolicies have a <code class="" data-line="">team</code> label</td>
<td>Kyverno or OPA policy</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="the-webhook-requestresponse-contract">The Webhook Request/Response Contract</h2>
<p>Both webhook types receive an <code class="" data-line="">AdmissionReview</code> object and return an <code class="" data-line="">AdmissionReview</code> response.</p>
<p>Request (from API server to webhook):</p>
<pre><code class="" data-line="">{
  &quot;apiVersion&quot;: &quot;admission.k8s.io/v1&quot;,
  &quot;kind&quot;: &quot;AdmissionReview&quot;,
  &quot;request&quot;: {
    &quot;uid&quot;: &quot;705ab4f5-6393-11e8-b7cc-42010a800002&quot;,
    &quot;kind&quot;: {&quot;group&quot;: &quot;storage.example.com&quot;, &quot;version&quot;: &quot;v1alpha1&quot;, &quot;kind&quot;: &quot;BackupPolicy&quot;},
    &quot;resource&quot;: {&quot;group&quot;: &quot;storage.example.com&quot;, &quot;version&quot;: &quot;v1alpha1&quot;, &quot;resource&quot;: &quot;backuppolicies&quot;},
    &quot;operation&quot;: &quot;CREATE&quot;,
    &quot;userInfo&quot;: {&quot;username&quot;: &quot;alice&quot;, &quot;groups&quot;: [&quot;system:authenticated&quot;]},
    &quot;object&quot;: { /* full BackupPolicy JSON */ },
    &quot;oldObject&quot;: null
  }
}
</code></pre>
<p>Response for a validating webhook (allow):</p>
<pre><code class="" data-line="">{
  &quot;apiVersion&quot;: &quot;admission.k8s.io/v1&quot;,
  &quot;kind&quot;: &quot;AdmissionReview&quot;,
  &quot;response&quot;: {
    &quot;uid&quot;: &quot;705ab4f5-6393-11e8-b7cc-42010a800002&quot;,
    &quot;allowed&quot;: true
  }
}
</code></pre>
<p>Response for a validating webhook (deny):</p>
<pre><code class="" data-line="">{
  &quot;response&quot;: {
    &quot;uid&quot;: &quot;...&quot;,
    &quot;allowed&quot;: false,
    &quot;status&quot;: {
      &quot;code&quot;: 422,
      &quot;message&quot;: &quot;referenced SecretStore &#039;aws-secrets-manager&#039; not found in namespace &#039;production&#039;&quot;
    }
  }
}
</code></pre>
<p>Response for a mutating webhook (allow + patch):</p>
<pre><code class="" data-line="">{
  &quot;response&quot;: {
    &quot;uid&quot;: &quot;...&quot;,
    &quot;allowed&quot;: true,
    &quot;patchType&quot;: &quot;JSONPatch&quot;,
    &quot;patch&quot;: &quot;W3sib3AiOiJhZGQiLCJwYXRoIjoiL21ldGFkYXRhL2Fubm90YXRpb25zL2Nvc3RDZW50ZXIiLCJ2YWx1ZSI6ImVuZ2luZWVyaW5nIn1d&quot;
    // base64-encoded JSON patch:
    // [{&quot;op&quot;:&quot;add&quot;,&quot;path&quot;:&quot;/metadata/annotations/costCenter&quot;,&quot;value&quot;:&quot;engineering&quot;}]
  }
}
</code></pre>
<hr />
<h2 id="writing-a-validating-webhook-with-kubebuilder">Writing a Validating Webhook with kubebuilder</h2>
<pre><code class="" data-line="">kubebuilder create webhook \
  --group storage \
  --version v1alpha1 \
  --kind BackupPolicy \
  --programmatic-validation
</code></pre>
<p>Edit <code class="" data-line="">api/v1alpha1/backuppolicy_webhook.go</code>:</p>
<pre><code class="" data-line="">package v1alpha1

import (
    &quot;context&quot;
    &quot;fmt&quot;

    apierrors &quot;k8s.io/apimachinery/pkg/api/errors&quot;
    &quot;k8s.io/apimachinery/pkg/runtime&quot;
    &quot;k8s.io/apimachinery/pkg/types&quot;
    ctrl &quot;sigs.k8s.io/controller-runtime&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/client&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/webhook/admission&quot;
    esov1beta1 &quot;github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1&quot;
)

type BackupPolicyCustomValidator struct {
    Client client.Client
}

//+kubebuilder:webhook:path=/validate-storage-example-com-v1alpha1-backuppolicy,mutating=false,failurePolicy=fail,sideEffects=None,groups=storage.example.com,resources=backuppolicies,verbs=create;update,versions=v1alpha1,name=vbackuppolicy.kb.io,admissionReviewVersions=v1

func (v *BackupPolicyCustomValidator) SetupWebhookWithManager(mgr ctrl.Manager) error {
    v.Client = mgr.GetClient()
    return ctrl.NewWebhookManagedBy(mgr).
        For(&amp;BackupPolicy{}).
        WithValidator(v).
        Complete()
}

// ValidateCreate validates a new BackupPolicy.
func (v *BackupPolicyCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
    bp := obj.(*BackupPolicy)
    return nil, v.validateSecretStoreRef(ctx, bp)
}

// ValidateUpdate validates an updated BackupPolicy.
func (v *BackupPolicyCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
    bp := newObj.(*BackupPolicy)
    return nil, v.validateSecretStoreRef(ctx, bp)
}

// ValidateDelete is a no-op here.
func (v *BackupPolicyCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
    return nil, nil
}

// validateSecretStoreRef checks that the referenced SecretStore exists in the same namespace.
func (v *BackupPolicyCustomValidator) validateSecretStoreRef(ctx context.Context, bp *BackupPolicy) error {
    ref := bp.Spec.SecretStoreRef
    if ref == &quot;&quot; {
        return nil  // optional field; CEL handles it if required
    }

    store := &amp;esov1beta1.SecretStore{}
    err := v.Client.Get(ctx, types.NamespacedName{Name: ref, Namespace: bp.Namespace}, store)
    if apierrors.IsNotFound(err) {
        return fmt.Errorf(&quot;referenced SecretStore %q not found in namespace %q&quot;,
            ref, bp.Namespace)
    }
    return err  // nil on found, real error on API failure
}
</code></pre>
<hr />
<h2 id="writing-a-mutating-webhook-cost-center-injection">Writing a Mutating Webhook: Cost Center Injection</h2>
<pre><code class="" data-line="">kubebuilder create webhook \
  --group storage \
  --version v1alpha1 \
  --kind BackupPolicy \
  --defaulting
</code></pre>
<p>Edit the defaulting webhook:</p>
<pre><code class="" data-line="">//+kubebuilder:webhook:path=/mutate-storage-example-com-v1alpha1-backuppolicy,mutating=true,failurePolicy=fail,sideEffects=None,groups=storage.example.com,resources=backuppolicies,verbs=create,versions=v1alpha1,name=mbackuppolicy.kb.io,admissionReviewVersions=v1

func (r *BackupPolicy) Default() {
    // Default is called by kubebuilder&#039;s webhook framework on admission.
    // The webhook handler calls this and patches the object.
    //
    // This runs AFTER API server schema defaults — use it for context-dependent defaults.
}

// For namespace-label-based injection, implement the full webhook handler instead:
type BackupPolicyMutator struct {
    Client client.Client
}

func (m *BackupPolicyMutator) Handle(ctx context.Context, req admission.Request) admission.Response {
    bp := &amp;BackupPolicy{}
    if err := json.Unmarshal(req.Object.Raw, bp); err != nil {
        return admission.Errored(http.StatusBadRequest, err)
    }

    // Fetch the namespace to read its labels
    ns := &amp;corev1.Namespace{}
    if err := m.Client.Get(ctx, types.NamespacedName{Name: bp.Namespace}, ns); err != nil {
        return admission.Errored(http.StatusInternalServerError, err)
    }

    // Inject costCenter annotation from namespace label
    if costCenter, ok := ns.Labels[&quot;billing/cost-center&quot;]; ok {
        if bp.Annotations == nil {
            bp.Annotations = make(map[string]string)
        }
        bp.Annotations[&quot;billing/cost-center&quot;] = costCenter
    }

    marshaled, err := json.Marshal(bp)
    if err != nil {
        return admission.Errored(http.StatusInternalServerError, err)
    }
    return admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
}
</code></pre>
<hr />
<h2 id="the-webhookconfiguration-resource">The WebhookConfiguration Resource</h2>
<p>The <code class="" data-line="">ValidatingWebhookConfiguration</code> tells the API server which webhooks exist and which resources/operations they handle:</p>
<pre><code class="" data-line="">apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: backup-operator-validating-webhook
  annotations:
    cert-manager.io/inject-ca-from: backup-operator-system/backup-operator-serving-cert
webhooks:
  - name: vbackuppolicy.kb.io
    admissionReviewVersions: [&quot;v1&quot;]
    clientConfig:
      service:
        name: backup-operator-webhook-service
        namespace: backup-operator-system
        path: /validate-storage-example-com-v1alpha1-backuppolicy
    rules:
      - apiGroups:   [&quot;storage.example.com&quot;]
        apiVersions: [&quot;v1alpha1&quot;]
        operations:  [&quot;CREATE&quot;, &quot;UPDATE&quot;]
        resources:   [&quot;backuppolicies&quot;]
    failurePolicy: Fail          # Fail = reject request if webhook unreachable
    sideEffects: None
    timeoutSeconds: 10
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: [&quot;kube-system&quot;]  # never webhook kube-system objects
</code></pre>
<hr />
<h2 id="failurepolicy-fail-vs-ignore"><code class="" data-line="">failurePolicy</code>: Fail vs Ignore</h2>
<pre><code class="" data-line="">  failurePolicy: Fail (default)
  ──────────────────────────────
  If webhook is unreachable → API request fails with 500
  Use when: the validation is critical (quota enforcement, policy)
  Risk: your webhook becoming unavailable breaks all covered API operations

  failurePolicy: Ignore
  ──────────────────────────────
  If webhook is unreachable → API request proceeds as if webhook allowed it
  Use when: the webhook is advisory or can be bypassed safely
  Risk: policy is silently not enforced during webhook outage
</code></pre>
<p>For production operators, use <code class="" data-line="">failurePolicy: Fail</code> but ensure high availability:<br />
&#8211; Run at least 2 webhook pod replicas with <code class="" data-line="">PodDisruptionBudget</code><br />
&#8211; Use cert-manager for automatic TLS certificate rotation<br />
&#8211; Set <code class="" data-line="">timeoutSeconds</code> to a value that allows graceful degradation (5–10s)<br />
&#8211; Exclude system namespaces with <code class="" data-line="">namespaceSelector</code></p>
<hr />
<h2 id="opagatekeeper-and-kyverno-webhooks-as-policy-platforms">OPA/Gatekeeper and Kyverno: Webhooks as Policy Platforms</h2>
<p>Writing raw webhook handlers in Go is powerful but heavyweight for policy enforcement. OPA/Gatekeeper and Kyverno are webhook-based policy engines that let you express policies as code:</p>
<p><strong>Kyverno</strong> (YAML-based policies):</p>
<pre><code class="" data-line="">apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-backup-label
spec:
  rules:
    - name: require-team-label
      match:
        any:
          - resources:
              kinds: [&quot;BackupPolicy&quot;]
      validate:
        message: &quot;BackupPolicy must have a &#039;team&#039; label&quot;
        pattern:
          metadata:
            labels:
              team: &quot;?*&quot;
</code></pre>
<p><strong>OPA/Gatekeeper</strong> (Rego-based policies):</p>
<pre><code class="" data-line="">package backuppolicy

deny[msg] {
    input.request.kind.kind == &quot;BackupPolicy&quot;
    not input.request.object.metadata.labels[&quot;team&quot;]
    msg := &quot;BackupPolicy must have a &#039;team&#039; label&quot;
}
</code></pre>
<p>Both run as admission webhooks that the API server calls. The policy language sits on top of the webhook plumbing. For organizational policy enforcement across many resource types, these tools outperform custom Go webhook handlers.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Webhook covering <code class="" data-line="">*</code> resources or <code class="" data-line="">*</code> operations.</strong> A webhook covering all resources in the cluster is a reliability risk — a bug in the webhook or an outage breaks everything. Scope webhooks to exactly the resources and operations they need with <code class="" data-line="">rules[].resources</code> and <code class="" data-line="">rules[].operations</code>.</p>
<p><strong>No TLS certificate rotation.</strong> Webhook endpoints require a TLS certificate that the API server trusts. Certificates expire. Using cert-manager with the <code class="" data-line="">cert-manager.io/inject-ca-from</code> annotation automates this. Without it, expired certificates cause silent webhook outages (the API server rejects the TLS handshake, triggering <code class="" data-line="">failurePolicy</code> behavior).</p>
<p><strong>Not excluding system namespaces.</strong> If a validating webhook covers Pods and has <code class="" data-line="">failurePolicy: Fail</code>, and the webhook pod itself crashes, the API server cannot create a new webhook pod because the webhook rejects the creation. Use <code class="" data-line="">namespaceSelector</code> to exclude <code class="" data-line="">kube-system</code> and your operator&#8217;s own namespace.</p>
<p><strong>Treating webhook latency as free.</strong> Every API operation covered by a webhook adds a synchronous HTTP round-trip. On a busy cluster creating thousands of objects per minute, a 100ms webhook latency becomes significant. Set <code class="" data-line="">timeoutSeconds</code>, profile webhook performance, and scope rules narrowly.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># List all webhook configurations
kubectl get validatingwebhookconfigurations
kubectl get mutatingwebhookconfigurations

# Inspect webhook rules and failure policy
kubectl describe validatingwebhookconfiguration backup-operator-validating-webhook

# Temporarily disable a webhook for debugging (dangerous in production)
kubectl delete validatingwebhookconfiguration backup-operator-validating-webhook

# Check webhook endpoint certificate
kubectl get secret backup-operator-webhook-server-cert \
  -n backup-operator-system \
  -o jsonpath=&#039;{.data.tls\.crt}&#039; | base64 -d | openssl x509 -noout -dates

# Test webhook is reachable from a cluster node
kubectl run webhook-test --image=curlimages/curl --rm -it --restart=Never -- \
  curl -k https://backup-operator-webhook-service.backup-operator-system.svc:443/healthz
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Mutating webhooks modify objects at admission; validating webhooks approve or reject them — mutating runs before validating</li>
<li>Use CEL for rules that depend only on the submitted object; use webhooks when you need external lookups or cross-resource checks</li>
<li><code class="" data-line="">failurePolicy: Fail</code> blocks API requests if the webhook is unreachable — ensure high availability before using it</li>
<li>Always exclude system namespaces and scope rules to specific resource types to minimize the blast radius of webhook failures</li>
<li>OPA/Gatekeeper and Kyverno are admission webhook platforms for policy-as-code — prefer them over custom Go handlers for organizational policy enforcement</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-crd-production-finalizers-conditions-rbac/">EP10: Kubernetes CRDs in Production</a> ties the full series together — finalizer design patterns, status condition conventions, owner references, RBAC for multi-tenant CRD usage, and the production failure modes that catch teams off guard.</p>
<p>Get EP10 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&amp;linkname=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-admission-webhooks-explained%2F&#038;title=Admission%20Webhooks%3A%20Validating%20and%20Mutating%20Requests%20Before%20They%20Reach%20etcd" data-a2a-url="https://linuxcent.com/kubernetes-admission-webhooks-explained/" data-a2a-title="Admission Webhooks: Validating and Mutating Requests Before They Reach etcd"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-admission-webhooks-explained/">Admission Webhooks: Validating and Mutating Requests Before They Reach etcd</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-admission-webhooks-explained/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1699</post-id>	</item>
		<item>
		<title>Kubernetes CRD Versioning: From v1alpha1 to v1 Without Breaking Clients</title>
		<link>https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/</link>
					<comments>https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:16:44 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[API Evolution]]></category>
		<category><![CDATA[Conversion Webhook]]></category>
		<category><![CDATA[CRD Versioning]]></category>
		<category><![CDATA[Operator]]></category>
		<category><![CDATA[Storage Version]]></category>
		<category><![CDATA[v1alpha1]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Evolve Kubernetes CRDs safely through v1alpha1, v1beta1, and v1 — storage versions, conversion webhooks, and the hub-and-spoke model explained for production clusters.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/">Kubernetes CRD Versioning: From v1alpha1 to v1 Without Breaking Clients</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 8</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · </em><em><a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a></em><em> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Kubernetes CRD versioning lets you evolve your API from <code class="" data-line="">v1alpha1</code> to <code class="" data-line="">v1</code> without deleting existing custom resources or breaking clients still using the old version<br />
  <em>(storage version = the version etcd actually stores objects in; served versions = the versions the API server responds to; you can serve v1alpha1 and v1 simultaneously while migrating)</em></li>
<li>The <strong>hub-and-spoke model</strong> is the recommended conversion architecture: one &#8220;hub&#8221; version (usually <code class="" data-line="">v1</code>) that every other version converts to/from</li>
<li>Without a conversion webhook, the API server only allows one served version at a time — you must use a webhook to serve multiple versions with schema differences</li>
<li><code class="" data-line="">kubectl storage-version-migrator</code> (or manual re-apply) migrates existing objects from the old storage version to the new one after you update <code class="" data-line="">storage: true</code></li>
<li>Changing field names between versions without a conversion webhook corrupts data silently — always test conversion round-trips before promoting a version</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  CRD VERSION LIFECYCLE

  Stage 1: Alpha                 Stage 2: Beta              Stage 3: Stable
  ──────────────────             ──────────────             ──────────────
  v1alpha1                       v1alpha1 (deprecated)      v1alpha1 (removed)
    served: true                   served: true               served: false
    storage: true                  storage: false             storage: false
                                 v1beta1                    v1beta1 (deprecated)
                                   served: true               served: true
                                   storage: false             storage: false
                                 v1                         v1
                                   served: true               served: true
                                   storage: true              storage: true

  Clients using v1alpha1:         The API server converts     Eventually remove
  still work via conversion       on the fly                  old served versions
  webhook
</code></pre>
<p>Kubernetes CRD versioning is what allows you to ship <code class="" data-line="">BackupPolicy v1alpha1</code> today, learn from real usage, evolve the schema to <code class="" data-line="">v1</code> with renamed fields and new constraints, and keep existing clusters running without a migration window.</p>
<hr />
<h2 id="why-versioning-is-necessary">Why Versioning Is Necessary</h2>
<p>When <code class="" data-line="">BackupPolicy v1alpha1</code> shipped, the spec used <code class="" data-line="">retentionDays</code>. After six months of production use, the team learns:</p>
<ul>
<li><code class="" data-line="">retentionDays</code> should be renamed to <code class="" data-line="">retention.days</code> (nested under a <code class="" data-line="">retention</code> object for future extensibility)</li>
<li>A new required field <code class="" data-line="">backupFormat</code> needs to be added with a default of <code class="" data-line="">tar.gz</code></li>
<li>The <code class="" data-line="">targets</code> field should be renamed to <code class="" data-line="">includedNamespaces</code></li>
</ul>
<p>These are breaking changes. Clients (GitOps repos, Helm charts, other operators) still have YAML referencing <code class="" data-line="">v1alpha1</code> with the old field names. You cannot simply rename the fields.</p>
<p>The solution: add <code class="" data-line="">v1</code> with the new schema, run both versions simultaneously via a conversion webhook, migrate objects to the new storage version, then deprecate <code class="" data-line="">v1alpha1</code>.</p>
<hr />
<h2 id="simple-case-non-breaking-addition-no-webhook-needed">Simple Case: Non-Breaking Addition (No Webhook Needed)</h2>
<p>If you only add new optional fields to the schema — no renames, no removals — you can add a new version without a conversion webhook, as long as only one version is served at a time.</p>
<pre><code class="" data-line="">versions:
  - name: v1alpha1
    served: false      # stop serving old version
    storage: false
    schema: ...
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        properties:
          spec:
            properties:
              schedule:
                type: string
              retentionDays:
                type: integer
              backupFormat:          # new optional field
                type: string
                default: &quot;tar.gz&quot;
</code></pre>
<p>Existing objects stored as <code class="" data-line="">v1alpha1</code> are served as <code class="" data-line="">v1</code> with the new field defaulted. This works for purely additive changes because the stored bytes are compatible with the new schema.</p>
<p><strong>When this is not enough:</strong> field renames, type changes, field removal, or structural reorganization all require a conversion webhook.</p>
<hr />
<h2 id="the-hub-and-spoke-model">The Hub-and-Spoke Model</h2>
<p>For breaking schema changes, the API server needs a conversion webhook. The recommended architecture is hub-and-spoke:</p>
<pre><code class="" data-line="">  HUB-AND-SPOKE CONVERSION

       v1alpha1
          │
          ▼ convert to hub
         v1  (hub)
          ▲
          │ convert to hub
       v1beta1

  Every version converts TO the hub and FROM the hub.
  The hub is always the storage version.
  Two-version conversion: v1alpha1 → v1 → v1beta1
  Never directly: v1alpha1 → v1beta1
</code></pre>
<p>This means you only write N conversion functions (one per version) rather than N² (one per version pair). As you add versions, the conversion complexity grows linearly.</p>
<hr />
<h2 id="writing-a-conversion-webhook">Writing a Conversion Webhook</h2>
<p>The conversion webhook is an HTTPS endpoint that the API server calls when it needs to convert an object between versions.</p>
<h3 id="1-define-the-conversion-hub">1. Define the conversion hub</h3>
<p>In the kubebuilder project, mark <code class="" data-line="">v1</code> as the hub:</p>
<p>In <code class="" data-line="">api/v1/backuppolicy_conversion.go</code>:</p>
<pre><code class="" data-line="">package v1

// Hub marks this type as the conversion hub.
func (*BackupPolicy) Hub() {}
</code></pre>
<h3 id="2-implement-conversion-in-v1alpha1">2. Implement conversion in v1alpha1</h3>
<p>In <code class="" data-line="">api/v1alpha1/backuppolicy_conversion.go</code>:</p>
<pre><code class="" data-line="">package v1alpha1

import (
    &quot;fmt&quot;
    v1 &quot;github.com/example/backup-operator/api/v1&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/conversion&quot;
)

// ConvertTo converts v1alpha1 BackupPolicy to v1 (the hub).
func (src *BackupPolicy) ConvertTo(dstRaw conversion.Hub) error {
    dst := dstRaw.(*v1.BackupPolicy)

    // Metadata
    dst.ObjectMeta = src.ObjectMeta

    // Field mapping: v1alpha1 → v1
    dst.Spec.Schedule      = src.Spec.Schedule
    dst.Spec.BackupFormat  = &quot;tar.gz&quot;           // new field: default for old objects
    dst.Spec.StorageClass  = src.Spec.StorageClass
    dst.Spec.Suspended     = src.Spec.Suspended

    // Renamed field: retentionDays → retention.days
    dst.Spec.Retention = v1.RetentionSpec{
        Days: src.Spec.RetentionDays,
    }

    // Renamed field: targets → includedNamespaces
    for _, t := range src.Spec.Targets {
        dst.Spec.IncludedNamespaces = append(dst.Spec.IncludedNamespaces,
            v1.NamespaceTarget{
                Namespace:      t.Namespace,
                IncludeSecrets: t.IncludeSecrets,
            })
    }

    dst.Status = v1.BackupPolicyStatus(src.Status)
    return nil
}

// ConvertFrom converts v1 (hub) BackupPolicy back to v1alpha1.
func (dst *BackupPolicy) ConvertFrom(srcRaw conversion.Hub) error {
    src := srcRaw.(*v1.BackupPolicy)

    dst.ObjectMeta = src.ObjectMeta

    dst.Spec.Schedule      = src.Spec.Schedule
    dst.Spec.StorageClass  = src.Spec.StorageClass
    dst.Spec.Suspended     = src.Spec.Suspended
    dst.Spec.RetentionDays = src.Spec.Retention.Days

    for _, n := range src.Spec.IncludedNamespaces {
        dst.Spec.Targets = append(dst.Spec.Targets, BackupTarget{
            Namespace:      n.Namespace,
            IncludeSecrets: n.IncludeSecrets,
        })
    }

    // backupFormat cannot be round-tripped to v1alpha1 (no such field)
    // Store it in an annotation to preserve the value if the object is
    // re-converted back to v1.
    if src.Spec.BackupFormat != &quot;&quot; &amp;&amp; src.Spec.BackupFormat != &quot;tar.gz&quot; {
        if dst.Annotations == nil {
            dst.Annotations = make(map[string]string)
        }
        dst.Annotations[&quot;storage.example.com/backup-format&quot;] = src.Spec.BackupFormat
    }

    dst.Status = BackupPolicyStatus(src.Status)
    return nil
}
</code></pre>
<h3 id="3-register-the-webhook">3. Register the webhook</h3>
<pre><code class="" data-line="">kubebuilder create webhook \
  --group storage \
  --version v1alpha1 \
  --kind BackupPolicy \
  --conversion
</code></pre>
<p>This generates the webhook server setup. Deploy with a TLS certificate (cert-manager can manage this automatically via the kubebuilder <code class="" data-line="">//+kubebuilder:webhook:...</code> marker).</p>
<hr />
<h2 id="updating-the-crd-to-reference-the-webhook">Updating the CRD to Reference the Webhook</h2>
<pre><code class="" data-line="">spec:
  conversion:
    strategy: Webhook
    webhook:
      clientConfig:
        service:
          name: backup-operator-webhook-service
          namespace: backup-operator-system
          path: /convert
      conversionReviewVersions: [&quot;v1&quot;, &quot;v1beta1&quot;]
  versions:
    - name: v1alpha1
      served: true
      storage: false
      schema: ...
    - name: v1
      served: true
      storage: true
      schema: ...
</code></pre>
<p>Once applied, <code class="" data-line="">kubectl get backuppolicies.v1alpha1.storage.example.com/nightly</code> and <code class="" data-line="">kubectl get backuppolicies.v1.storage.example.com/nightly</code> both work — the API server converts transparently.</p>
<hr />
<h2 id="migrating-existing-objects-to-the-new-storage-version">Migrating Existing Objects to the New Storage Version</h2>
<p>After changing <code class="" data-line="">storage: true</code> from <code class="" data-line="">v1alpha1</code> to <code class="" data-line="">v1</code>, existing objects in etcd are still stored as <code class="" data-line="">v1alpha1</code> bytes. They are served correctly (via conversion) but are not yet migrated.</p>
<p>Migrate them:</p>
<pre><code class="" data-line=""># Option 1: Manual re-apply (works for small object counts)
kubectl get backuppolicies -A -o name | while read name; do
  kubectl apply -f &lt;(kubectl get $name -o yaml)
done

# Option 2: Storage Version Migrator (automated, for large clusters)
# Install: https://github.com/kubernetes-sigs/kube-storage-version-migrator
kubectl apply -f storageVersionMigration.yaml
</code></pre>
<p>After migration, all objects in etcd are stored as <code class="" data-line="">v1</code>. You can then set <code class="" data-line="">v1alpha1 served: false</code> to stop serving the old version.</p>
<hr />
<h2 id="storage-version-migration-checklist">Storage Version Migration Checklist</h2>
<pre><code class="" data-line="">  SAFE VERSION PROMOTION CHECKLIST

  □ New version (v1) has served: true, storage: true
  □ Old version (v1alpha1) has served: true, storage: false
  □ Conversion webhook deployed and healthy
  □ Round-trip conversion tested (v1alpha1 → v1 → v1alpha1 preserves all data)
  □ kubectl get backuppolicies works at both versions
  □ Existing objects migrated (re-applied or migration job run)
  □ Old version set to served: false (stop serving)
  □ Old version removed from CRD after N release cycles
</code></pre>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Changing the storage version without a conversion webhook.</strong> If you flip <code class="" data-line="">storage: true</code> from <code class="" data-line="">v1alpha1</code> to <code class="" data-line="">v1</code> while still serving <code class="" data-line="">v1alpha1</code>, the API server tries to read stored <code class="" data-line="">v1alpha1</code> bytes as <code class="" data-line="">v1</code> and fails. Always deploy the conversion webhook before changing the storage version.</p>
<p><strong>Lossy conversion.</strong> If <code class="" data-line="">ConvertFrom</code> (v1 → v1alpha1) drops a field that exists in v1, objects are silently corrupted when a v1alpha1 client reads and re-saves them. Round-trip test every conversion: <code class="" data-line="">original → hub → original</code> must produce identical objects (or use annotations to preserve fields that cannot round-trip).</p>
<p><strong>Forgetting to migrate existing objects.</strong> After changing the storage version, existing objects are still stored in the old format. They convert on read, but etcd still holds old bytes. Until migrated, your etcd backup/restore story is broken — restoring from backup would restore old-format bytes that need conversion.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># Check which version is currently the storage version
kubectl get crd backuppolicies.storage.example.com \
  -o jsonpath=&#039;{.status.storedVersions}&#039;
# output: [&quot;v1alpha1&quot;]  or  [&quot;v1alpha1&quot;,&quot;v1&quot;]  or  [&quot;v1&quot;]

# Verify conversion webhook is reachable
kubectl get crd backuppolicies.storage.example.com \
  -o jsonpath=&#039;{.spec.conversion.webhook.clientConfig}&#039;

# Read an object at a specific version
kubectl get backuppolicies.v1alpha1.storage.example.com/nightly -n demo -o yaml
kubectl get backuppolicies.v1.storage.example.com/nightly -n demo -o yaml

# Check CRD conditions (NamesAccepted, Established)
kubectl describe crd backuppolicies.storage.example.com | grep -A5 Conditions
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>CRD versioning lets you evolve the schema without a migration window — old and new versions coexist via a conversion webhook</li>
<li>The hub-and-spoke model minimizes conversion code: N functions, not N² — the hub version is always the storage version</li>
<li>Never change the storage version without a deployed conversion webhook for breaking schema changes</li>
<li>Conversion must be lossless — fields that cannot round-trip should be preserved in annotations</li>
<li>Migrate existing objects to the new storage version after promoting it, then deprecate the old served version</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-admission-webhooks-explained/">EP09: Admission Webhooks</a> completes the Kubernetes extension picture — validating and mutating webhooks that intercept API requests before they reach etcd, when to use them alongside CRDs, and how they differ from CEL validation.</p>
<p>Get EP09 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&amp;linkname=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-versioning-conversion-webhook%2F&#038;title=Kubernetes%20CRD%20Versioning%3A%20From%20v1alpha1%20to%20v1%20Without%20Breaking%20Clients" data-a2a-url="https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/" data-a2a-title="Kubernetes CRD Versioning: From v1alpha1 to v1 Without Breaking Clients"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/">Kubernetes CRD Versioning: From v1alpha1 to v1 Without Breaking Clients</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-crd-versioning-conversion-webhook/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1696</post-id>	</item>
		<item>
		<title>Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</title>
		<link>https://linuxcent.com/build-kubernetes-operator-controller-runtime/</link>
					<comments>https://linuxcent.com/build-kubernetes-operator-controller-runtime/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:16:26 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[controller-runtime]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[kubebuilder]]></category>
		<category><![CDATA[Operator]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<guid isPermaLink="false">https://linuxcent.com/build-kubernetes-operator-controller-runtime/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Build a real Kubernetes operator with controller-runtime and kubebuilder — scaffold, reconciler code, RBAC markers, and testing on a local cluster.</p>
<p>The post <a href="https://linuxcent.com/build-kubernetes-operator-controller-runtime/">Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 7</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · </em><em><a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a></em><em> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Building a Kubernetes operator means writing a Go reconciler with <code class="" data-line="">controller-runtime</code> — kubebuilder scaffolds the project structure, RBAC markers, and Makefile targets so you focus on the reconcile logic<br />
  <em>(kubebuilder = a CLI and framework that generates the operator project scaffold; controller-runtime = the Go library that provides the informer cache, work queue, and reconciler interface)</em></li>
<li>The reconciler for <code class="" data-line="">BackupPolicy</code> in this episode creates and manages a <code class="" data-line="">CronJob</code> — it is the behavior layer for the CRD built in EP03–EP05</li>
<li>RBAC is expressed as Go code comments (<code class="" data-line="">//+kubebuilder:rbac:...</code>) — kubebuilder generates the ClusterRole YAML from them</li>
<li>Run the operator locally with <code class="" data-line="">make run</code> during development; no cluster deployment needed until ready</li>
<li>The same project that builds the operator also builds and installs the CRD — <code class="" data-line="">make install</code> applies the CRD YAML generated from your Go types</li>
<li>Testing: the operator ships with <code class="" data-line="">envtest</code> — a local API server + etcd for controller testing without a real cluster</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  OPERATOR PROJECT STRUCTURE (kubebuilder scaffold)

  backup-operator/
  ├── api/v1alpha1/
  │   ├── backuppolicy_types.go     ← Go types that define CRD schema
  │   └── groupversion_info.go
  ├── internal/controller/
  │   └── backuppolicy_controller.go ← reconcile logic (our main focus)
  ├── config/
  │   ├── crd/                       ← generated CRD YAML
  │   ├── rbac/                      ← generated RBAC YAML
  │   └── manager/                   ← controller Deployment YAML
  ├── cmd/main.go                    ← entrypoint, sets up the manager
  └── Makefile                       ← build, test, install, deploy targets

  FLOW:
  Go types → kubebuilder generate → CRD YAML + RBAC YAML
  Reconcile function → runs in cluster → watches BackupPolicy → manages CronJobs
</code></pre>
<p>Building a Kubernetes operator with <code class="" data-line="">controller-runtime</code> is where CRDs become living infrastructure — the <code class="" data-line="">BackupPolicy</code> objects created in <a href="/write-kubernetes-crd-yaml-walkthrough/">EP04</a> now get actual behavior attached to them.</p>
<hr />
<h2 id="prerequisites">Prerequisites</h2>
<pre><code class="" data-line=""># Go 1.22+
go version

# kubebuilder CLI
curl -L -o kubebuilder \
  https://github.com/kubernetes-sigs/kubebuilder/releases/latest/download/kubebuilder_linux_amd64
chmod +x kubebuilder
sudo mv kubebuilder /usr/local/bin/

# A running cluster (kind works well for development)
kind create cluster --name operator-dev

# Verify kubectl works
kubectl cluster-info --context kind-operator-dev
</code></pre>
<hr />
<h2 id="step-1-scaffold-the-project">Step 1: Scaffold the Project</h2>
<pre><code class="" data-line="">mkdir backup-operator &amp;&amp; cd backup-operator

# Initialize the Go module and project structure
kubebuilder init \
  --domain storage.example.com \
  --repo github.com/example/backup-operator

# Create the API (Go types + controller scaffold)
kubebuilder create api \
  --group storage \
  --version v1alpha1 \
  --kind BackupPolicy \
  --resource \
  --controller
</code></pre>
<p>When prompted:</p>
<pre><code class="" data-line="">Create Resource [y/n]: y
Create Controller [y/n]: y
</code></pre>
<p>The generated directory tree:</p>
<pre><code class="" data-line="">backup-operator/
├── api/
│   └── v1alpha1/
│       ├── backuppolicy_types.go
│       └── groupversion_info.go
├── internal/
│   └── controller/
│       └── backuppolicy_controller.go
├── cmd/
│   └── main.go
├── config/
│   ├── crd/bases/
│   ├── rbac/
│   └── manager/
├── go.mod
├── go.sum
└── Makefile
</code></pre>
<hr />
<h2 id="step-2-define-the-go-types">Step 2: Define the Go Types</h2>
<p>Edit <code class="" data-line="">api/v1alpha1/backuppolicy_types.go</code> to match the schema from <a href="/kubernetes-crd-schema-explained/">EP03</a>:</p>
<pre><code class="" data-line="">package v1alpha1

import (
    metav1 &quot;k8s.io/apimachinery/pkg/apis/meta/v1&quot;
)

// BackupTarget specifies a namespace to include in the backup.
type BackupTarget struct {
    Namespace      string `json:&quot;namespace&quot;`
    IncludeSecrets bool   `json:&quot;includeSecrets,omitempty&quot;`
}

// BackupPolicySpec defines the desired state of BackupPolicy.
type BackupPolicySpec struct {
    // Schedule is a cron expression for when to run backups.
    // +kubebuilder:validation:Pattern=`^(\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+) (\*|[0-9,\-\/]+)$`
    Schedule string `json:&quot;schedule&quot;`

    // RetentionDays is how long to keep backup snapshots.
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=365
    RetentionDays int32 `json:&quot;retentionDays&quot;`

    // StorageClass is the storage class to use for backup volumes.
    // +kubebuilder:default=standard
    // +kubebuilder:validation:Enum=standard;premium;encrypted;archive
    StorageClass string `json:&quot;storageClass,omitempty&quot;`

    // Targets lists the namespaces and resources to include.
    // +kubebuilder:validation:MaxItems=20
    Targets []BackupTarget `json:&quot;targets,omitempty&quot;`

    // Suspended pauses backup execution when true.
    // +kubebuilder:default=false
    Suspended bool `json:&quot;suspended,omitempty&quot;`
}

// BackupPolicyStatus defines the observed state of BackupPolicy.
type BackupPolicyStatus struct {
    // Conditions reflect the current state of the BackupPolicy.
    Conditions []metav1.Condition `json:&quot;conditions,omitempty&quot;`

    // LastBackupTime is when the most recent backup completed.
    LastBackupTime *metav1.Time `json:&quot;lastBackupTime,omitempty&quot;`

    // CronJobName is the name of the managed CronJob.
    CronJobName string `json:&quot;cronJobName,omitempty&quot;`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name=&quot;Schedule&quot;,type=string,JSONPath=`.spec.schedule`
// +kubebuilder:printcolumn:name=&quot;Retention&quot;,type=integer,JSONPath=`.spec.retentionDays`
// +kubebuilder:printcolumn:name=&quot;Suspended&quot;,type=boolean,JSONPath=`.spec.suspended`
// +kubebuilder:printcolumn:name=&quot;Ready&quot;,type=string,JSONPath=`.status.conditions[?(@.type==&#039;Ready&#039;)].status`
// +kubebuilder:printcolumn:name=&quot;Age&quot;,type=date,JSONPath=`.metadata.creationTimestamp`

// BackupPolicy is the Schema for the backuppolicies API.
type BackupPolicy struct {
    metav1.TypeMeta   `json:&quot;,inline&quot;`
    metav1.ObjectMeta `json:&quot;metadata,omitempty&quot;`

    Spec   BackupPolicySpec   `json:&quot;spec,omitempty&quot;`
    Status BackupPolicyStatus `json:&quot;status,omitempty&quot;`
}

// +kubebuilder:object:root=true

// BackupPolicyList contains a list of BackupPolicy.
type BackupPolicyList struct {
    metav1.TypeMeta `json:&quot;,inline&quot;`
    metav1.ListMeta `json:&quot;metadata,omitempty&quot;`
    Items           []BackupPolicy `json:&quot;items&quot;`
}

func init() {
    SchemeBuilder.Register(&amp;BackupPolicy{}, &amp;BackupPolicyList{})
}
</code></pre>
<p>Regenerate the CRD YAML and DeepCopy methods:</p>
<pre><code class="" data-line="">make generate   # regenerates zz_generated.deepcopy.go
make manifests  # regenerates CRD YAML under config/crd/bases/
</code></pre>
<hr />
<h2 id="step-3-write-the-reconciler">Step 3: Write the Reconciler</h2>
<p>Edit <code class="" data-line="">internal/controller/backuppolicy_controller.go</code>:</p>
<pre><code class="" data-line="">package controller

import (
    &quot;context&quot;
    &quot;fmt&quot;

    batchv1 &quot;k8s.io/api/batch/v1&quot;
    corev1 &quot;k8s.io/api/core/v1&quot;
    apierrors &quot;k8s.io/apimachinery/pkg/api/errors&quot;
    &quot;k8s.io/apimachinery/pkg/api/meta&quot;
    metav1 &quot;k8s.io/apimachinery/pkg/apis/meta/v1&quot;
    &quot;k8s.io/apimachinery/pkg/runtime&quot;
    &quot;k8s.io/apimachinery/pkg/types&quot;
    ctrl &quot;sigs.k8s.io/controller-runtime&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/client&quot;
    &quot;sigs.k8s.io/controller-runtime/pkg/log&quot;

    storagev1alpha1 &quot;github.com/example/backup-operator/api/v1alpha1&quot;
)

// BackupPolicyReconciler reconciles BackupPolicy objects.
type BackupPolicyReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// RBAC markers — kubebuilder generates ClusterRole YAML from these comments.
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/finalizers,verbs=update
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete

func (r *BackupPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // Step 1: Fetch the BackupPolicy
    bp := &amp;storagev1alpha1.BackupPolicy{}
    if err := r.Get(ctx, req.NamespacedName, bp); err != nil {
        if apierrors.IsNotFound(err) {
            // Object deleted before we could reconcile — nothing to do.
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, fmt.Errorf(&quot;fetching BackupPolicy: %w&quot;, err)
    }

    // Step 2: Define the desired CronJob name
    cronJobName := fmt.Sprintf(&quot;%s-backup&quot;, bp.Name)

    // Step 3: Fetch the existing CronJob (if any)
    existing := &amp;batchv1.CronJob{}
    err := r.Get(ctx, types.NamespacedName{Name: cronJobName, Namespace: bp.Namespace}, existing)
    notFound := apierrors.IsNotFound(err)
    if err != nil &amp;&amp; !notFound {
        return ctrl.Result{}, fmt.Errorf(&quot;fetching CronJob: %w&quot;, err)
    }

    // Step 4: Build the desired CronJob
    desired := r.buildCronJob(bp, cronJobName)

    // Step 5: Create or update
    if notFound {
        logger.Info(&quot;Creating CronJob&quot;, &quot;name&quot;, cronJobName)
        if err := r.Create(ctx, desired); err != nil {
            return ctrl.Result{}, fmt.Errorf(&quot;creating CronJob: %w&quot;, err)
        }
    } else {
        // Update schedule and suspend state if they differ
        if existing.Spec.Schedule != desired.Spec.Schedule ||
            existing.Spec.Suspend != desired.Spec.Suspend {
            existing.Spec.Schedule = desired.Spec.Schedule
            existing.Spec.Suspend = desired.Spec.Suspend
            logger.Info(&quot;Updating CronJob&quot;, &quot;name&quot;, cronJobName)
            if err := r.Update(ctx, existing); err != nil {
                return ctrl.Result{}, fmt.Errorf(&quot;updating CronJob: %w&quot;, err)
            }
        }
    }

    // Step 6: Update status
    bpCopy := bp.DeepCopy()
    meta.SetStatusCondition(&amp;bpCopy.Status.Conditions, metav1.Condition{
        Type:               &quot;Ready&quot;,
        Status:             metav1.ConditionTrue,
        Reason:             &quot;CronJobReady&quot;,
        Message:            fmt.Sprintf(&quot;CronJob %s is configured&quot;, cronJobName),
        ObservedGeneration: bp.Generation,
    })
    bpCopy.Status.CronJobName = cronJobName

    if err := r.Status().Update(ctx, bpCopy); err != nil {
        return ctrl.Result{}, fmt.Errorf(&quot;updating status: %w&quot;, err)
    }

    return ctrl.Result{}, nil
}

func (r *BackupPolicyReconciler) buildCronJob(bp *storagev1alpha1.BackupPolicy, name string) *batchv1.CronJob {
    suspend := bp.Spec.Suspended
    retentionArg := fmt.Sprintf(&quot;--retention-days=%d&quot;, bp.Spec.RetentionDays)

    cj := &amp;batchv1.CronJob{
        ObjectMeta: metav1.ObjectMeta{
            Name:      name,
            Namespace: bp.Namespace,
            Labels: map[string]string{
                &quot;app.kubernetes.io/managed-by&quot;: &quot;backup-operator&quot;,
                &quot;backuppolicy&quot;:                 bp.Name,
            },
        },
        Spec: batchv1.CronJobSpec{
            Schedule: bp.Spec.Schedule,
            Suspend:  &amp;suspend,
            JobTemplate: batchv1.JobTemplateSpec{
                Spec: batchv1.JobSpec{
                    Template: corev1.PodTemplateSpec{
                        Spec: corev1.PodSpec{
                            RestartPolicy: corev1.RestartPolicyOnFailure,
                            Containers: []corev1.Container{
                                {
                                    Name:    &quot;backup&quot;,
                                    Image:   &quot;backup-tool:latest&quot;,
                                    Args:    []string{retentionArg},
                                },
                            },
                        },
                    },
                },
            },
        },
    }

    // Set owner reference — CronJob is garbage-collected when BackupPolicy is deleted
    _ = ctrl.SetControllerReference(bp, cj, r.Scheme)
    return cj
}

// SetupWithManager registers the controller with the manager and declares what to watch.
func (r *BackupPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&amp;storagev1alpha1.BackupPolicy{}).
        Owns(&amp;batchv1.CronJob{}).    // reconcile BackupPolicy when owned CronJob changes
        Complete(r)
}
</code></pre>
<hr />
<h2 id="step-4-install-the-crd-and-run-locally">Step 4: Install the CRD and Run Locally</h2>
<pre><code class="" data-line=""># Install the CRD into the cluster
make install
</code></pre>
<pre><code class="" data-line="">customresourcedefinition.apiextensions.k8s.io/backuppolicies.storage.example.com created
</code></pre>
<pre><code class="" data-line=""># Run the controller locally (outside the cluster)
make run
</code></pre>
<pre><code class="" data-line="">2026-04-25T08:00:00Z  INFO  Starting manager
2026-04-25T08:00:00Z  INFO  Starting workers  {&quot;controller&quot;: &quot;backuppolicy&quot;, &quot;worker count&quot;: 1}
</code></pre>
<p>In a separate terminal:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: default
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
EOF
</code></pre>
<p>Watch the controller output:</p>
<pre><code class="" data-line="">2026-04-25T08:01:00Z  INFO  Creating CronJob  {&quot;name&quot;: &quot;nightly-backup&quot;}
</code></pre>
<p>Check the result:</p>
<pre><code class="" data-line="">kubectl get bp nightly
</code></pre>
<pre><code class="" data-line="">NAME      SCHEDULE    RETENTION   SUSPENDED   READY   AGE
nightly   0 2 * * *   30          false       True    10s
</code></pre>
<pre><code class="" data-line="">kubectl get cronjob nightly-backup
</code></pre>
<pre><code class="" data-line="">NAME             SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE   AGE
nightly-backup   0 2 * * *   False     0        &lt;none&gt;          10s
</code></pre>
<p>Test self-healing — delete the CronJob and watch the controller recreate it:</p>
<pre><code class="" data-line="">kubectl delete cronjob nightly-backup
# Controller output:
# 2026-04-25T08:02:00Z  INFO  Creating CronJob  {&quot;name&quot;: &quot;nightly-backup&quot;}

kubectl get cronjob nightly-backup
# Back within seconds
</code></pre>
<p>Test suspend:</p>
<pre><code class="" data-line="">kubectl patch bp nightly --type=merge -p &#039;{&quot;spec&quot;:{&quot;suspended&quot;:true}}&#039;
kubectl get cronjob nightly-backup -o jsonpath=&#039;{.spec.suspend}&#039;
# true
</code></pre>
<hr />
<h2 id="step-5-deploy-to-cluster">Step 5: Deploy to Cluster</h2>
<p>When ready for in-cluster deployment:</p>
<pre><code class="" data-line=""># Build and push the controller image
make docker-build docker-push IMG=your-registry/backup-operator:v0.1.0

# Deploy to cluster (creates Deployment, RBAC, CRD)
make deploy IMG=your-registry/backup-operator:v0.1.0
</code></pre>
<pre><code class="" data-line="">kubectl get pods -n backup-operator-system
</code></pre>
<pre><code class="" data-line="">NAME                                          READY   STATUS    RESTARTS   AGE
backup-operator-controller-manager-abc123     2/2     Running   0          30s
</code></pre>
<hr />
<h2 id="understanding-the-rbac-markers">Understanding the RBAC Markers</h2>
<p>The <code class="" data-line="">//+kubebuilder:rbac:...</code> comments in the controller generate the ClusterRole YAML when you run <code class="" data-line="">make manifests</code>:</p>
<pre><code class="" data-line="">//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=storage.example.com,resources=backuppolicies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
</code></pre>
<p>Generated YAML under <code class="" data-line="">config/rbac/role.yaml</code>:</p>
<pre><code class="" data-line="">apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: manager-role
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/status&quot;]
    verbs: [&quot;get&quot;, &quot;update&quot;, &quot;patch&quot;]
  - apiGroups: [&quot;batch&quot;]
    resources: [&quot;cronjobs&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
</code></pre>
<p>This approach keeps RBAC co-located with the code that needs it — if you add a new resource access in the controller, you add the marker next to it.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Not setting an owner reference on child resources.</strong> Without <code class="" data-line="">ctrl.SetControllerReference(parent, child, scheme)</code>, deleting the <code class="" data-line="">BackupPolicy</code> leaves orphaned CronJobs. Owner references enable automatic garbage collection of child resources.</p>
<p><strong>Updating the object after <code class="" data-line="">r.Get()</code> without handling conflicts.</strong> If two reconciles run concurrently (possible after a controller restart), both may try to update the same resource. The API server uses resource version for optimistic concurrency — you will get a conflict error. Retry the reconcile on conflict errors rather than failing.</p>
<p><strong>Writing to <code class="" data-line="">bp</code> directly instead of <code class="" data-line="">bp.DeepCopy()</code> for status updates.</strong> If the status update fails and you retry, the original <code class="" data-line="">bp</code> object now has the modified status in memory. Always update a deep copy when writing status so the in-memory state stays consistent with what was actually persisted.</p>
<p><strong>Not watching owned resources.</strong> If you forget <code class="" data-line="">.Owns(&amp;batchv1.CronJob{})</code> in <code class="" data-line="">SetupWithManager</code>, the controller will not reconcile when a CronJob is deleted. Self-healing requires watching the resources you manage.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># Scaffold a new API + controller
kubebuilder create api --group mygroup --version v1alpha1 --kind MyKind

# Regenerate deep copy methods after changing types
make generate

# Regenerate CRD YAML + RBAC from markers
make manifests

# Install CRD into current cluster
make install

# Run controller locally (outside cluster)
make run

# Build + push image, then deploy to cluster
make docker-build docker-push IMG=registry/operator:tag
make deploy IMG=registry/operator:tag

# Uninstall CRD (WARNING: deletes all instances)
make uninstall
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>kubebuilder scaffolds the project; you write the types and the reconcile function</li>
<li>Go struct markers (<code class="" data-line="">//+kubebuilder:...</code>) generate the CRD YAML and RBAC — keep them close to the code they describe</li>
<li><code class="" data-line="">ctrl.SetControllerReference</code> enables automatic garbage collection of child resources</li>
<li>Always deep-copy the object before writing status; retry on conflict errors</li>
<li><code class="" data-line="">make run</code> runs the controller locally — no Docker build needed during development</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-crd-versioning-conversion-webhook/">EP08: Kubernetes CRD Versioning</a> covers how to evolve the <code class="" data-line="">BackupPolicy</code> schema from <code class="" data-line="">v1alpha1</code> to <code class="" data-line="">v1</code> without breaking existing clients — storage versions, conversion webhooks, and the hub-and-spoke model for safe API evolution in production clusters.</p>
<p>Get EP08 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&amp;linkname=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fbuild-kubernetes-operator-controller-runtime%2F&#038;title=Build%20a%20Simple%20Kubernetes%20Operator%20with%20controller-runtime%20and%20kubebuilder" data-a2a-url="https://linuxcent.com/build-kubernetes-operator-controller-runtime/" data-a2a-title="Build a Simple Kubernetes Operator with controller-runtime and kubebuilder"></a></p><p>The post <a href="https://linuxcent.com/build-kubernetes-operator-controller-runtime/">Build a Simple Kubernetes Operator with controller-runtime and kubebuilder</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/build-kubernetes-operator-controller-runtime/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1693</post-id>	</item>
		<item>
		<title>The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</title>
		<link>https://linuxcent.com/kubernetes-controller-reconcile-loop/</link>
					<comments>https://linuxcent.com/kubernetes-controller-reconcile-loop/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:16:12 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Controller]]></category>
		<category><![CDATA[controller-runtime]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Informer]]></category>
		<category><![CDATA[Operator Pattern]]></category>
		<category><![CDATA[Reconcile Loop]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-controller-reconcile-loop/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Understand the Kubernetes controller reconcile loop — how a controller watches CRDs, detects drift, and reconciles desired vs actual state. The theory behind every operator.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-controller-reconcile-loop/">The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 6</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · </em><em><a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a></em><em> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>The Kubernetes controller reconcile loop is the mechanism that makes CRDs do something — it watches custom resources, compares desired state (spec) to actual state, and takes actions to close the gap<br />
  <em>(reconcile = &#8220;make actual match desired&#8221;; the loop runs repeatedly because the world is not static — things drift, fail, and change)</em></li>
<li>Controllers do not receive events like webhooks — they receive object names from a work queue, then re-read the full object from the API server cache</li>
<li>The reconcile function is <strong>idempotent</strong>: calling it ten times with the same object must produce the same result as calling it once</li>
<li><code class="" data-line="">controller-runtime</code> is the Go library that provides the informer cache, work queue, and reconciler interface — kubebuilder scaffolds controllers on top of it</li>
<li>Kubernetes uses the same reconcile loop internally — the Deployment controller, ReplicaSet controller, and node lifecycle controller all follow this exact pattern</li>
<li>A failed reconcile returns an error or explicit requeue request; the controller retries with exponential backoff, not an infinite tight loop</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  THE KUBERNETES CONTROLLER RECONCILE LOOP

  etcd
   │ change event
   ▼
  Informer cache
  (API server-side list+watch,
   local in-memory replica)
   │ cache update → enqueue object name
   ▼
  Work queue
  (rate-limited, deduplicating)
   │ dequeue: &quot;demo/nightly&quot;
   ▼
  Reconcile(ctx, Request{Name, Namespace})
   │
   ├── 1. Fetch object from cache
   │        if not found → ignore (already deleted)
   │
   ├── 2. Read spec (desired state)
   │
   ├── 3. Read actual state
   │        (check child resources, external systems)
   │
   ├── 4. Compare: actual vs desired
   │
   ├── 5. Act: create/update/delete child resources
   │        OR update external system
   │
   └── 6. Update status with outcome
           └── return Result{}, nil      → done
               return Result{Requeue}, nil → retry after delay
               return Result{}, err     → immediate retry + backoff
</code></pre>
<p>The Kubernetes controller reconcile loop is what separates a CRD (validated storage) from an operator (automated behavior). Understanding this loop is the prerequisite for writing controllers that work correctly under failure, partial completion, and concurrent modification.</p>
<hr />
<h2 id="what-reconcile-actually-means">What &#8220;Reconcile&#8221; Actually Means</h2>
<p>Reconcile means: look at what the user asked for (<code class="" data-line="">spec</code>), look at what actually exists, and do whatever is needed to make actual match desired.</p>
<p>The key insight is that this is <strong>not event-driven in the traditional sense</strong>. A controller does not receive a &#8220;diff&#8221; — it receives a name. It reads the full current state of the object and acts accordingly.</p>
<p>This matters because:</p>
<ol>
<li><strong>Multiple events get deduplicated.</strong> If a BackupPolicy is updated five times in one second, the work queue delivers one reconcile call, not five.</li>
<li><strong>The reconcile is stateless.</strong> The controller should not maintain in-memory state about what it &#8220;did last time.&#8221; It re-reads everything on each reconcile.</li>
<li><strong>Partial failure is safe.</strong> If the reconcile fails halfway through, the next reconcile re-reads actual state and continues from where it left off.</li>
</ol>
<hr />
<h2 id="the-informer-cache">The Informer Cache</h2>
<p>Controllers do not call the API server directly for every read. They use an <strong>informer</strong> — a list-and-watch mechanism that maintains a local in-memory copy of all objects of a given type.</p>
<pre><code class="" data-line="">  HOW THE INFORMER CACHE WORKS

  Controller startup:
  ┌─────────────────────────────────────────────────────┐
  │ 1. List all BackupPolicies from API server          │
  │    → populate local cache                           │
  │ 2. Establish a Watch stream                         │
  │    → receive incremental updates                    │
  │ 3. For each update: update cache + enqueue object   │
  └─────────────────────────────────────────────────────┘

  On reconcile:
  ┌─────────────────────────────────────────────────────┐
  │ controller reads from LOCAL cache (not API server)  │
  │ → fast, no network round-trip per reconcile         │
  │ → cache is eventually consistent                    │
  └─────────────────────────────────────────────────────┘
</code></pre>
<blockquote>
<p><strong>Cache consistency:</strong> After writing a change (creating a child Secret, for example), re-reading from the cache may return the old state for a brief period. This is normal and expected. Well-written controllers handle this by returning a requeue rather than assuming the write is immediately visible.</p>
</blockquote>
<hr />
<h2 id="walking-through-a-reconcile-for-backuppolicy">Walking Through a Reconcile for BackupPolicy</h2>
<p>Suppose a user creates this BackupPolicy:</p>
<pre><code class="" data-line="">apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
  targets:
    - namespace: production
</code></pre>
<p>The controller&#8217;s reconcile function runs. Here is what it does conceptually:</p>
<pre><code class="" data-line="">Reconcile(ctx, {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;})

Step 1: Fetch BackupPolicy &quot;demo/nightly&quot; from cache
  → found; spec.schedule = &quot;0 2 * * *&quot;, spec.retentionDays = 30

Step 2: Check if a CronJob for this BackupPolicy exists
  → kubectl get cronjob nightly-backup -n demo
  → not found

Step 3: Gap detected: CronJob should exist but doesn&#039;t
  → Create CronJob &quot;nightly-backup&quot; in namespace &quot;demo&quot;
    spec.schedule = &quot;0 2 * * *&quot;
    spec.jobTemplate.spec.template.spec.containers[0].args = [&quot;--retention=30&quot;]

Step 4: Set owner reference on CronJob pointing to BackupPolicy
  → CronJob is now garbage-collected if BackupPolicy is deleted

Step 5: Update BackupPolicy status
  → conditions: [{type: Ready, status: True, reason: CronJobCreated}]
  → lastScheduleTime: null (not yet run)

Step 6: Return Result{}, nil   → reconcile complete
</code></pre>
<p>Next time the BackupPolicy is modified (e.g., <code class="" data-line="">suspended: true</code>):</p>
<pre><code class="" data-line="">Reconcile(ctx, {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;})

Step 1: Fetch → spec.suspended = true

Step 2: Fetch CronJob &quot;nightly-backup&quot;
  → found; spec.suspend = false  ← actual state

Step 3: Gap: CronJob.spec.suspend should be true but is false
  → Patch CronJob: set spec.suspend = true

Step 4: Update status
  → conditions: [{type: Ready, status: True, reason: Suspended}]

Step 5: Return Result{}, nil
</code></pre>
<hr />
<h2 id="idempotency-the-essential-property">Idempotency: The Essential Property</h2>
<p>The reconcile function must be idempotent. If it runs ten times with the same object state, the result must be the same as if it ran once.</p>
<p>Why? Because the controller framework delivers at-least-once semantics — your reconcile function will be called more than once for the same object state, especially at startup (the informer re-lists all objects) and after controller restarts.</p>
<p><strong>Non-idempotent (wrong):</strong></p>
<pre><code class="" data-line="">// Creates a new CronJob every time, even if one already exists
err := r.Create(ctx, cronJob)
</code></pre>
<p><strong>Idempotent (correct):</strong></p>
<pre><code class="" data-line="">// Only creates if it doesn&#039;t exist; updates if it does
existing := &amp;batchv1.CronJob{}
err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: ns}, existing)
if apierrors.IsNotFound(err) {
    err = r.Create(ctx, cronJob)
} else if err == nil {
    // update if spec differs
    existing.Spec = cronJob.Spec
    err = r.Update(ctx, existing)
}
</code></pre>
<p>The get-before-create pattern is the most basic idempotency mechanism. <code class="" data-line="">controller-runtime</code> provides <code class="" data-line="">CreateOrUpdate</code> helpers that codify this.</p>
<hr />
<h2 id="requeue-and-retry-semantics">Requeue and Retry Semantics</h2>
<p>The reconcile function returns a <code class="" data-line="">(Result, error)</code> pair:</p>
<pre><code class="" data-line="">return Result{}, nil
  → Reconcile succeeded. Re-run only if object changes again.

return Result{RequeueAfter: 5 * time.Minute}, nil
  → Reconcile succeeded, but requeue in 5 minutes regardless.
  → Used for: polling external system, TTL-based refresh.

return Result{Requeue: true}, nil
  → Requeue immediately (with rate limiting).
  → Used for: cache not yet consistent after a write.

return Result{}, err
  → Reconcile failed. Retry with exponential backoff.
  → Used for: API errors, transient failures.
</code></pre>
<pre><code class="" data-line="">  RETRY BEHAVIOR

  First failure  → retry after ~1s
  Second failure → retry after ~2s
  Third failure  → retry after ~4s
  ...
  Max backoff    → ~16min (controller-runtime default)

  Object changes (new version from informer) → reset backoff, reconcile immediately
</code></pre>
<p>Do not return <code class="" data-line="">Result{Requeue: true}, nil</code> in a tight loop — this saturates the work queue and starves other objects. If you need to poll, use <code class="" data-line="">RequeueAfter</code> with a meaningful interval.</p>
<hr />
<h2 id="watches-what-triggers-a-reconcile">Watches: What Triggers a Reconcile</h2>
<p>The controller does not only watch the primary resource (BackupPolicy). It also watches child resources and maps child changes back to the parent:</p>
<pre><code class="" data-line="">  WATCH CONFIGURATION (conceptual)

  Controller watches:
    BackupPolicy (primary) → reconcile when BackupPolicy changes
    CronJob (child/owned)  → reconcile BackupPolicy owner when CronJob changes
    ConfigMap (watched)    → reconcile BackupPolicy when referenced ConfigMap changes
</code></pre>
<p>If a user accidentally deletes the CronJob that the controller created:</p>
<ol>
<li>CronJob deletion event arrives in the informer</li>
<li>Controller maps the deleted CronJob → its owner BackupPolicy</li>
<li>BackupPolicy is enqueued</li>
<li>Reconcile runs, detects missing CronJob, recreates it</li>
</ol>
<p>This &#8220;self-healing&#8221; behavior — where controllers reconcile the world back to desired state — is the core operational value of operators. It is not magic; it is the result of watching child resources and re-running reconcile when they drift.</p>
<hr />
<h2 id="level-triggered-vs-edge-triggered">Level-Triggered vs Edge-Triggered</h2>
<p>Kubernetes controllers are <strong>level-triggered</strong>, not edge-triggered. This distinction matters:</p>
<pre><code class="" data-line="">  EDGE-TRIGGERED (not what Kubernetes uses)
  → &quot;BackupPolicy was updated FROM retained-30 TO retained-7&quot;
  → If event is lost, the update is lost forever

  LEVEL-TRIGGERED (what Kubernetes uses)
  → &quot;BackupPolicy exists with retentionDays=7&quot;
  → On every reconcile, the controller reads the current level (state)
  → Missing an event is safe — the next reconcile corrects the state
</code></pre>
<p>Level-triggered design is why controllers survive restarts, network partitions, and lost events gracefully. The reconcile does not need to track &#8220;what changed&#8221; — it only needs to know &#8220;what is the desired state right now.&#8221;</p>
<hr />
<h2 id="the-same-pattern-in-kubernetes-core">The Same Pattern in Kubernetes Core</h2>
<p>Every built-in Kubernetes controller follows this loop:</p>
<table>
<thead>
<tr>
<th>Controller</th>
<th>Watches</th>
<th>Manages</th>
<th>Reconciles</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deployment controller</td>
<td>Deployment</td>
<td>ReplicaSets</td>
<td>desired replicas <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> actual ReplicaSet count</td>
</tr>
<tr>
<td>ReplicaSet controller</td>
<td>ReplicaSet</td>
<td>Pods</td>
<td>desired replicas <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> running Pod count</td>
</tr>
<tr>
<td>Node lifecycle controller</td>
<td>Node</td>
<td>Node conditions</td>
<td>NotReady nodes → taint, evict pods</td>
</tr>
<tr>
<td>Service controller (cloud)</td>
<td>Service</td>
<td>LoadBalancer</td>
<td>cloud LB exists <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Service spec</td>
</tr>
</tbody>
</table>
<p>The <code class="" data-line="">BackupPolicy</code> controller you will build in <a href="/build-kubernetes-operator-controller-runtime/">EP07</a> follows exactly the same structure as the Deployment controller.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Reading from the API server directly instead of the cache.</strong> Every reconcile reading directly from the API server (not the informer cache) creates N×M load on the API server as the number of objects and reconcile frequency grows. Always read via the controller&#8217;s cached client.</p>
<p><strong>Not handling &#8220;not found&#8221; on object fetch.</strong> If a reconcile is triggered but the object has been deleted by the time reconcile runs, the cache returns &#8220;not found.&#8221; This is normal — the correct response is to return <code class="" data-line="">Result{}, nil</code>, not an error.</p>
<p><strong>Tight requeue loop on recoverable error.</strong> Returning <code class="" data-line="">Result{Requeue: true}, nil</code> or <code class="" data-line="">Result{}, err</code> on every call creates an infinite busy-loop. Use <code class="" data-line="">RequeueAfter</code> for expected wait conditions, and only return errors for unexpected failures that should back off.</p>
<p><strong>Mutable reconcile state.</strong> Do not store reconcile state in struct fields on the reconciler. The reconciler is shared across goroutines; mutable fields cause race conditions. Everything transient must be local to the reconcile function.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line="">Reconcile input:
  ctx context.Context
  req ctrl.Request   → {Namespace: &quot;demo&quot;, Name: &quot;nightly&quot;}

Reconcile output:
  (ctrl.Result, error)

Common returns:
  Result{}, nil                        → done, wait for next change
  Result{Requeue: true}, nil           → retry now (rate limited)
  Result{RequeueAfter: 5*time.Minute}  → retry in 5 minutes
  Result{}, err                        → retry with backoff

Key operations:
  r.Get(ctx, req.NamespacedName, &amp;obj)     → fetch from cache
  r.Create(ctx, &amp;obj)                      → create in API server
  r.Update(ctx, &amp;obj)                      → full update
  r.Patch(ctx, &amp;obj, patch)                → partial update
  r.Delete(ctx, &amp;obj)                      → delete
  r.Status().Update(ctx, &amp;obj)             → update status only
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>The reconcile loop reads desired state from <code class="" data-line="">spec</code>, reads actual state from the cluster, and closes the gap — on every trigger, not just on changes</li>
<li>Controllers use an informer cache for reads — fast, eventually consistent, does not hammer the API server</li>
<li>Idempotency is not optional: the reconcile function will be called multiple times with the same state</li>
<li>Level-triggered design means missing events is safe — the next reconcile corrects any drift</li>
<li>Return values from reconcile control retry behavior: <code class="" data-line="">RequeueAfter</code> for polling, <code class="" data-line="">err</code> for failures, <code class="" data-line="">nil</code> for success</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/build-kubernetes-operator-controller-runtime/">EP07: Build a Simple Kubernetes Operator with controller-runtime</a> puts the reconcile loop into practice — kubebuilder scaffold, a complete reconciler for <code class="" data-line="">BackupPolicy</code>, RBAC markers, and running the operator locally against a real cluster.</p>
<p>Get EP07 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&amp;linkname=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-controller-reconcile-loop%2F&#038;title=The%20Kubernetes%20Controller%20Reconcile%20Loop%3A%20How%20CRDs%20Come%20Alive%20at%20Runtime" data-a2a-url="https://linuxcent.com/kubernetes-controller-reconcile-loop/" data-a2a-title="The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-controller-reconcile-loop/">The Kubernetes Controller Reconcile Loop: How CRDs Come Alive at Runtime</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-controller-reconcile-loop/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1690</post-id>	</item>
		<item>
		<title>Kubernetes CRD CEL Validation: Replace Admission Webhooks for Schema Rules</title>
		<link>https://linuxcent.com/kubernetes-crd-cel-validation/</link>
					<comments>https://linuxcent.com/kubernetes-crd-cel-validation/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:15:55 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Admission Webhooks]]></category>
		<category><![CDATA[CEL]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Kubernetes 1.25]]></category>
		<category><![CDATA[Validation]]></category>
		<category><![CDATA[x-kubernetes-validations]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-crd-cel-validation/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Use CEL expressions in Kubernetes CRDs to validate fields without an admission webhook — cross-field rules, regex checks, and defaulting with x-kubernetes-validations.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-crd-cel-validation/">Kubernetes CRD CEL Validation: Replace Admission Webhooks for Schema Rules</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 5</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · </em><em><a href="/kubernetes-crd-cel-validation/">CEL Validation</a></em><em> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Kubernetes CRD CEL validation (<code class="" data-line="">x-kubernetes-validations</code>) lets you write arbitrary validation rules in the CRD schema — no admission webhook needed<br />
  <em>(CEL = Common Expression Language, a lightweight expression language built into Kubernetes since 1.25 stable; replaces most reasons you would write a validating admission webhook)</em></li>
<li>CEL rules are evaluated by the API server at admit time — the same place as OpenAPI schema validation, before etcd</li>
<li><code class="" data-line="">self</code> refers to the current object&#8217;s field; <code class="" data-line="">oldSelf</code> refers to the previous value (for update rules)</li>
<li>Cross-field validation: &#8220;if <code class="" data-line="">storageClass</code> is <code class="" data-line="">premium</code>, <code class="" data-line="">retentionDays</code> must be ≤ 90&#8243; — impossible with plain OpenAPI schema, trivial with CEL</li>
<li>Immutable fields: <code class="" data-line="">oldSelf == self</code> with <code class="" data-line="">reason: Immutable</code> prevents users from changing values after creation</li>
<li>CEL rules run in ~microseconds inside the API server; no external service, no TLS, no latency budget to manage</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  CEL VALIDATION: WHERE IT FITS IN THE ADMISSION CHAIN

  kubectl apply -f backup.yaml
         │
         ▼
  API Server admission chain
  ┌────────────────────────────────────────────────────┐
  │                                                    │
  │  1. Mutating admission webhooks (modify object)    │
  │  2. Schema validation (OpenAPI types, required,    │
  │     minimum/maximum, pattern)                      │
  │  3. CEL validation (x-kubernetes-validations)  ←  │ THIS EPISODE
  │  4. Validating admission webhooks (external)       │
  │                                                    │
  └────────────────────────────────────────────────────┘
         │
         ▼ (passes all checks)
  etcd storage
</code></pre>
<p>Kubernetes CRD CEL validation sits between schema validation and external webhooks. For most validation requirements, CEL eliminates the need for a webhook entirely — which means no separate deployment to maintain, no TLS certificates to rotate, no availability dependency between your CRD and a webhook server.</p>
<hr />
<h2 id="why-cel-replaces-most-admission-webhooks">Why CEL Replaces Most Admission Webhooks</h2>
<p>Before CEL (stable in Kubernetes 1.25), the only way to express &#8220;if field A has value X, field B must be present&#8221; was an admission webhook — a separate HTTP server that Kubernetes called synchronously during every API request.</p>
<p>Webhooks work, but they have real costs:</p>
<ul>
<li><strong>Availability dependency</strong>: if the webhook is down, creates/updates for that resource type fail</li>
<li><strong>TLS management</strong>: webhook endpoints require valid TLS certs that must be rotated</li>
<li><strong>Deployment overhead</strong>: another Deployment, Service, and certificate to manage</li>
<li><strong>Latency</strong>: every API operation waits for an HTTP round-trip</li>
</ul>
<p>CEL runs inside the API server process. There is no network call, no certificate, no separate deployment. Rules are compiled once and evaluated in microseconds.</p>
<p>The trade-off: CEL cannot make network calls or access state outside the object being validated. For rules that need to look up other resources (e.g., &#8220;does this referenced Secret exist?&#8221;), you still need a webhook or a controller that validates via status conditions.</p>
<hr />
<h2 id="cel-syntax-basics">CEL Syntax Basics</h2>
<p>CEL expressions are small programs. In Kubernetes CRD validation, the key variables are:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">self</code></td>
<td>The current field value (or root object at top level)</td>
</tr>
<tr>
<td><code class="" data-line="">oldSelf</code></td>
<td>The previous value of the field (only available on update; nil on create)</td>
</tr>
</tbody>
</table>
<p>CEL returns <code class="" data-line="">true</code> (validation passes) or <code class="" data-line="">false</code> (validation fails, API returns error).</p>
<p>Common patterns:</p>
<pre><code class="" data-line=""># String not empty
self.size() &gt; 0

# String matches format
self.matches(&#039;^[a-z][a-z0-9-]*$&#039;)

# Integer in range
self &gt;= 1 &amp;&amp; self &lt;= 365

# Field present (for optional fields)
has(self.fieldName)

# Conditional: if A then B
!has(self.premium) || self.retentionDays &lt;= 90

# List not empty
self.size() &gt; 0

# All items in list satisfy condition
self.all(item, item.namespace.size() &gt; 0)

# Cross-field: access sibling field via parent
self.retentionDays &gt;= self.minRetentionDays
</code></pre>
<hr />
<h2 id="adding-cel-rules-to-the-backuppolicy-crd">Adding CEL Rules to the BackupPolicy CRD</h2>
<p>Start from the CRD built in <a href="/write-kubernetes-crd-yaml-walkthrough/">EP04</a>. Add <code class="" data-line="">x-kubernetes-validations</code> at the levels where you need them.</p>
<h3 id="rule-1-cron-expression-validation">Rule 1: Cron expression validation</h3>
<p>The OpenAPI <code class="" data-line="">pattern</code> field can validate basic structure, but a proper cron regex is unwieldy. CEL is cleaner:</p>
<pre><code class="" data-line="">spec:
  type: object
  required: [&quot;schedule&quot;, &quot;retentionDays&quot;]
  x-kubernetes-validations:
    - rule: &quot;self.schedule.matches(&#039;^(\\\\*|[0-9,\\\\-\\\\/]+) (\\\\*|[0-9,\\\\-\\\\/]+) (\\\\*|[0-9,\\\\-\\\\/]+) (\\\\*|[0-9,\\\\-\\\\/]+) (\\\\*|[0-9,\\\\-\\\\/]+)$&#039;)&quot;
      message: &quot;schedule must be a valid 5-field cron expression&quot;
</code></pre>
<h3 id="rule-2-cross-field-validation">Rule 2: Cross-field validation</h3>
<pre><code class="" data-line="">spec:
  type: object
  x-kubernetes-validations:
    - rule: &quot;!(self.storageClass == &#039;premium&#039;) || self.retentionDays &lt;= 90&quot;
      message: &quot;premium storage class supports at most 90 days retention&quot;
    - rule: &quot;!self.suspended || !has(self.pausedBy) || self.pausedBy.size() &gt; 0&quot;
      message: &quot;when suspended is true, pausedBy must be non-empty if provided&quot;
</code></pre>
<h3 id="rule-3-immutable-fields">Rule 3: Immutable fields</h3>
<p>Once a <code class="" data-line="">BackupPolicy</code> is created, the <code class="" data-line="">schedule</code> field should not be changeable without deleting and recreating:</p>
<pre><code class="" data-line="">schedule:
  type: string
  x-kubernetes-validations:
    - rule: &quot;self == oldSelf&quot;
      message: &quot;schedule is immutable after creation&quot;
      reason: Immutable
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">reason</code> field:</strong> Available reasons are <code class="" data-line="">FieldValueInvalid</code> (default), <code class="" data-line="">FieldValueForbidden</code>, <code class="" data-line="">FieldValueRequired</code>, and <code class="" data-line="">Immutable</code>. Using <code class="" data-line="">Immutable</code> returns HTTP 422 with a clear message that the field cannot be changed.</p>
</blockquote>
<h3 id="rule-4-conditional-required-field">Rule 4: Conditional required field</h3>
<p>If <code class="" data-line="">storageClass</code> is <code class="" data-line="">encrypted</code>, then <code class="" data-line="">encryptionKeyRef</code> must be present:</p>
<pre><code class="" data-line="">spec:
  type: object
  x-kubernetes-validations:
    - rule: &quot;self.storageClass != &#039;encrypted&#039; || has(self.encryptionKeyRef)&quot;
      message: &quot;encryptionKeyRef is required when storageClass is &#039;encrypted&#039;&quot;
</code></pre>
<h3 id="rule-5-list-element-validation">Rule 5: List element validation</h3>
<p>Ensure each target namespace is a valid RFC 1123 DNS label:</p>
<pre><code class="" data-line="">targets:
  type: array
  items:
    type: object
    x-kubernetes-validations:
      - rule: &quot;self.namespace.matches(&#039;^[a-z0-9]([-a-z0-9]*[a-z0-9])?$&#039;)&quot;
        message: &quot;namespace must be a valid DNS label&quot;
</code></pre>
<hr />
<h2 id="the-complete-updated-crd-with-cel">The Complete Updated CRD with CEL</h2>
<pre><code class="" data-line="">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: backuppolicies.storage.example.com
spec:
  group: storage.example.com
  scope: Namespaced
  names:
    plural:     backuppolicies
    singular:   backuppolicy
    kind:       BackupPolicy
    shortNames: [bp]
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          required: [&quot;spec&quot;]
          properties:
            spec:
              type: object
              required: [&quot;schedule&quot;, &quot;retentionDays&quot;]
              x-kubernetes-validations:
                - rule: &quot;!(self.storageClass == &#039;premium&#039;) || self.retentionDays &lt;= 90&quot;
                  message: &quot;premium storage class supports at most 90 days retention&quot;
              properties:
                schedule:
                  type: string
                  x-kubernetes-validations:
                    - rule: &quot;self == oldSelf&quot;
                      message: &quot;schedule is immutable after creation&quot;
                      reason: Immutable
                retentionDays:
                  type: integer
                  minimum: 1
                  maximum: 365
                storageClass:
                  type: string
                  default: &quot;standard&quot;
                  enum: [&quot;standard&quot;, &quot;premium&quot;, &quot;encrypted&quot;, &quot;archive&quot;]
                encryptionKeyRef:
                  type: string
                targets:
                  type: array
                  maxItems: 20
                  items:
                    type: object
                    required: [&quot;namespace&quot;]
                    x-kubernetes-validations:
                      - rule: &quot;self.namespace.matches(&#039;^[a-z0-9]([-a-z0-9]*[a-z0-9])?$&#039;)&quot;
                        message: &quot;namespace must be a valid DNS label&quot;
                    properties:
                      namespace:
                        type: string
                      includeSecrets:
                        type: boolean
                        default: false
                suspended:
                  type: boolean
                  default: false
            status:
              type: object
              x-kubernetes-preserve-unknown-fields: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Schedule
          type: string
          jsonPath: .spec.schedule
        - name: Retention
          type: integer
          jsonPath: .spec.retentionDays
        - name: Ready
          type: string
          jsonPath: .status.conditions[?(@.type==&#039;Ready&#039;)].status
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
</code></pre>
<hr />
<h2 id="testing-cel-rules">Testing CEL Rules</h2>
<p>Apply the updated CRD:</p>
<pre><code class="" data-line="">kubectl apply -f backuppolicies-crd-cel.yaml
</code></pre>
<p>Test cross-field validation:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: premium-long
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 180          # violates: premium + &gt; 90 days
  storageClass: premium
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;premium-long&quot; is invalid:
  spec: Invalid value: &quot;object&quot;:
    premium storage class supports at most 90 days retention
</code></pre>
<p>Test immutability:</p>
<pre><code class="" data-line=""># Create valid policy
kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: immutable-test
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
EOF

# Try to change the schedule
kubectl patch bp immutable-test -n demo \
  --type=merge -p &#039;{&quot;spec&quot;:{&quot;schedule&quot;:&quot;0 3 * * *&quot;}}&#039;
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;immutable-test&quot; is invalid:
  spec.schedule: Invalid value: &quot;0 3 * * *&quot;:
    schedule is immutable after creation
</code></pre>
<p>Test list element validation:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: bad-namespace
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 7
  targets:
    - namespace: &quot;UPPERCASE_IS_INVALID&quot;
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;bad-namespace&quot; is invalid:
  spec.targets[0]: Invalid value: &quot;object&quot;:
    namespace must be a valid DNS label
</code></pre>
<hr />
<h2 id="cel-cost-and-limits">CEL Cost and Limits</h2>
<p>CEL expressions are evaluated at admission time in the API server. Kubernetes imposes cost limits to prevent expressions from consuming excessive CPU:</p>
<ul>
<li>Each expression is assigned a <em>cost</em> based on its operations (string matches, list iteration, etc.)</li>
<li>If the expression cost exceeds the per-validation limit, the API server rejects the CRD itself when you apply it</li>
<li>Complex <code class="" data-line="">all()</code> over large lists is the most common way to hit cost limits</li>
</ul>
<p>If you hit a cost limit error:</p>
<pre><code class="" data-line="">CustomResourceDefinition is invalid: spec.validation.openAPIV3Schema...
  CEL expression cost exceeds budget
</code></pre>
<p>Solutions:<br />
&#8211; Reduce list traversal in CEL rules; enforce list length with <code class="" data-line="">maxItems</code> instead<br />
&#8211; Split one expensive rule into multiple simpler rules<br />
&#8211; Move the expensive validation to a controller (status condition) rather than admission</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Using <code class="" data-line="">oldSelf</code> on create.</strong> On create operations, <code class="" data-line="">oldSelf</code> is nil/unset. A rule like <code class="" data-line="">self == oldSelf</code> for immutability will panic on create unless you guard it: <code class="" data-line="">oldSelf == null || self == oldSelf</code>. In practice, Kubernetes applies immutable rules only on updates (the reason: Immutable annotation helps here), but be explicit in rules that reference <code class="" data-line="">oldSelf</code>.</p>
<p><strong>Forgetting <code class="" data-line="">has()</code> checks for optional fields.</strong> If <code class="" data-line="">encryptionKeyRef</code> is optional (not in <code class="" data-line="">required</code>) and you write a rule like <code class="" data-line="">self.encryptionKeyRef.size() &gt; 0</code>, it will fail with a &#8220;no such key&#8221; error when the field is absent. Always guard optional field access with <code class="" data-line="">has(self.fieldName)</code>.</p>
<p><strong>Overloading CEL for what a controller should do.</strong> CEL validates fields at admission. If your rule needs to verify that a referenced Secret actually exists, CEL cannot do that — it only sees the object being submitted. Use a controller status condition for existence checks, not CEL.</p>
<hr />
<h2 id="quick-reference-common-cel-patterns">Quick Reference: Common CEL Patterns</h2>
<pre><code class="" data-line=""># String not empty
self.size() &gt; 0

# String matches regex
self.matches(&#039;^[a-z][a-z0-9-]{1,62}$&#039;)

# Optional field guard
!has(self.fieldName) || self.fieldName.size() &gt; 0

# Conditional requirement
!(condition) || has(self.requiredWhenConditionIsTrue)

# Immutable field (update only)
self == oldSelf

# All list items satisfy condition
self.all(item, item.namespace.size() &gt; 0)

# At least one list item satisfies condition
self.exists(item, item.type == &#039;primary&#039;)

# Cross-field comparison
self.minReplicas &lt;= self.maxReplicas

# Enum-style check
self.in([&#039;standard&#039;, &#039;premium&#039;, &#039;archive&#039;])
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><code class="" data-line="">x-kubernetes-validations</code> with CEL rules replaces most validating admission webhooks for CRD-specific logic</li>
<li>CEL runs inside the API server — no external service, no TLS, no separate deployment</li>
<li>Cross-field validation, immutable fields, and conditional requirements are all expressible in CEL</li>
<li>Use <code class="" data-line="">has()</code> guards for optional fields; use <code class="" data-line="">oldSelf</code> carefully (it is nil on create)</li>
<li>CEL has cost limits — avoid unbounded list iteration; use <code class="" data-line="">maxItems</code> to bound lists first</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-controller-reconcile-loop/">EP06: The Kubernetes Controller Reconcile Loop</a> explains how a controller watches <code class="" data-line="">BackupPolicy</code> objects and acts on them — the mechanism that makes CRDs useful beyond validated configuration storage. Before writing code in EP07, you need to understand the reconcile loop conceptually.</p>
<p>Get EP06 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&amp;linkname=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-cel-validation%2F&#038;title=Kubernetes%20CRD%20CEL%20Validation%3A%20Replace%20Admission%20Webhooks%20for%20Schema%20Rules" data-a2a-url="https://linuxcent.com/kubernetes-crd-cel-validation/" data-a2a-title="Kubernetes CRD CEL Validation: Replace Admission Webhooks for Schema Rules"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-crd-cel-validation/">Kubernetes CRD CEL Validation: Replace Admission Webhooks for Schema Rules</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-crd-cel-validation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1687</post-id>	</item>
		<item>
		<title>Write Your First Kubernetes CRD: A Hands-On YAML Walkthrough</title>
		<link>https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/</link>
					<comments>https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:15:37 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Custom Resource]]></category>
		<category><![CDATA[Hands-On Tutorial]]></category>
		<category><![CDATA[kubectl]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[YAML]]></category>
		<guid isPermaLink="false">https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Write a Kubernetes CRD from scratch — complete YAML walkthrough with schema, RBAC, kubectl apply, and custom resource operations on a real cluster.</p>
<p>The post <a href="https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/">Write Your First Kubernetes CRD: A Hands-On YAML Walkthrough</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 4</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · </em><em><a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a></em><em> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Writing a Kubernetes CRD requires five YAML files: the CRD itself, a ClusterRole/ClusterRoleBinding, a namespaced Role/RoleBinding for consumers, and a sample custom resource</li>
<li>The <code class="" data-line="">BackupPolicy</code> CRD built in this episode is the running example throughout the rest of the series — operators, versioning, and production patterns all use it</li>
<li>Apply the CRD, verify it with <code class="" data-line="">kubectl get crds</code>, create a custom resource, and watch the API server validate your spec</li>
<li>RBAC for CRDs follows the same Role/ClusterRole model as built-in resources — the generated resource name is <code class="" data-line="">{plural}.{group}</code></li>
<li>Schema validation fires at apply time: bad field types, missing required fields, and out-of-range values all return clear errors before anything reaches etcd</li>
<li>Without a controller, a <code class="" data-line="">BackupPolicy</code> is stored in etcd but nothing acts on it — that is the topic of EP05 and EP07</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  WHAT WE&#039;RE BUILDING IN THIS EPISODE

  1. backuppolicies-crd.yaml        ← registers the BackupPolicy type
  2. backuppolicies-rbac.yaml       ← controls who can create/view/delete
  3. nightly-backup.yaml            ← our first custom resource instance

  After applying:

  kubectl get crds | grep backup      ← BackupPolicy type exists
  kubectl get backuppolicies -n demo  ← nightly instance exists
  kubectl describe bp nightly -n demo ← spec visible, status empty
  kubectl apply -f bad-backup.yaml    ← schema validation rejects bad data
</code></pre>
<p>Writing your first Kubernetes CRD is the step that bridges understanding CRDs conceptually to operating them in a real cluster. This episode is hands-on — every block of YAML is something you apply and verify.</p>
<hr />
<h2 id="prerequisites">Prerequisites</h2>
<p>You need a running Kubernetes cluster and <code class="" data-line="">kubectl</code> configured. Any of these work:</p>
<pre><code class="" data-line=""># Local options
kind create cluster --name crd-demo
# or
minikube start

# Verify cluster access
kubectl cluster-info
kubectl get nodes
</code></pre>
<hr />
<h2 id="step-1-write-the-crd">Step 1: Write the CRD</h2>
<p>Save this as <code class="" data-line="">backuppolicies-crd.yaml</code>:</p>
<pre><code class="" data-line="">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: backuppolicies.storage.example.com
spec:
  group: storage.example.com
  scope: Namespaced
  names:
    plural:     backuppolicies
    singular:   backuppolicy
    kind:       BackupPolicy
    shortNames:
      - bp
    categories:
      - storage
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          required: [&quot;spec&quot;]
          properties:
            spec:
              type: object
              required: [&quot;schedule&quot;, &quot;retentionDays&quot;]
              properties:
                schedule:
                  type: string
                  description: &quot;Cron expression (e.g. &#039;0 2 * * *&#039; for 02:00 daily)&quot;
                retentionDays:
                  type: integer
                  minimum: 1
                  maximum: 365
                  description: &quot;How many days to retain backup snapshots&quot;
                storageClass:
                  type: string
                  default: &quot;standard&quot;
                  description: &quot;StorageClass to use for backup volumes&quot;
                targets:
                  type: array
                  description: &quot;Namespaces and resources to include in the backup&quot;
                  maxItems: 20
                  items:
                    type: object
                    required: [&quot;namespace&quot;]
                    properties:
                      namespace:
                        type: string
                      includeSecrets:
                        type: boolean
                        default: false
                suspended:
                  type: boolean
                  default: false
                  description: &quot;Set to true to pause backup execution&quot;
            status:
              type: object
              x-kubernetes-preserve-unknown-fields: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Schedule
          type: string
          jsonPath: .spec.schedule
        - name: Retention
          type: integer
          jsonPath: .spec.retentionDays
        - name: Suspended
          type: boolean
          jsonPath: .spec.suspended
        - name: Ready
          type: string
          jsonPath: .status.conditions[?(@.type==&#039;Ready&#039;)].status
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
</code></pre>
<p>Apply it:</p>
<pre><code class="" data-line="">kubectl apply -f backuppolicies-crd.yaml
</code></pre>
<p>Verify it registered correctly:</p>
<pre><code class="" data-line="">kubectl get crds backuppolicies.storage.example.com
</code></pre>
<pre><code class="" data-line="">NAME                                    CREATED AT
backuppolicies.storage.example.com      2026-04-25T08:00:00Z
</code></pre>
<p>Check the API server now knows about it:</p>
<pre><code class="" data-line="">kubectl api-resources | grep backuppolic
</code></pre>
<pre><code class="" data-line="">backuppolicies    bp    storage.example.com/v1alpha1    true    BackupPolicy
</code></pre>
<p>Check it is <code class="" data-line="">Established</code>:</p>
<pre><code class="" data-line="">kubectl get crd backuppolicies.storage.example.com \
  -o jsonpath=&#039;{.status.conditions[?(@.type==&quot;Established&quot;)].status}&#039;
</code></pre>
<pre><code class="" data-line="">True
</code></pre>
<p>If you see <code class="" data-line="">False</code> or empty output, wait a few seconds and retry — the API server takes a moment to register new CRDs.</p>
<hr />
<h2 id="step-2-write-rbac">Step 2: Write RBAC</h2>
<p>CRDs follow the same RBAC model as built-in resources. The resource name is <code class="" data-line="">{plural}.{group}</code>.</p>
<p>Save this as <code class="" data-line="">backuppolicies-rbac.yaml</code>:</p>
<pre><code class="" data-line=""># ClusterRole for operators/controllers that manage BackupPolicy objects
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-controller
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/status&quot;]
    verbs: [&quot;get&quot;, &quot;update&quot;, &quot;patch&quot;]
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies/finalizers&quot;]
    verbs: [&quot;update&quot;]
---
# Role for application teams to manage BackupPolicies in their namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-editor
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
---
# Read-only role for auditors
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: backuppolicy-viewer
rules:
  - apiGroups: [&quot;storage.example.com&quot;]
    resources: [&quot;backuppolicies&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;]
</code></pre>
<pre><code class="" data-line="">kubectl apply -f backuppolicies-rbac.yaml
</code></pre>
<p>Verify the roles exist:</p>
<pre><code class="" data-line="">kubectl get clusterrole | grep backuppolicy
</code></pre>
<pre><code class="" data-line="">backuppolicy-controller   2026-04-25T08:01:00Z
backuppolicy-editor       2026-04-25T08:01:00Z
backuppolicy-viewer       2026-04-25T08:01:00Z
</code></pre>
<blockquote>
<p><strong>Note on <code class="" data-line="">backuppolicies/status</code>:</strong> The separate status RBAC rule is only meaningful if you enabled the status subresource (we did). Without it, status and spec share the same update path.</p>
</blockquote>
<hr />
<h2 id="step-3-create-a-namespace-and-your-first-custom-resource">Step 3: Create a Namespace and Your First Custom Resource</h2>
<pre><code class="" data-line="">kubectl create namespace demo
</code></pre>
<p>Save this as <code class="" data-line="">nightly-backup.yaml</code>:</p>
<pre><code class="" data-line="">apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: demo
  labels:
    app.kubernetes.io/managed-by: manual
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
  storageClass: standard
  targets:
    - namespace: production
      includeSecrets: false
    - namespace: staging
      includeSecrets: false
  suspended: false
</code></pre>
<p>Apply it:</p>
<pre><code class="" data-line="">kubectl apply -f nightly-backup.yaml
</code></pre>
<p>Get it back:</p>
<pre><code class="" data-line="">kubectl get backuppolicies -n demo
</code></pre>
<pre><code class="" data-line="">NAME      SCHEDULE    RETENTION   SUSPENDED   READY   AGE
nightly   0 2 * * *   30          false       &lt;none&gt;  5s
</code></pre>
<p>The <code class="" data-line="">Ready</code> column is <code class="" data-line="">&lt;none&gt;</code> because there is no controller writing status yet. The custom resource exists and is stored in etcd, but nothing is acting on it.</p>
<p>Describe it:</p>
<pre><code class="" data-line="">kubectl describe bp nightly -n demo
</code></pre>
<pre><code class="" data-line="">Name:         nightly
Namespace:    demo
Labels:       app.kubernetes.io/managed-by=manual
Annotations:  &lt;none&gt;
API Version:  storage.example.com/v1alpha1
Kind:         BackupPolicy
Metadata:
  Creation Timestamp:  2026-04-25T08:05:00Z
  ...
Spec:
  Retention Days:  30
  Schedule:        0 2 * * *
  Storage Class:   standard
  Suspended:       false
  Targets:
    Include Secrets:  false
    Namespace:        production
    Include Secrets:  false
    Namespace:        staging
Status:
Events:  &lt;none&gt;
</code></pre>
<hr />
<h2 id="step-4-test-schema-validation">Step 4: Test Schema Validation</h2>
<p>The API server now validates every <code class="" data-line="">BackupPolicy</code> against the schema. Try creating an invalid one:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: bad-policy
  namespace: demo
spec:
  schedule: &quot;not-a-cron&quot;
  retentionDays: 500
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;bad-policy&quot; is invalid:
  spec.retentionDays: Invalid value: 500:
    spec.retentionDays in body should be less than or equal to 365
</code></pre>
<p>Missing required field:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: missing-schedule
  namespace: demo
spec:
  retentionDays: 7
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;missing-schedule&quot; is invalid:
  spec.schedule: Required value
</code></pre>
<p>Wrong type:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: wrong-type
  namespace: demo
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: &quot;thirty&quot;
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;wrong-type&quot; is invalid:
  spec.retentionDays: Invalid value: &quot;string&quot;:
    spec.retentionDays in body must be of type integer: &quot;string&quot;
</code></pre>
<p>All validation fires at the API boundary — before etcd, before any controller sees the object.</p>
<hr />
<h2 id="step-5-verify-default-values-apply">Step 5: Verify Default Values Apply</h2>
<p>The schema defines <code class="" data-line="">storageClass: default: &quot;standard&quot;</code> and <code class="" data-line="">suspended: default: false</code>. Verify they are applied even when not specified:</p>
<pre><code class="" data-line="">kubectl apply -f - &lt;&lt;&#039;EOF&#039;
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: minimal
  namespace: demo
spec:
  schedule: &quot;0 0 * * 0&quot;
  retentionDays: 7
EOF

kubectl get bp minimal -n demo -o jsonpath=&#039;{.spec.storageClass}&#039;
</code></pre>
<pre><code class="" data-line="">standard
</code></pre>
<pre><code class="" data-line="">kubectl get bp minimal -n demo -o jsonpath=&#039;{.spec.suspended}&#039;
</code></pre>
<pre><code class="" data-line="">false
</code></pre>
<p>Defaults are injected by the API server at admission time. They appear in etcd and in every <code class="" data-line="">kubectl get -o yaml</code> output — the stored object includes the defaults even if the user did not specify them.</p>
<hr />
<h2 id="step-6-explore-the-api-endpoints">Step 6: Explore the API Endpoints</h2>
<p>Your custom resource is now available at standard REST endpoints:</p>
<pre><code class="" data-line="">kubectl proxy --port=8001 &amp;

# List all BackupPolicies in the demo namespace
curl -s http://localhost:8001/apis/storage.example.com/v1alpha1/namespaces/demo/backuppolicies \
  | jq &#039;.items[].metadata.name&#039;
</code></pre>
<pre><code class="" data-line="">&quot;nightly&quot;
&quot;minimal&quot;
</code></pre>
<pre><code class="" data-line=""># Get a specific BackupPolicy
curl -s http://localhost:8001/apis/storage.example.com/v1alpha1/namespaces/demo/backuppolicies/nightly \
  | jq &#039;.spec&#039;
</code></pre>
<p>This is how controllers discover and watch custom resources — via the same API server endpoints, using informers that wrap these REST calls with efficient list-and-watch semantics.</p>
<hr />
<h2 id="step-7-clean-up">Step 7: Clean Up</h2>
<pre><code class="" data-line="">kubectl delete namespace demo
kubectl delete -f backuppolicies-rbac.yaml
kubectl delete -f backuppolicies-crd.yaml   # WARNING: deletes all BackupPolicy instances first
</code></pre>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong><code class="" data-line="">metadata.name</code> does not match <code class="" data-line="">{plural}.{group}</code>.</strong> The most common error. If you name the CRD <code class="" data-line="">backuppolicy.storage.example.com</code> (singular) but the spec says <code class="" data-line="">plural: backuppolicies</code>, the API server rejects it. The name must always be <code class="" data-line="">{plural}.{group}</code>.</p>
<p><strong>No <code class="" data-line="">required</code> fields on spec.</strong> Without required constraints, <code class="" data-line="">kubectl apply</code> accepts an empty <code class="" data-line="">spec: {}</code>. The controller then receives objects with no configuration and has to handle the nil case. Define required fields in the schema.</p>
<p><strong>Forgetting <code class="" data-line="">subresources: status: {}</code>.</strong> Without this, controllers writing <code class="" data-line="">.status</code> also overwrite <code class="" data-line="">.spec</code> on full PUT updates. This causes status updates to reset user edits. Enable the status subresource from day one.</p>
<p><strong>Not testing validation errors.</strong> Schema validation is the first line of defense. Always explicitly test that your required fields are required, types are enforced, and range constraints work — before deploying the controller.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># All kubectl operations work on custom resources
kubectl get      backuppolicies -n demo
kubectl get      bp -n demo                  # shortName
kubectl describe bp nightly -n demo
kubectl edit     bp nightly -n demo
kubectl delete   bp nightly -n demo

# Output formats
kubectl get bp -n demo -o yaml
kubectl get bp -n demo -o json
kubectl get bp -n demo -o jsonpath=&#039;{.items[*].metadata.name}&#039;

# Watch for changes
kubectl get bp -n demo -w

# List across all namespaces
kubectl get bp -A

# Patch spec
kubectl patch bp nightly -n demo \
  --type=merge -p &#039;{&quot;spec&quot;:{&quot;suspended&quot;:true}}&#039;
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A working CRD deployment needs: the CRD YAML, RBAC ClusterRoles, and at least one sample custom resource</li>
<li>The API server validates all custom resources against the schema at apply time — errors are surfaced immediately, not inside the controller</li>
<li>Default values in the schema are injected at admission time and appear in every stored object</li>
<li>RBAC for custom resources uses <code class="" data-line="">{plural}.{group}</code> as the resource name — <code class="" data-line="">status</code> and <code class="" data-line="">finalizers</code> are separate sub-resources</li>
<li>Without a controller, custom resources are stored in etcd and serve as validated configuration — nothing acts on them until a controller is deployed</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-crd-cel-validation/">EP05: Kubernetes CRD CEL Validation</a> extends schema validation beyond simple type and range checks — cross-field rules (&#8220;if <code class="" data-line="">storageClass</code> is <code class="" data-line="">premium</code>, <code class="" data-line="">retentionDays</code> must be at most 90&#8243;), regex validation beyond <code class="" data-line="">pattern</code>, and immutable field enforcement. All without an admission webhook.</p>
<p>Get EP05 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&amp;linkname=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fwrite-kubernetes-crd-yaml-walkthrough%2F&#038;title=Write%20Your%20First%20Kubernetes%20CRD%3A%20A%20Hands-On%20YAML%20Walkthrough" data-a2a-url="https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/" data-a2a-title="Write Your First Kubernetes CRD: A Hands-On YAML Walkthrough"></a></p><p>The post <a href="https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/">Write Your First Kubernetes CRD: A Hands-On YAML Walkthrough</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/write-kubernetes-crd-yaml-walkthrough/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1684</post-id>	</item>
		<item>
		<title>Kubernetes CRD Schema Explained: Versions, Validation, and Status Subresource</title>
		<link>https://linuxcent.com/kubernetes-crd-schema-explained/</link>
					<comments>https://linuxcent.com/kubernetes-crd-schema-explained/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:15:21 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[CRD Schema]]></category>
		<category><![CDATA[OpenAPI]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[spec.versions]]></category>
		<category><![CDATA[Status Subresource]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-crd-schema-explained/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Understand Kubernetes CRD anatomy: spec.versions, OpenAPI schema validation, scope, and the status subresource — what each YAML field does and why it matters in production.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-crd-schema-explained/">Kubernetes CRD Schema Explained: Versions, Validation, and Status Subresource</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 3</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · </em><em><a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a></em><em> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>The Kubernetes CRD schema is defined in <code class="" data-line="">spec.versions[].schema.openAPIV3Schema</code> — the API server uses it to validate every custom resource create and update before storing in etcd<br />
  <em>(OpenAPI v3 schema = a JSON Schema dialect that describes the structure, types, and constraints of your resource&#8217;s fields)</em></li>
<li><code class="" data-line="">spec.versions</code> is a list — CRDs can serve multiple API versions simultaneously; exactly one version must have <code class="" data-line="">storage: true</code></li>
<li><code class="" data-line="">scope: Namespaced</code> vs <code class="" data-line="">scope: Cluster</code> controls whether custom resources live inside a namespace or at cluster level (like <code class="" data-line="">PersistentVolume</code> vs <code class="" data-line="">PersistentVolumeClaim</code>)</li>
<li><code class="" data-line="">spec.names</code> defines the plural, singular, kind, and optional shortNames used in <code class="" data-line="">kubectl</code> and RBAC</li>
<li>The <strong>status subresource</strong> (<code class="" data-line="">subresources.status: {}</code>) separates user writes (<code class="" data-line="">spec</code>) from controller writes (<code class="" data-line="">status</code>) — enabling optimistic concurrency and <code class="" data-line="">kubectl status</code> support</li>
<li>The <strong>scale subresource</strong> (<code class="" data-line="">subresources.scale</code>) makes your custom resource compatible with <code class="" data-line="">kubectl scale</code> and the HorizontalPodAutoscaler</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  ANATOMY OF A CUSTOMRESOURCEDEFINITION

  apiVersion: apiextensions.k8s.io/v1
  kind: CustomResourceDefinition
  metadata:
    name: {plural}.{group}        ← MUST be exactly this format
  spec:
    group: {group}                ← API group (e.g. storage.example.com)
    scope: Namespaced | Cluster   ← where instances live
    names:                        ← how kubectl refers to this resource
      plural: backuppolicies
      singular: backuppolicy
      kind: BackupPolicy
      shortNames: [bp]
    versions:                     ← can be a list; one must have storage: true
      - name: v1alpha1
        served: true              ← API server responds to this version
        storage: true             ← etcd stores objects in this version
        schema:
          openAPIV3Schema:        ← validation schema for ALL objects of this type
            type: object
            properties:
              spec: {...}
              status: {...}
        subresources:
          status: {}              ← enables separate status write path
          scale:                  ← enables kubectl scale + HPA
            specReplicasPath: .spec.replicas
            statusReplicasPath: .status.replicas
        additionalPrinterColumns: ← extra columns in kubectl get output
          - name: Schedule
            type: string
            jsonPath: .spec.schedule
</code></pre>
<p>Understanding the Kubernetes CRD schema is the prerequisite for writing a CRD that behaves correctly in production — validation catches bad data at the API boundary, the status subresource prevents controller race conditions, and scope determines your entire RBAC and multi-tenancy model.</p>
<hr />
<h2 id="specgroup-and-metadataname"><code class="" data-line="">spec.group</code> and <code class="" data-line="">metadata.name</code></h2>
<p>The group is a reverse-DNS identifier for your API. Convention:</p>
<pre><code class="" data-line="">storage.example.com     ← domain you control + functional area
monitoring.myteam.io
databases.platform.company.com
</code></pre>
<p>The CRD&#8217;s <code class="" data-line="">metadata.name</code> must be exactly <code class="" data-line="">{plural}.{group}</code>:</p>
<pre><code class="" data-line="">metadata:
  name: backuppolicies.storage.example.com
spec:
  group: storage.example.com
  names:
    plural: backuppolicies
</code></pre>
<p>If these do not match, the API server rejects the CRD with a validation error. This is the most common first-timer mistake.</p>
<hr />
<h2 id="specscope-namespaced-vs-cluster"><code class="" data-line="">spec.scope</code>: Namespaced vs Cluster</h2>
<pre><code class="" data-line="">  SCOPE DETERMINES WHERE INSTANCES LIVE

  Namespaced (scope: Namespaced)       Cluster (scope: Cluster)
  ─────────────────────────────         ──────────────────────────
  kubectl get backuppolicies -n prod    kubectl get clusterbackuppolicies
  kubectl get backuppolicies -A         (no -n flag, no namespace)

  Analogous to: Pod, Deployment,        Analogous to: PersistentVolume,
                ConfigMap                             ClusterRole, Node
</code></pre>
<p><strong>Namespaced</strong>: Use when instances are per-tenant or per-application. Users with namespace-scoped RBAC can manage their own instances without cluster-admin. Most CRDs should be namespaced.</p>
<p><strong>Cluster-scoped</strong>: Use when instances represent cluster-wide configuration — a <code class="" data-line="">ClusterIssuer</code> (cert-manager), <code class="" data-line="">ClusterSecretStore</code> (ESO), a <code class="" data-line="">StorageClass</code>-like concept. Requires cluster-level RBAC to create/modify.</p>
<p>You cannot change scope after a CRD is created without deleting and recreating it (which deletes all instances). Choose carefully.</p>
<hr />
<h2 id="specversions-serving-multiple-api-versions"><code class="" data-line="">spec.versions</code>: Serving Multiple API Versions</h2>
<pre><code class="" data-line="">spec:
  versions:
    - name: v1alpha1
      served: true
      storage: false       # not stored; converted on read
      schema:
        openAPIV3Schema: {...}
    - name: v1beta1
      served: true
      storage: false
      schema:
        openAPIV3Schema: {...}
    - name: v1
      served: true
      storage: true        # etcd stores in this version
      schema:
        openAPIV3Schema: {...}
</code></pre>
<p>Rules:<br />
&#8211; <code class="" data-line="">served: true</code> means the API server accepts requests at this version<br />
&#8211; <code class="" data-line="">served: false</code> means the API server returns <code class="" data-line="">404</code> for that version — use to deprecate<br />
&#8211; Exactly one version must have <code class="" data-line="">storage: true</code> — this is what gets written to etcd<br />
&#8211; When a client requests a non-storage version, the API server converts on the fly (or calls your conversion webhook — see <a href="/kubernetes-crd-versioning-conversion-webhook/">EP08</a>)</p>
<p>Early in development, start with <code class="" data-line="">v1alpha1 storage: true</code>. Promote to <code class="" data-line="">v1</code> when the schema is stable. EP08 covers how to do this without losing data.</p>
<hr />
<h2 id="specnames-what-kubectl-sees"><code class="" data-line="">spec.names</code>: What kubectl Sees</h2>
<pre><code class="" data-line="">spec:
  names:
    plural:     backuppolicies     # kubectl get backuppolicies
    singular:   backuppolicy       # kubectl get backuppolicy (also works)
    kind:       BackupPolicy       # used in YAML apiVersion/kind
    listKind:   BackupPolicyList   # optional; auto-derived if omitted
    shortNames:                    # kubectl get bp
      - bp
    categories:                    # kubectl get all includes this type
      - all
</code></pre>
<p><code class="" data-line="">categories</code> is worth noting: if you add <code class="" data-line="">all</code> to categories, your custom resources appear when someone runs <code class="" data-line="">kubectl get all -n mynamespace</code>. Most CRDs deliberately do not add this — it clutters <code class="" data-line="">get all</code> output. Only add it if your resource is a primary operational concern.</p>
<hr />
<h2 id="schemaopenapiv3schema-validation"><code class="" data-line="">schema.openAPIV3Schema</code>: Validation</h2>
<p>The schema is where you define field types, required fields, constraints, and descriptions. The API server validates every create and update against this schema before writing to etcd.</p>
<pre><code class="" data-line="">schema:
  openAPIV3Schema:
    type: object
    required: [&quot;spec&quot;]
    properties:
      spec:
        type: object
        required: [&quot;schedule&quot;, &quot;retentionDays&quot;]
        properties:
          schedule:
            type: string
            description: &quot;Cron expression for backup schedule&quot;
            pattern: &#039;^(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)$&#039;
          retentionDays:
            type: integer
            minimum: 1
            maximum: 365
          storageClass:
            type: string
            default: &quot;standard&quot;        # default value (Kubernetes 1.17+)
          targets:
            type: array
            maxItems: 10
            items:
              type: object
              required: [&quot;name&quot;]
              properties:
                name:
                  type: string
                namespace:
                  type: string
                  default: &quot;default&quot;
      status:
        type: object
        x-kubernetes-preserve-unknown-fields: true   # controllers write arbitrary status
</code></pre>
<h3 id="field-types-available">Field types available</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Usage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">string</code></td>
<td>Text values; supports <code class="" data-line="">format</code>, <code class="" data-line="">pattern</code>, <code class="" data-line="">enum</code>, <code class="" data-line="">minLength</code>, <code class="" data-line="">maxLength</code></td>
</tr>
<tr>
<td><code class="" data-line="">integer</code></td>
<td>Whole numbers; supports <code class="" data-line="">minimum</code>, <code class="" data-line="">maximum</code></td>
</tr>
<tr>
<td><code class="" data-line="">number</code></td>
<td>Floating point</td>
</tr>
<tr>
<td><code class="" data-line="">boolean</code></td>
<td><code class="" data-line="">true</code>/<code class="" data-line="">false</code></td>
</tr>
<tr>
<td><code class="" data-line="">object</code></td>
<td>Nested structure; use <code class="" data-line="">properties</code> to define fields</td>
</tr>
<tr>
<td><code class="" data-line="">array</code></td>
<td>List; use <code class="" data-line="">items</code> to define element schema; supports <code class="" data-line="">minItems</code>, <code class="" data-line="">maxItems</code></td>
</tr>
</tbody>
</table>
<h3 id="x-kubernetes-preserve-unknown-fields-true"><code class="" data-line="">x-kubernetes-preserve-unknown-fields: true</code></h3>
<p>This tells the API server not to prune fields it does not know about. Use it on <code class="" data-line="">status</code> (controllers write whatever they need) and on fields that are intentionally free-form (like a <code class="" data-line="">config</code> field that accepts arbitrary YAML). Avoid it on <code class="" data-line="">spec</code> — it bypasses validation.</p>
<h3 id="validation-behavior-in-practice">Validation behavior in practice</h3>
<pre><code class="" data-line=""># This will fail with a clear error:
kubectl apply -f - &lt;&lt;EOF
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: bad
  namespace: default
spec:
  schedule: &quot;not-a-cron&quot;    # fails pattern validation
  retentionDays: 500         # fails maximum: 365
EOF
</code></pre>
<pre><code class="" data-line="">The BackupPolicy &quot;bad&quot; is invalid:
  spec.schedule: Invalid value: &quot;not-a-cron&quot;: spec.schedule in body should match
    &#039;^(\*|[0-9,\-\/]+)\s+...&#039;
  spec.retentionDays: Invalid value: 500: spec.retentionDays in body should be
    less than or equal to 365
</code></pre>
<p>Schema validation catches configuration mistakes at apply time, not at runtime inside a pod. This is one of the core advantages of expressing domain configuration as CRDs rather than ConfigMaps.</p>
<hr />
<h2 id="additionalprintercolumns-what-kubectl-get-shows"><code class="" data-line="">additionalPrinterColumns</code>: What kubectl get Shows</h2>
<p>By default, <code class="" data-line="">kubectl get backuppolicies</code> shows only <code class="" data-line="">NAME</code> and <code class="" data-line="">AGE</code>. You can add columns:</p>
<pre><code class="" data-line="">additionalPrinterColumns:
  - name: Schedule
    type: string
    jsonPath: .spec.schedule
    description: Cron schedule for backups
  - name: Retention
    type: integer
    jsonPath: .spec.retentionDays
    priority: 1          # 0 = always shown; 1 = only with -o wide
  - name: Ready
    type: string
    jsonPath: .status.conditions[?(@.type==&#039;Ready&#039;)].status
  - name: Age
    type: date
    jsonPath: .metadata.creationTimestamp
</code></pre>
<p>Result:</p>
<pre><code class="" data-line="">NAME        SCHEDULE      READY   AGE
nightly     0 2 * * *     True    3d
weekly      0 0 * * 0     False   7d
</code></pre>
<p>Good printer columns turn <code class="" data-line="">kubectl get</code> into a useful operational dashboard. Include <code class="" data-line="">Ready</code> (from status conditions) so operators can immediately see which custom resources are healthy without running <code class="" data-line="">kubectl describe</code>.</p>
<hr />
<h2 id="the-status-subresource">The Status Subresource</h2>
<pre><code class="" data-line="">subresources:
  status: {}
</code></pre>
<p>Without the status subresource, <code class="" data-line="">spec</code> and <code class="" data-line="">status</code> are part of the same object. Any user with <code class="" data-line="">update</code> permission on the CRD can modify both. Controllers write status through the same path as users write spec.</p>
<p>With the status subresource enabled:<br />
&#8211; <code class="" data-line="">kubectl apply</code> / <code class="" data-line="">kubectl patch</code> only update <code class="" data-line="">spec</code> — the <code class="" data-line="">status</code> block is stripped<br />
&#8211; Controllers use the <code class="" data-line="">/status</code> subresource endpoint to write status<br />
&#8211; RBAC can grant <code class="" data-line="">update</code> on <code class="" data-line="">backuppolicies</code> (spec) independently from <code class="" data-line="">update</code> on <code class="" data-line="">backuppolicies/status</code></p>
<pre><code class="" data-line="">  WITHOUT status subresource:         WITH status subresource:
  ─────────────────────────            ──────────────────────────
  PUT /backuppolicies/nightly          PUT /backuppolicies/nightly
  → updates spec AND status            → updates spec only

                                       PUT /backuppolicies/nightly/status
                                       → updates status only (controller path)
</code></pre>
<p>Always enable the status subresource on production CRDs. The split between spec and status is fundamental to the Kubernetes API contract. Without it, a controller updating status can accidentally overwrite spec changes made by a user at the same time.</p>
<hr />
<h2 id="the-scale-subresource">The Scale Subresource</h2>
<pre><code class="" data-line="">subresources:
  scale:
    specReplicasPath: .spec.replicas
    statusReplicasPath: .status.replicas
    labelSelectorPath: .status.labelSelector
</code></pre>
<p>This makes your custom resource compatible with:</p>
<pre><code class="" data-line="">kubectl scale backuppolicy nightly --replicas=3
</code></pre>
<p>And with <code class="" data-line="">HorizontalPodAutoscaler</code> targeting your custom resource. If your CRD manages something replica-based (workers, shards, connections), enabling the scale subresource lets it plug into the standard Kubernetes autoscaling ecosystem without extra plumbing.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Forgetting <code class="" data-line="">x-kubernetes-preserve-unknown-fields: true</code> on status.</strong> If you validate the status field with a strict schema but do not add this, the API server will prune any status fields the controller writes that are not in the schema. The controller&#8217;s status updates will silently lose fields. Either define the full status schema or use <code class="" data-line="">x-kubernetes-preserve-unknown-fields: true</code>.</p>
<p><strong>Using <code class="" data-line="">scope: Cluster</code> for resources that should be namespaced.</strong> Once a CRD is created as cluster-scoped, you cannot make it namespaced without deleting and recreating it. Plan scope before deploying to production.</p>
<p><strong>Not enabling the status subresource.</strong> Without it, controllers writing status can race with users updating spec. It also means <code class="" data-line="">kubectl patch --subresource=status</code> does not work and some tooling behaves unexpectedly. Enable it from the start.</p>
<p><strong>Loose schema with no <code class="" data-line="">required</code> fields.</strong> An <code class="" data-line="">openAPIV3Schema</code> with no <code class="" data-line="">required</code> constraint accepts objects with empty <code class="" data-line="">spec</code>. This usually means your controller gets called with a resource that is missing mandatory configuration. Define required fields and validate them at the API boundary, not inside the controller.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># Inspect the full schema of a CRD
kubectl get crd backuppolicies.storage.example.com -o yaml | \
  yq &#039;.spec.versions[0].schema&#039;

# Check what subresources are enabled
kubectl get crd certificates.cert-manager.io -o jsonpath=\
  &#039;{.spec.versions[0].subresources}&#039;

# See all served versions for a CRD
kubectl get crd prometheuses.monitoring.coreos.com \
  -o jsonpath=&#039;{.spec.versions[*].name}&#039;

# Check which version is the storage version
kubectl get crd certificates.cert-manager.io \
  -o jsonpath=&#039;{.spec.versions[?(@.storage==true)].name}&#039;

# Describe the printer columns for a CRD
kubectl get crd scaledobjects.keda.sh \
  -o jsonpath=&#039;{.spec.versions[0].additionalPrinterColumns}&#039;
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><code class="" data-line="">spec.versions</code> allows serving and storing multiple API versions; only one version has <code class="" data-line="">storage: true</code></li>
<li><code class="" data-line="">scope</code> (Namespaced vs Cluster) cannot be changed after creation — choose deliberately</li>
<li><code class="" data-line="">openAPIV3Schema</code> validates every CR at the API boundary, before etcd storage</li>
<li>The status subresource separates the user write path (spec) from the controller write path (status) — always enable it</li>
<li><code class="" data-line="">additionalPrinterColumns</code> makes <code class="" data-line="">kubectl get</code> operationally useful; include a <code class="" data-line="">Ready</code> column from status conditions</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/write-kubernetes-crd-yaml-walkthrough/">EP04: Write Your First Kubernetes CRD</a> puts the anatomy into practice — a complete hands-on walkthrough building a <code class="" data-line="">BackupPolicy</code> CRD from scratch, applying it to a cluster, creating instances, and verifying validation, RBAC, and status behavior.</p>
<p>Get EP04 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&amp;linkname=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-crd-schema-explained%2F&#038;title=Kubernetes%20CRD%20Schema%20Explained%3A%20Versions%2C%20Validation%2C%20and%20Status%20Subresource" data-a2a-url="https://linuxcent.com/kubernetes-crd-schema-explained/" data-a2a-title="Kubernetes CRD Schema Explained: Versions, Validation, and Status Subresource"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-crd-schema-explained/">Kubernetes CRD Schema Explained: Versions, Validation, and Status Subresource</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-crd-schema-explained/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1681</post-id>	</item>
		<item>
		<title>CRDs You Already Use: cert-manager, KEDA, and External Secrets Explained</title>
		<link>https://linuxcent.com/kubernetes-custom-resources-examples/</link>
					<comments>https://linuxcent.com/kubernetes-custom-resources-examples/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:15:04 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[cert-manager]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Custom Resources]]></category>
		<category><![CDATA[External Secrets]]></category>
		<category><![CDATA[KEDA]]></category>
		<category><![CDATA[Operators]]></category>
		<guid isPermaLink="false">https://linuxcent.com/kubernetes-custom-resources-examples/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>See Kubernetes custom resources in action: cert-manager Certificates, KEDA ScaledObjects, and External Secrets — understand what CRDs look like before you build one.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-custom-resources-examples/">CRDs You Already Use: cert-manager, KEDA, and External Secrets Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 2</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · </em><em><a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a></em><em> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>cert-manager, KEDA, and External Secrets Operator are all CRD-based systems — understanding their custom resources shows you what a well-designed CRD looks like before you build one</li>
<li>cert-manager&#8217;s <code class="" data-line="">Certificate</code> CRD expresses desired TLS state; the cert-manager controller reconciles that state by issuing, renewing, and storing certificates in Secrets</li>
<li>KEDA&#8217;s <code class="" data-line="">ScaledObject</code> extends the HorizontalPodAutoscaler with external metrics (queue depth, Kafka lag, Prometheus queries) — the KEDA operator translates ScaledObjects into native HPA objects</li>
<li>External Secrets Operator&#8217;s <code class="" data-line="">ExternalSecret</code> abstracts over secret backends (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) — the controller pulls values and writes Kubernetes Secrets</li>
<li>All three follow the same pattern: you describe <strong>desired state</strong> in a custom resource; the operator <strong>reconciles</strong> actual state to match</li>
<li>Kubernetes custom resources examples like these are the fastest way to internalize the CRD mental model before writing your own</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  THREE CRD-BASED OPERATORS AND WHAT THEY MANAGE

  ┌─────────────────────────────────────────────────────────────┐
  │  cert-manager                                               │
  │  Certificate CR  →  controller issues cert  →  TLS Secret  │
  └─────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────────┐
  │  KEDA                                                       │
  │  ScaledObject CR  →  controller creates HPA  →  Pod count  │
  └─────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────────┐
  │  External Secrets Operator                                  │
  │  ExternalSecret CR  →  controller pulls  →  K8s Secret      │
  │                         from Vault/AWS/GCP                  │
  └─────────────────────────────────────────────────────────────┘

  In every case:
  User creates CR  →  Operator watches CR  →  Operator acts  →  Status updated
</code></pre>
<p>Kubernetes custom resources examples from real tools like these reveal the design pattern you will use in every CRD you build: express desired state declaratively, let the controller bridge the gap to actual state, surface the outcome in the status subresource.</p>
<hr />
<h2 id="why-look-at-existing-crds-first">Why Look at Existing CRDs First?</h2>
<p>Before designing your own CRD, you want to understand what good CRD design looks like from the user&#8217;s perspective. The engineers at Jetstack (cert-manager), KEDACORE (KEDA), and External Secrets contributors have collectively solved the same problems you will face:</p>
<ul>
<li>What goes in <code class="" data-line="">spec</code> vs <code class="" data-line="">status</code>?</li>
<li>How do you reference other Kubernetes objects?</li>
<li>How do you handle secrets and credentials securely?</li>
<li>What does a healthy vs unhealthy custom resource look like?</li>
</ul>
<p>Studying these before writing your own saves you from the most common first-timer mistakes.</p>
<hr />
<h2 id="cert-manager-the-certificate-crd">cert-manager: The Certificate CRD</h2>
<p>cert-manager is the most widely deployed CRD-based system in Kubernetes. It manages TLS certificates from Let&#8217;s Encrypt, internal CAs, and cloud providers.</p>
<h3 id="the-core-crds">The core CRDs</h3>
<pre><code class="" data-line="">kubectl get crds | grep cert-manager
</code></pre>
<pre><code class="" data-line="">certificates.cert-manager.io
certificaterequests.cert-manager.io
challenges.acme.cert-manager.io
clusterissuers.cert-manager.io
issuers.cert-manager.io
orders.acme.cert-manager.io
</code></pre>
<p>The one you interact with most is <code class="" data-line="">Certificate</code>. Here is a real example:</p>
<pre><code class="" data-line="">apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: api-tls
  namespace: production
spec:
  secretName: api-tls-cert        # cert-manager writes the TLS Secret here
  duration: 2160h                 # 90 days
  renewBefore: 720h               # renew 30 days before expiry
  subject:
    organizations:
      - example.com
  dnsNames:
    - api.example.com
    - api-internal.example.com
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
</code></pre>
<p>What happens after you apply this:</p>
<ol>
<li>cert-manager controller sees the new <code class="" data-line="">Certificate</code> object</li>
<li>It contacts the referenced <code class="" data-line="">ClusterIssuer</code> (Let&#8217;s Encrypt in this case)</li>
<li>It completes the ACME challenge, obtains the certificate</li>
<li>It writes the certificate and private key into the <code class="" data-line="">api-tls-cert</code> Secret</li>
<li>It updates the <code class="" data-line="">Certificate</code> object&#8217;s <code class="" data-line="">status</code> to reflect success</li>
</ol>
<pre><code class="" data-line="">kubectl describe certificate api-tls -n production
</code></pre>
<pre><code class="" data-line="">Status:
  Conditions:
    Last Transition Time:  2026-04-10T08:00:00Z
    Message:               Certificate is up to date and has not expired
    Reason:                Ready
    Status:                True
    Type:                  Ready
  Not After:               2026-07-09T08:00:00Z
  Not Before:              2026-04-10T08:00:00Z
  Renewal Time:            2026-06-09T08:00:00Z
</code></pre>
<h3 id="what-this-teaches-you-about-crd-design">What this teaches you about CRD design</h3>
<ul>
<li><code class="" data-line="">spec.secretName</code> — the CR references an output object by name. The controller creates or updates that object.</li>
<li><code class="" data-line="">spec.issuerRef</code> — the CR references another custom resource (<code class="" data-line="">ClusterIssuer</code>) by name. This is a common pattern for separating configuration concerns.</li>
<li><code class="" data-line="">status.conditions</code> — the standard Kubernetes condition pattern: <code class="" data-line="">type</code>, <code class="" data-line="">status</code>, <code class="" data-line="">reason</code>, <code class="" data-line="">message</code>. You will use the same structure in your own CRDs.</li>
<li>The controller owns <code class="" data-line="">status</code> — users own <code class="" data-line="">spec</code>. This separation is a core convention.</li>
</ul>
<hr />
<h2 id="keda-the-scaledobject-crd">KEDA: The ScaledObject CRD</h2>
<p>KEDA (Kubernetes Event-Driven Autoscaling) extends Kubernetes autoscaling beyond CPU and memory. It can scale deployments based on queue depth, Kafka consumer lag, Prometheus metric values, and dozens of other event sources.</p>
<h3 id="the-core-crds_1">The core CRDs</h3>
<pre><code class="" data-line="">kubectl get crds | grep keda
</code></pre>
<pre><code class="" data-line="">clustertriggerauthentications.keda.sh
scaledjobs.keda.sh
scaledobjects.keda.sh
triggerauthentications.keda.sh
</code></pre>
<p>A <code class="" data-line="">ScaledObject</code> ties a Deployment to an external scaler:</p>
<pre><code class="" data-line="">apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: order-processor        # the Deployment to scale
  minReplicaCount: 0             # scale to zero when idle
  maxReplicaCount: 50
  triggers:
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789/orders
        queueLength: &quot;5&quot;         # target: 5 messages per pod
        awsRegion: us-east-1
      authenticationRef:
        name: keda-sqs-auth      # TriggerAuthentication for AWS credentials
</code></pre>
<p>What KEDA does with this:</p>
<ol>
<li>KEDA controller sees the <code class="" data-line="">ScaledObject</code></li>
<li>It creates a native <code class="" data-line="">HorizontalPodAutoscaler</code> object targeting the <code class="" data-line="">order-processor</code> Deployment</li>
<li>KEDA&#8217;s metrics adapter polls the SQS queue depth and exposes it as a custom metric</li>
<li>The HPA uses that metric to scale replicas — including to zero when the queue is empty</li>
</ol>
<pre><code class="" data-line="">kubectl get scaledobject order-processor-scaler -n production
</code></pre>
<pre><code class="" data-line="">NAME                       SCALETARGETKIND      SCALETARGETNAME    MIN   MAX   TRIGGERS         READY   ACTIVE
order-processor-scaler     apps/Deployment      order-processor    0     50    aws-sqs-queue    True    True
</code></pre>
<h3 id="what-this-teaches-you-about-crd-design_1">What this teaches you about CRD design</h3>
<ul>
<li><code class="" data-line="">spec.scaleTargetRef</code> — targeting another object by name. The controller acts on that object, not on the CR itself.</li>
<li><code class="" data-line="">spec.triggers</code> — a list of trigger specifications. Lists of typed sub-objects are a recurring CRD pattern.</li>
<li><code class="" data-line="">spec.minReplicaCount: 0</code> — expressing scale-to-zero as a first-class concept in the API. Built-in HPA does not support this; KEDA&#8217;s CRD extends the vocabulary of what is expressible.</li>
<li>The KEDA operator translates <code class="" data-line="">ScaledObject</code> → native HPA. The CRD is an abstraction over a more complex Kubernetes object. This &#8220;translate and manage child resources&#8221; pattern is extremely common in operators.</li>
</ul>
<hr />
<h2 id="external-secrets-operator-the-externalsecret-crd">External Secrets Operator: The ExternalSecret CRD</h2>
<p>External Secrets Operator (ESO) solves a specific problem: secrets live in external systems (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager), but Kubernetes workloads need them as Kubernetes Secrets. ESO bridges the gap.</p>
<h3 id="the-core-crds_2">The core CRDs</h3>
<pre><code class="" data-line="">kubectl get crds | grep external-secrets
</code></pre>
<pre><code class="" data-line="">clusterexternalsecrets.external-secrets.io
clustersecretstores.external-secrets.io
externalsecrets.external-secrets.io
secretstores.external-secrets.io
</code></pre>
<p>A <code class="" data-line="">SecretStore</code> defines the backend connection:</p>
<pre><code class="" data-line="">apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: production
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: eso-sa            # uses IRSA/workload identity
</code></pre>
<p>An <code class="" data-line="">ExternalSecret</code> defines what to pull and how to map it:</p>
<pre><code class="" data-line="">apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: database-creds
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: database-secret          # Kubernetes Secret to create/update
    creationPolicy: Owner
  data:
    - secretKey: username          # key in the K8s Secret
      remoteRef:
        key: prod/database         # path in AWS Secrets Manager
        property: username         # property within that secret
    - secretKey: password
      remoteRef:
        key: prod/database
        property: password
</code></pre>
<p>After ESO reconciles this:</p>
<pre><code class="" data-line="">kubectl get secret database-secret -n production -o jsonpath=&#039;{.data.username}&#039; | base64 -d
# outputs: db_user
</code></pre>
<pre><code class="" data-line="">kubectl describe externalsecret database-creds -n production
</code></pre>
<pre><code class="" data-line="">Status:
  Conditions:
    Last Transition Time:   2026-04-10T08:00:00Z
    Message:                Secret was synced
    Reason:                 SecretSynced
    Status:                 True
    Type:                   Ready
  Refresh Time:             2026-04-10T09:00:00Z
  Synced Resource Version:  1-abc123
</code></pre>
<h3 id="what-this-teaches-you-about-crd-design_2">What this teaches you about CRD design</h3>
<ul>
<li><code class="" data-line="">spec.secretStoreRef</code> — referencing a configuration CRD (<code class="" data-line="">SecretStore</code>) from an operational CRD (<code class="" data-line="">ExternalSecret</code>). This layering of CRDs to separate concerns is a mature pattern.</li>
<li><code class="" data-line="">spec.refreshInterval</code> — the CR expresses a desired behavior (periodic sync), not just a desired state snapshot. CRDs can express temporal behaviors.</li>
<li><code class="" data-line="">spec.target.creationPolicy: Owner</code> — ESO will set an owner reference on the created Secret, so deleting the <code class="" data-line="">ExternalSecret</code> cascades to deleting the Secret. This is how controllers manage lifecycle.</li>
<li>Sensitive values never appear in the CR — only paths and references. The controller handles the actual secret retrieval. This is a key security pattern in CRD design.</li>
</ul>
<hr />
<h2 id="the-common-pattern-across-all-three">The Common Pattern Across All Three</h2>
<pre><code class="" data-line="">  OPERATOR PATTERN (cert-manager / KEDA / ESO / every other operator)

  User applies CR
        │
        ▼
  Controller watches CRDs
  (informer cache, events queue)
        │
        ▼
  Controller reconciles:
  actual state ──→ compare ──→ desired state
        │              │
        │         (gap found)
        │              │
        ▼              ▼
  Takes action      Updates status
  (issue cert,      conditions in CR
   create HPA,
   sync Secret)
        │
        └──── loops back, watches for next change
</code></pre>
<p>The design contract:<br />
&#8211; <strong>Users write <code class="" data-line="">spec</code></strong> — what they want<br />
&#8211; <strong>Controllers read <code class="" data-line="">spec</code>, write <code class="" data-line="">status</code></strong> — what actually happened<br />
&#8211; <strong>Status conditions are truth</strong> — <code class="" data-line="">Ready: True/False</code> with <code class="" data-line="">reason</code> and <code class="" data-line="">message</code> tell operators what the controller knows</p>
<p>This pattern, <a href="/kubernetes-controller-reconcile-loop/">explained in depth in EP06</a>, is why CRDs and controllers are designed the way they are.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Installing CRDs without the controller.</strong> If you install cert-manager&#8217;s CRDs from the <code class="" data-line="">crds.yaml</code> manifest without installing cert-manager itself, <code class="" data-line="">Certificate</code> objects will be accepted by the API server but never reconciled. The <code class="" data-line="">Ready</code> condition will never appear. Always install the operator alongside its CRDs.</p>
<p><strong>Editing status fields directly.</strong> Many teams try <code class="" data-line="">kubectl patch</code> or <code class="" data-line="">kubectl edit</code> to update a custom resource&#8217;s status to work around a stuck controller. Most well-written controllers overwrite status every reconcile loop — your manual change will be wiped. Fix the underlying issue, not the status display.</p>
<p><strong>Assuming CRD deletion is safe.</strong> Covered in EP01 but worth repeating: deleting a CRD cascades to deleting all instances. If you <code class="" data-line="">kubectl delete crd certificates.cert-manager.io</code>, every <code class="" data-line="">Certificate</code> object in every namespace is gone and cert-manager will stop issuing. Back up CRDs and their instances before any CRD deletion.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># See all CRDs installed by cert-manager
kubectl get crds | grep cert-manager.io

# Get all Certificates across all namespaces
kubectl get certificates -A

# Watch cert-manager reconcile a new Certificate
kubectl get certificate api-tls -n production -w

# See all ScaledObjects and their current state
kubectl get scaledobjects -A

# Check ESO sync status for all ExternalSecrets
kubectl get externalsecrets -A

# Inspect what APIs a CRD exposes
kubectl api-resources | grep cert-manager
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>cert-manager, KEDA, and ESO are canonical examples of well-designed CRD-based operators</li>
<li>All three follow the same pattern: user writes <code class="" data-line="">spec</code>, controller reconciles to actual state, status reflects outcome</li>
<li><code class="" data-line="">spec</code> expresses desired state declaratively; the controller figures out how to achieve it</li>
<li>Status conditions (<code class="" data-line="">type</code>, <code class="" data-line="">status</code>, <code class="" data-line="">reason</code>, <code class="" data-line="">message</code>) are the standard way to surface controller outcomes</li>
<li>Sensitive values never appear in the CR — controllers retrieve them from external systems using references and credentials</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-crd-schema-explained/">EP03: CRD Anatomy</a> opens the YAML of a CRD itself — <code class="" data-line="">spec.versions</code>, OpenAPI schema properties, scope, names, and subresources. You have seen CRDs from the outside; next we look at how they are structured on the inside.</p>
<p>Get EP03 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&amp;linkname=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-custom-resources-examples%2F&#038;title=CRDs%20You%20Already%20Use%3A%20cert-manager%2C%20KEDA%2C%20and%20External%20Secrets%20Explained" data-a2a-url="https://linuxcent.com/kubernetes-custom-resources-examples/" data-a2a-title="CRDs You Already Use: cert-manager, KEDA, and External Secrets Explained"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-custom-resources-examples/">CRDs You Already Use: cert-manager, KEDA, and External Secrets Explained</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-custom-resources-examples/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1678</post-id>	</item>
		<item>
		<title>What Is a Kubernetes CRD? How Custom Resources Extend the API</title>
		<link>https://linuxcent.com/what-is-kubernetes-crd/</link>
					<comments>https://linuxcent.com/what-is-kubernetes-crd/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 25 Apr 2026 18:14:48 +0000</pubDate>
				<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[API Extension]]></category>
		<category><![CDATA[CRD]]></category>
		<category><![CDATA[Custom Resource Definition]]></category>
		<category><![CDATA[kubectl]]></category>
		<category><![CDATA[Operators]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<guid isPermaLink="false">https://linuxcent.com/what-is-kubernetes-crd/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Learn what a Kubernetes CRD is, how custom resources extend the API server, and why every operator depends on them — with real kubectl examples for platform engineers.</p>
<p>The post <a href="https://linuxcent.com/what-is-kubernetes-crd/">What Is a Kubernetes CRD? How Custom Resources Extend the API</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>Kubernetes CRDs &amp; Operators: Extending the API, Episode 1</em><br />
<em><a href="/what-is-kubernetes-crd/">What Is a CRD?</a> · <a href="/kubernetes-custom-resources-examples/">CRDs You Already Use</a> · <a href="/kubernetes-crd-schema-explained/">CRD Anatomy</a> · <a href="/write-kubernetes-crd-yaml-walkthrough/">Write Your First CRD</a> · <a href="/kubernetes-crd-cel-validation/">CEL Validation</a> · <a href="/kubernetes-controller-reconcile-loop/">Controller Loop</a> · <a href="/build-kubernetes-operator-controller-runtime/">Build an Operator</a> · <a href="/kubernetes-crd-versioning-conversion-webhook/">CRD Versioning</a> · <a href="/kubernetes-admission-webhooks-explained/">Admission Webhooks</a> · <a href="/kubernetes-crd-production-finalizers-conditions-rbac/">CRDs in Production</a></em></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>A Kubernetes CRD (Custom Resource Definition) is how you add new resource types to the Kubernetes API — the same way <code class="" data-line="">Deployment</code> and <code class="" data-line="">Service</code> exist natively, you can make <code class="" data-line="">BackupPolicy</code> or <code class="" data-line="">Certificate</code> exist too<br />
  <em>(CRD = the schema/blueprint; Custom Resource = an instance of that schema, just like a Pod is an instance of the Pod schema)</em></li>
<li>Every <code class="" data-line="">kubectl get crds</code> on a real cluster shows dozens of them — cert-manager, KEDA, Prometheus Operator, Crossplane all ship their own CRDs</li>
<li>CRDs are served by the same API server as built-in resources — <code class="" data-line="">kubectl</code>, RBAC, watches, and events all work identically</li>
<li>A CRD alone does nothing — a <strong>controller</strong> watches the custom resources and acts on them; together they form an <strong>Operator</strong></li>
<li>CRDs live in etcd just like Pods and Deployments — they survive API server restarts and cluster upgrades</li>
<li>You do not need to modify Kubernetes source code or restart the API server to add a CRD</li>
</ul>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">  HOW KUBERNETES CRDs EXTEND THE API

  ┌──────────────────────────────────────────────────────────────┐
  │  Kubernetes API Server                                       │
  │                                                              │
  │  Built-in resources          Custom resources (via CRD)      │
  │  ─────────────────           ──────────────────────────      │
  │  Pod                         Certificate     (cert-manager)  │
  │  Deployment                  ScaledObject    (KEDA)          │
  │  Service                     ExternalSecret  (ESO)           │
  │  ConfigMap                   BackupPolicy    (your team)     │
  │  ...                         ...                             │
  │                                                              │
  │  All resources: same API, same kubectl, same RBAC, same etcd │
  └──────────────────────────────────────────────────────────────┘
            ▲                          ▲
            │ built in                 │ registered at runtime
            │                         │
         Kubernetes              CustomResourceDefinition
          binary                    (a YAML you apply)
</code></pre>
<p>What is a Kubernetes CRD? It is a resource that defines resources — a schema registration that teaches the API server about a new object type you want to use in your cluster.</p>
<hr />
<h2 id="what-problem-crds-solve">What Problem CRDs Solve</h2>
<p>Kubernetes ships with roughly 50 resource types: Pods, Deployments, Services, ConfigMaps, Secrets, PersistentVolumes, and so on. These cover the general-purpose building blocks for running containerized workloads.</p>
<p>But the moment you operate real infrastructure, you hit the edges. You want to express:</p>
<ul>
<li>&#8220;This database should have three replicas with point-in-time recovery enabled&#8221; — not a Deployment</li>
<li>&#8220;This TLS certificate for api.example.com should renew 30 days before expiry&#8221; — not a Secret</li>
<li>&#8220;This queue consumer should scale to zero when the queue is empty&#8221; — not a HorizontalPodAutoscaler</li>
</ul>
<p>Before CRDs (pre-2017), the only options were: use ConfigMaps as a poor substitute (no schema, no validation, no dedicated RBAC), or fork Kubernetes and add the resource natively (impractical for everyone outside the core team).</p>
<p>CRDs, introduced as stable in Kubernetes 1.16, solved this by letting you register a new resource type with the API server at runtime — without touching Kubernetes source code, without restarting the API server, without any special access beyond being able to create cluster-scoped resources.</p>
<hr />
<h2 id="the-kubernetes-api-a-brief-mental-model">The Kubernetes API: A Brief Mental Model</h2>
<p>Before CRDs make sense, the API model needs to be clear.</p>
<pre><code class="" data-line="">  KUBERNETES API STRUCTURE

  apiVersion: apps/v1       ← API group (apps) + version (v1)
  kind: Deployment          ← resource type
  metadata:
    name: web               ← instance name
    namespace: default      ← namespace scope
  spec:
    replicas: 3             ← desired state
</code></pre>
<p>Every Kubernetes resource has:<br />
&#8211; A <strong>group</strong> (e.g., <code class="" data-line="">apps</code>, <code class="" data-line="">batch</code>, <code class="" data-line="">networking.k8s.io</code>) — or no group for core resources<br />
&#8211; A <strong>version</strong> (e.g., <code class="" data-line="">v1</code>, <code class="" data-line="">v1beta1</code>)<br />
&#8211; A <strong>kind</strong> (e.g., <code class="" data-line="">Deployment</code>, <code class="" data-line="">Pod</code>)<br />
&#8211; A <strong>scope</strong>: namespaced or cluster-wide</p>
<p>The API server is a registry. Each group/version/kind combination maps to a Go struct that knows how to validate, store, and serve that resource type.</p>
<p>A CRD registers a new entry in that registry. You supply the group, version, kind, and schema. The API server handles everything else — serving it via REST, storing it in etcd, exposing it to <code class="" data-line="">kubectl</code>.</p>
<hr />
<h2 id="what-a-crd-looks-like">What a CRD Looks Like</h2>
<p>Here is the smallest possible CRD — it creates a new <code class="" data-line="">BackupPolicy</code> resource type in the <code class="" data-line="">storage.example.com</code> API group:</p>
<pre><code class="" data-line="">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: backuppolicies.storage.example.com
spec:
  group: storage.example.com
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                schedule:
                  type: string
                retentionDays:
                  type: integer
  scope: Namespaced
  names:
    plural: backuppolicies
    singular: backuppolicy
    kind: BackupPolicy
    shortNames:
      - bp
</code></pre>
<p>Apply it:</p>
<pre><code class="" data-line="">kubectl apply -f backuppolicy-crd.yaml
</code></pre>
<p>Now create an instance:</p>
<pre><code class="" data-line="">apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
  name: nightly
  namespace: default
spec:
  schedule: &quot;0 2 * * *&quot;
  retentionDays: 30
</code></pre>
<pre><code class="" data-line="">kubectl apply -f nightly-backup.yaml
kubectl get backuppolicies
kubectl get bp            # shortName works
kubectl describe bp nightly
</code></pre>
<p>The API server validates the spec against the schema, stores it in etcd, and returns it via all the standard API endpoints — all without a single line of custom code.</p>
<hr />
<h2 id="crd-vs-built-in-resource-what-is-different">CRD vs Built-In Resource: What Is Different?</h2>
<p>Not much, deliberately.</p>
<table>
<thead>
<tr>
<th>Capability</th>
<th>Built-in resource</th>
<th>Custom resource (CRD)</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">kubectl get / describe / delete</code></td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>RBAC (Roles, ClusterRoles)</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Watch (informers, events)</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Stored in etcd</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>OpenAPI schema validation</td>
<td>Yes</td>
<td>Yes (you define the schema)</td>
</tr>
<tr>
<td>Admission webhooks</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Status subresource</td>
<td>Yes</td>
<td>Optional (you enable it)</td>
</tr>
<tr>
<td>Scale subresource</td>
<td>Yes</td>
<td>Optional (you enable it)</td>
</tr>
<tr>
<td>Built-in controller behavior</td>
<td>Yes</td>
<td>No — you write the controller</td>
</tr>
</tbody>
</table>
<p>The last row is the critical one. When you create a <code class="" data-line="">Deployment</code>, the deployment controller immediately starts managing ReplicaSets. When you create a <code class="" data-line="">BackupPolicy</code>, nothing happens — until you write and deploy a <strong>controller</strong> that watches <code class="" data-line="">BackupPolicy</code> objects and acts on them.</p>
<p>That controller + the CRD is what people call an <strong>Operator</strong>.</p>
<hr />
<h2 id="a-real-cluster-what-you-actually-see">A Real Cluster: What You Actually See</h2>
<p>Run this on any cluster running cert-manager, Prometheus Operator, or any other tooling:</p>
<pre><code class="" data-line="">kubectl get crds
</code></pre>
<p>Sample output (abbreviated):</p>
<pre><code class="" data-line="">NAME                                                  CREATED AT
certificates.cert-manager.io                          2024-11-01T08:12:00Z
certificaterequests.cert-manager.io                   2024-11-01T08:12:00Z
issuers.cert-manager.io                               2024-11-01T08:12:00Z
clusterissuers.cert-manager.io                        2024-11-01T08:12:00Z
scaledobjects.keda.sh                                 2024-11-01T08:13:00Z
scaledjobs.keda.sh                                    2024-11-01T08:13:00Z
externalsecrets.external-secrets.io                   2024-11-01T08:14:00Z
prometheuses.monitoring.coreos.com                    2024-11-01T08:15:00Z
servicemonitors.monitoring.coreos.com                 2024-11-01T08:15:00Z
</code></pre>
<p>Every tool that ships as a CRD-based system registers its resource types here first. The count often surprises engineers: a production cluster with a typical toolchain easily has 40–80 CRDs.</p>
<p>Check how many are on your cluster:</p>
<pre><code class="" data-line="">kubectl get crds --no-headers | wc -l
</code></pre>
<hr />
<h2 id="how-the-api-server-handles-a-crd">How the API Server Handles a CRD</h2>
<p>When you apply a CRD, the API server does three things:</p>
<pre><code class="" data-line="">  CRD REGISTRATION FLOW

  kubectl apply -f my-crd.yaml
          │
          ▼
  1. API server validates the CRD manifest
     (is the schema valid OpenAPI v3? are names correct?)
          │
          ▼
  2. CRD stored in etcd
     (under /registry/apiextensions.k8s.io/customresourcedefinitions/)
          │
          ▼
  3. New REST endpoints activated immediately:
     GET  /apis/storage.example.com/v1alpha1/namespaces/{ns}/backuppolicies
     POST /apis/storage.example.com/v1alpha1/namespaces/{ns}/backuppolicies
     ...
</code></pre>
<p>From this point, any <code class="" data-line="">kubectl get backuppolicies</code> or API call to those endpoints is handled exactly like a built-in resource call — the API server serves it from etcd, applies RBAC, runs admission webhooks, and returns standard JSON.</p>
<p>No restart required. The new endpoints appear within seconds.</p>
<hr />
<h2 id="the-difference-between-crd-and-cr">The Difference Between CRD and CR</h2>
<p>Two terms that are easily confused:</p>
<ul>
<li><strong>CRD (CustomResourceDefinition)</strong> — the schema/blueprint. There is one CRD per resource type. <code class="" data-line="">certificates.cert-manager.io</code> is a CRD.</li>
<li><strong>CR (Custom Resource)</strong> — an instance of a CRD. Every <code class="" data-line="">Certificate</code> object you create is a custom resource. You can have thousands of CRs per CRD.</li>
</ul>
<pre><code class="" data-line="">  CRD (one)          →  Custom Resource (many)
  ─────────             ─────────────────────
  certificates          web-tls           (namespace: production)
  .cert-manager.io      api-tls           (namespace: production)
                        admin-tls         (namespace: staging)
                        ...
</code></pre>
<p>The CRD is applied once (usually by the tool&#8217;s Helm chart). Custom resources are created by your users, your CI pipeline, or your GitOps system throughout the life of the cluster.</p>
<hr />
<h2 id="where-crds-fit-in-the-kubernetes-extension-model">Where CRDs Fit in the Kubernetes Extension Model</h2>
<p>CRDs are one of three ways to extend Kubernetes:</p>
<pre><code class="" data-line="">  KUBERNETES EXTENSION MECHANISMS

  1. CRDs + Controllers (Operators)
     Add new resource types + behavior
     → cert-manager, KEDA, Argo CD, Crossplane
     Used for: domain-specific abstractions, infrastructure management

  2. Admission Webhooks
     Intercept API requests to validate or mutate objects
     → OPA/Gatekeeper, Kyverno, Istio injection
     Used for: policy enforcement, sidecar injection, defaulting

  3. API Aggregation (AA)
     Register a fully separate API server behind the main API server
     → metrics-server, custom autoscalers
     Used for: when you need non-CRUD semantics (e.g. exec, attach, streaming)
</code></pre>
<p>For 95% of use cases, CRDs + controllers are the right mechanism. API aggregation is complex and only warranted for non-standard API semantics. Admission webhooks are complementary to CRDs, not an alternative.</p>
<hr />
<h2 id="common-mistakes"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes</h2>
<p><strong>Confusing the CRD with the controller.</strong> The CRD is just a schema registration — it does not execute code. If you apply a CRD but do not deploy its controller, creating custom resources will succeed (the API server accepts them) but nothing will happen. This catches many people the first time they try to use cert-manager by only applying the CRDs without installing the cert-manager controller.</p>
<p><strong>Assuming CRD deletion is safe.</strong> Deleting a CRD deletes all custom resources of that type from etcd. There is no &#8220;are you sure?&#8221; prompt. If you delete the <code class="" data-line="">certificates.cert-manager.io</code> CRD, every <code class="" data-line="">Certificate</code> object in every namespace is gone.</p>
<p><strong>Treating CRDs as ConfigMap replacements.</strong> Some teams store configuration in CRDs purely to get schema validation. This works, but without a controller, the custom resources are inert data. If you only need configuration storage with validation, a CRD is viable — just be explicit that there is no reconciliation loop.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<pre><code class="" data-line=""># List all CRDs in the cluster
kubectl get crds

# Inspect a specific CRD&#039;s schema
kubectl get crd certificates.cert-manager.io -o yaml

# List all custom resources of a type
kubectl get certificates -A

# Get details on a specific custom resource
kubectl describe certificate web-tls -n production

# Delete a CRD (WARNING: deletes all instances)
kubectl delete crd backuppolicies.storage.example.com

# Check if a CRD is established (ready to use)
kubectl get crd backuppolicies.storage.example.com \
  -o jsonpath=&#039;{.status.conditions[?(@.type==&quot;Established&quot;)].status}&#039;
# Returns: True
</code></pre>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A Kubernetes CRD registers a new resource type with the API server — no source code changes, no restart required</li>
<li>Custom resources behave identically to built-in resources: <code class="" data-line="">kubectl</code>, RBAC, watches, etcd, admission webhooks all work the same way</li>
<li>The CRD is just the schema; a controller gives custom resources behavior — together they form an Operator</li>
<li>Every production cluster running modern tooling already uses dozens of CRDs</li>
<li>Deleting a CRD deletes all its instances — treat CRDs as production-critical objects</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p><a href="/kubernetes-custom-resources-examples/">EP02: CRDs You Already Use</a> makes this concrete before we go deeper — we walk through cert-manager&#8217;s <code class="" data-line="">Certificate</code>, KEDA&#8217;s <code class="" data-line="">ScaledObject</code>, and External Secrets&#8217; <code class="" data-line="">ExternalSecret</code> as working examples, so you understand what a well-designed CRD looks like from a user&#8217;s perspective before you design your own.</p>
<p>Get EP02 in your inbox when it publishes → <a href="https://linuxcent.com">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&amp;linkname=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fwhat-is-kubernetes-crd%2F&#038;title=What%20Is%20a%20Kubernetes%20CRD%3F%20How%20Custom%20Resources%20Extend%20the%20API" data-a2a-url="https://linuxcent.com/what-is-kubernetes-crd/" data-a2a-title="What Is a Kubernetes CRD? How Custom Resources Extend the API"></a></p><p>The post <a href="https://linuxcent.com/what-is-kubernetes-crd/">What Is a Kubernetes CRD? How Custom Resources Extend the API</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/what-is-kubernetes-crd/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1675</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-07-26 11:04:20 by W3 Total Cache
-->