The Exam Blueprint · D3 · Platform APIs, CRDs & Operators · 25%

Platform APIs, CRDs & Operators

Kubernetes isn’t just a place to run containers — it’s a platform for building platforms. Its API is extensible: teach it brand-new nouns like kind: Database or kind: Environment, then write a tireless program that makes them come true. This lesson is the first half of the exam’s Platform APIs domain: designing a Custom Resource Definition, how the operator/controller pattern turns one line into real infrastructure, and choosing a framework. The caterpillar-to-butterfly move: ugly YAML in, clean self-service API out.

☺ Explain it like I’m 10

Kubernetes comes with a fixed set of Lego bricks — Pods, Services, Deployments. But you can invent your own brick and teach Kubernetes what it means: “a Database brick.” Then you write a helpful robot that watches for them and builds the real database behind each — storage, network, password — fixing it forever if anything breaks. Your developer snaps in one brick — “I want a medium Postgres” — and the robot does the other 200 steps. That invented brick is a CRD; the robot is an operator.

🦋🤖Your hosts for this topic: Mira the Butterfly & Recon the Robot — Mira turns the ugly YAML “caterpillar” into a beautiful self-service “butterfly” (the new API), and Recon is the reconcile loop inside the operator who makes every request come true and stay true.

Why extend the Kubernetes API at all

☺ Like you’re 10: Instead of a 200-page manual, you hand your friend one button: “make me a sandwich.” The platform is the buttons you invent.

The mental shift that unlocks the domain: the platform you’re building isn’t a website or a tool — it’s a set of APIs. When Dot wants a database, the truth is a pile of tightly-coupled Kubernetes objects: a StatefulSet, a headless Service, a PersistentVolumeClaim, a Secret, a ConfigMap, a backup CronJob, maybe a NetworkPolicy. Two hundred lines of YAML Dot must copy, keep consistent, and never get wrong — a caterpillar: functional, but not something you’d hand a developer.

Extending the API hides all that behind a new resource type developers write. Kubernetes was built for this: the API server treats custom types as first-class citizens, so they get the same kubectl get, RBAC, audit log, and GitOps reconciliation as a built-in Pod. You’re not bolting a portal onto Kubernetes — you’re teaching it your platform’s vocabulary.

◆ Key idea

A CRD is a new noun; an operator is the verb that makes it real. Together they turn “file a ticket and wait” into “write eight lines of YAML and get a database.” Everything else here — Crossplane, Backstage, self-service — builds on this primitive.

CRDs: teaching Kubernetes a new noun

☺ Like you’re 10: A CRD is the dictionary entry: it tells Kubernetes “a Database is a real word now, and here’s what a valid one looks like.”

A CustomResourceDefinition (CRD) is itself a Kubernetes object: kubectl apply it once and the API server serves a new endpoint. Register Database and you instantly get kubectl get databases, create/update/delete, watch, and RBAC verbs — no code required to store and validate objects. The CRD is the schema and front door; the operator gives the noun behaviour.

Anatomy of a CustomResourceDefinition

The CRD names your type and its shape. A few exam rules: metadata.name must be exactly <plural>.<group>; scope is Namespaced or Cluster (global, like a Node); and every served version carries its own schema. Here’s a real Database, with the pieces the exam loves — an OpenAPI schema, additionalPrinterColumns, the /status subresource, shortNames, and categories:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.platform.acme.io        # MUST be <plural>.<group>
spec:
  group: platform.acme.io
  scope: Namespaced                        # or Cluster
  names:
    kind: Database
    plural: databases
    singular: database
    shortNames: [db]                       # kubectl get db
    categories: [platform, acme]           # kubectl get platform → shows all our CRs
  versions:
    - name: v1alpha1
      served: true                         # this version is reachable via the API
      storage: true                        # exactly ONE version is the stored one
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [engine, size]
              properties:
                engine:  { type: string, enum: [postgres, mysql] }
                version: { type: string, default: "16" }
                size:    { type: string, enum: [small, medium, large], default: small }
                highAvailability: { type: boolean, default: false }
            status:                        # the operator writes here, not the user
              type: object
              properties:
                phase:    { type: string }
                endpoint: { type: string }
                conditions:                # the standard metav1.Condition array
                  type: array
                  x-kubernetes-list-type: map
                  x-kubernetes-list-map-keys: [type]
                  items:
                    type: object
                    required: [type, status, lastTransitionTime, reason, message]
                    properties:
                      type:               { type: string }
                      status:             { type: string, enum: ["True", "False", "Unknown"] }
                      reason:             { type: string }
                      message:            { type: string }
                      lastTransitionTime: { type: string, format: date-time }
      subresources:
        status: {}                         # status gets its own endpoint; main writes ignore it
      additionalPrinterColumns:
        - { name: Engine, type: string, jsonPath: .spec.engine }
        - { name: Size,   type: string, jsonPath: .spec.size }
        - { name: Phase,  type: string, jsonPath: .status.phase }
        - { name: Age,    type: date,   jsonPath: .metadata.creationTimestamp }

The OpenAPI schema — validation & defaulting for free

☺ Like you’re 10: The schema is bouncer and helper: it turns away nonsense (“engine: banana”) and fills in blanks you left empty (“no size? here’s ‘small’”).

The openAPIV3Schema earns a platform API its keep. In apiextensions.k8s.io/v1 it must be a structural schema (every field typed), enforced before your operator runs. Two powerful things for zero code: validation (enum, required, minimum/maximum, pattern, and CEL x-kubernetes-validations rules reject a bad Database at apply time) and defaulting (a default: is filled in on write, so version and size get sane values when Dot omits them). Bad requests bounce with a clear error, so your controller only sees well-formed objects. A structural schema also prunes: any field you didn’t declare is silently dropped on write — which is why every field your operator writes into status (conditions included) has to appear in the schema.

Printer columns, subresources, shortNames & categories

☺ Like you’re 10: Polish that makes your new brick feel store-bought: a nice table when you list them, and a safe “report card” only the robot writes on.

Four touches separate a toy CRD from a real platform API:

Versions & conversion

☺ Like you’re 10: Improve the brick’s design and old bricks still have to fit — a little translator converts between old and new shapes so nothing breaks.

APIs evolve. A CRD can serve multiple versions (say v1alpha1 and v1) at once, but exactly one is storage: true — the shape written to etcd. When the shapes differ, set spec.conversion.strategy: Webhook and run a conversion webhook that translates between versions on the fly, so old and new clients both work. That’s how you graduate an API from experimental to stable without a flag day. (If versions are identical, use strategy: None.)

⚠ Don’t CRD everything

Every CRD is a public API you own forever — you version, validate, document, and never casually break it. A CRD without a controller is a fancy database table. Reach for one only when there’s real behaviour and genuine reuse; for a one-off knob, a ConfigMap or Helm value is plenty. Over-abstraction — a bespoke CRD for every tiny thing — is Gizmo’s favourite trap: it recreates the cognitive load you set out to remove.

The custom resource: the butterfly Dot writes

☺ Like you’re 10: The CRD was the dictionary entry. A custom resource is an actual sentence in that new word — one real request.

Once registered, a custom resource (CR) is an instance of it — the friendly object a developer authors, the butterfly. Compare the eight lines below to the two hundred lines of StatefulSet-plus-friends they replace:

apiVersion: platform.acme.io/v1alpha1
kind: Database
metadata:
  name: orders-db
  namespace: checkout
spec:
  engine: postgres
  version: "16"
  size: medium
  highAvailability: true

Dot commits that to the config repo, Argo CD or Flux syncs it, and a minute later kubectl get db orders-db shows Phase: Ready with a connection endpoint. Dot never touched a StatefulSet, picked a storage class, or wrote a backup schedule — the platform absorbed it all.

🦆 Dot’s-eye view

“I used to keep a cursed 200-line ‘database.yaml’ I’d copy between projects, praying I updated every field. Now I write eight lines — engine, size, HA — and get a production-grade Postgres with backups and monitoring I didn’t know to ask for. The scary parts didn’t vanish — the platform team put them behind the word Database. My API got smaller and safer at once.”

The operator: giving your noun a verb

☺ Like you’re 10: The CRD lets you write down “I want a database.” The operator reads it, builds the database, and rebuilds any piece that breaks — forever.

A CRD alone stores and validates objects; nothing happens. An operator — a specialised controller — supplies the behaviour: the same reconcile loop that powers GitOps, aimed at your custom resource. It packages the know-how a human expert would apply, run in software day and night — “an SRE for one application, encoded as code.”

Recon’s loop, again: observe → diff → act

You met this loop in GitOps; it’s the same shape here — Recon doesn’t care whether desired state came from Git or a custom resource. It watches Database objects (desired), sees what exists (actual), diffs, and closes the gap. Crucially it is level-triggered, not edge-triggered: instead of firing once on “created,” it repeatedly drives actual toward desired. Delete the Service or lose a node, and the next reconcile quietly rebuilds it — self-healing comes free.

Database CR kind: Database desired state 🤖 operator observe · diff · act StatefulSet the pods + storage Service the endpoint Secret the credentials watch create / repair ownerRef write .status (Ready + endpoint) level-triggered: the loop runs forever — delete a child and it comes back next reconcile

Watches, informers & the work queue

☺ Like you’re 10: The robot doesn’t poll “anything new?” all day. It keeps a live notebook of every Database and gets tapped the instant one changes.

An operator doesn’t hammer the API server: it sets up watches fed by informers — a shared in-memory cache from a list-then-watch stream, so reads are cheap. When a Database or child changes, the informer drops a key onto a work queue that is rate-limited and de-duplicating: fifty rapid edits collapse into one reconcile; a failure re-queues with backoff. It also watches what it owns, so deleting the child Service re-enqueues the parent Database. That’s why operators scale to thousands of objects.

Owner references & garbage collection

☺ Like you’re 10: Each part the robot builds gets a tag: “I belong to orders-db.” Throw away orders-db and Kubernetes sweeps up everything wearing that tag.

When the operator creates the StatefulSet, Service, and Secret, it stamps each with an owner reference back to the Database. That wires them into Kubernetes’ built-in garbage collection: delete the Database and the cluster cascades to every owned child — no orphaned volumes, no leaked Services. The operator never hand-deletes; it just sets ownership and lets the platform clean up. Owner refs also tell it which objects are “its” to reconcile.

Finalizers & status conditions

☺ Like you’re 10: A finalizer is a “don’t toss this yet — one last chore” sticky note. Conditions are the report card the robot fills in: Ready? Yes/No, and why.

Owner-ref cleanup handles resources inside the cluster — but a real cloud database, or a final backup? That’s what finalizers are for. A finalizer is a string in metadata.finalizers; while it’s present, a delete doesn’t remove the object — it just sets a deletionTimestamp. The operator notices, runs cleanup (snapshot data, deprovision the external resource), removes its finalizer, and only then does the object disappear. Skip finalizers and you leak external state on kubectl delete.

Finally, the operator reports through status conditions — the standard type/status/reason/message array (Ready=True, Progressing=True, Degraded=False) that tools, dashboards, and kubectl wait --for=condition=Ready all understand. That’s how a platform tells Dot “provisioning… now ready,” machine-readably. Here’s the loop in controller-runtime shape, scaffolded by Kubebuilder:

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var db platformv1.Database
    if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)   // gone — owned children GC’d via ownerRefs
    }

    // Deleting? run finalizer cleanup, then let the object go.
    if !db.DeletionTimestamp.IsZero() {
        if err := r.takeFinalBackupAndDeprovision(ctx, &db); err != nil {
            return ctrl.Result{}, err                       // retry — object stays until we succeed
        }
        controllerutil.RemoveFinalizer(&db, dbFinalizer)
        return ctrl.Result{}, r.Update(ctx, &db)
    }
    // Finalizers live in metadata — adding one only counts once it is persisted.
    if controllerutil.AddFinalizer(&db, dbFinalizer) {
        if err := r.Update(ctx, &db); err != nil {
            return ctrl.Result{}, err
        }
    }

    // observe → diff → act: build desired children and apply them.
    sts := desiredStatefulSet(&db)
    controllerutil.SetControllerReference(&db, sts, r.Scheme)   // ownerRef → cascade delete
    if err := r.Patch(ctx, sts, client.Apply,
        client.FieldOwner("database-controller"), client.ForceOwnership); err != nil {
        return ctrl.Result{}, err                               // server-side apply the child
    }

    // report observed state back through .status conditions.
    meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
        Type: "Ready", Status: metav1.ConditionTrue, Reason: "Provisioned",
        Message: "database is serving connections",
    })
    db.Status.Phase = "Ready"
    return ctrl.Result{}, r.Status().Update(ctx, &db)       // level-triggered: re-runs on every change
}
◆ Key idea — operators encode Day-2

An operator’s value isn’t the Day-1 “create.” It’s Day-2 operational knowledge baked into the loop: version upgrades in the right order, scheduled backups, failover, resizing volumes, rotating credentials. That expertise used to live in a runbook and a tired human at 2am; the operator runs it continuously, every time. That’s the caterpillar becoming a butterfly.

Operator frameworks — what to build one with

☺ Like you’re 10: You don’t carve the robot from scratch. Use kits — some for robot-building experts, some where you write only the clever part.

You rarely hand-write the watch/informer/queue plumbing — you use a framework. The exam won’t quiz you on SDK flags, but you should know the landscape and, more importantly, when to reach for each:

FrameworkLanguage / approachReach for it when…
KubebuilderGo, on controller-runtime; scaffolds CRD + controllerYou want a full-control, production Go operator — the canonical, best-documented path.
Operator SDKGo (same core as Kubebuilder), or Ansible, or HelmYou want Kubebuilder’s Go path plus a no-code option: wrap an existing Helm chart or Ansible role as an operator, and OLM packaging.
KopfPython, decorator-based handlersYour team lives in Python and you want fast, readable glue automation without Go.
MetacontrollerAny language, via JSON webhooksYou want to write only the “given this parent, return these children” diff logic as a small web service — no controller boilerplate at all.

The rough rule: reach for Helm/Ansible to package an existing deployment with light lifecycle, Kubebuilder/Go when the logic is genuinely stateful and complex, and Kopf or Metacontroller for smaller automations where full Go is overkill.

Operator Capability Levels

☺ Like you’re 10: Robots come in five belts, from “can only install it” to “runs everything itself.”

The community Operator Capability Levels give you a maturity ladder for scoping how much operational knowledge to encode:

  1. Basic Install — provisions the app and its config.
  2. Seamless Upgrades — upgrades versions safely, in order.
  3. Full Lifecycle — backups, restores, failover, scaling.
  4. Deep Insights — emits metrics, alerts, and workload analysis.
  5. Auto Pilot — auto-scales, auto-tunes, auto-remediates without a human.
⚠ Watch out — leaky abstractions

A platform API is a promise, and every promise can leak. If your Database hides Postgres but a developer hits a Postgres-specific limit, they must understand both your abstraction and what’s underneath — worse than raw YAML. Keep resources thin over well-understood primitives, expose the escape hatches that matter, and don’t abstract what you don’t operate well. Remove toil; don’t build a mystery box nobody can debug.

Serving the API: admission & the request path

☺ Like you’re 10: Before your Database request is saved, it goes through a short security line: who are you, what may you do, tidy the form, is it valid?

Extending the API means understanding how a request is served. When Dot runs kubectl apply -f orders-db.yaml, the object travels a fixed path through the kube-apiserver before it’s stored — where a platform enforces its rules:

🦆 kubectl apply Database KUBE-APISERVER AuthN who are you AuthZ · RBAC may you? Mutating webhooks default & inject Schema validation OpenAPI structural Validating webhooks policy engines accept / deny etcd stored 🤖 operator watches & reconciles watch stream then builds the real resources →

Mutating webhooks run first and can change the object — inject a sidecar, set a default the schema can’t express, add a label. Then the OpenAPI schema validates structure. Then validating webhooks give a final yes/no — where policy engines like OPA/Gatekeeper and Kyverno live, enforcing rules like “every Database must set an owner label.” Only then is the object stored and your operator woken. Admission control is part of serving a platform API.

⚠ Webhook availability is a real risk

An admission webhook sits in the synchronous path of every matching write. If the webhook pod is down or slow with failurePolicy: Fail, you can wedge the whole API for that resource — nobody can create or update it. Keep webhooks fast and available, scope them tightly with namespaceSelector/objectSelector, set sane timeouts, and think hard before choosing Fail over Ignore. A guardrail that takes down the cluster isn’t a guardrail.

🦋 Mira’s workshop · 15 min

On a throwaway cluster (kind or minikube), apply a tiny CRD — kind: Greeting, a spec.message string, an enum on spec.language. Try to break it: kubectl apply a Greeting with language: klingon and watch the API server reject it before any controller exists — free OpenAPI validation. Add additionalPrinterColumns for .spec.message and re-list with kubectl get greetings; add shortNames: [greet] and confirm kubectl get greet works. You’ve shipped a (behaviour-free) platform API in fifteen minutes — only the operator is left.

🎬 At the Platform Guild
🦊

Foxy: Wait — if I make a Database CRD, do I get a real database? Where does the Postgres come from?

🦋

Mira: The CRD is just the word, Foxy — it teaches Kubernetes what a valid Database looks like. Nothing happens until there’s an operator to give the word a meaning.

🤖

Recon: That’s me. BEEP. I watch every Database, build the StatefulSet, Service, and Secret, stamp them as owned, and if one vanishes I rebuild it on the next loop. Observe, diff, act. Forever.

👺

Gizmo: Ugh, so much work. Just make a CRD for everything — Database, Cache, DNS, Coffee — and wrap a Bash script around it. Ship it! 🤑

🦋

Mira: A CRD with no reconcile loop is a database table with a costume, Gizmo. No self-healing, no Day-2, no cleanup — and every noun you invent is an API we support forever. Abstract what we operate well, nothing more.

🦆

Dot: Honestly I don’t care how the robot works. I write eight lines, I get a database with backups, and if I kubectl delete it, it cleans up after itself. That’s the platform I want.

CRDs and operators are the machinery under every self-service promise: the platform team encodes a hard operational job once — an API plus a loop — and hands developers a friendly noun. Next, Mira and Nutty add a storefront — self-service provisioning with Crossplane (just CRDs-and-controllers over cloud infrastructure) and a Backstage portal, so Dot picks a template instead of writing even eight lines. For how the planes fit together, keep the reference architecture and glossary close.

🦋 Build it for real

This lesson is the theory; Capstone Part 3 — a new noun for ledger is the hands-on twin — scaffold a real Kubebuilder CRD and operator for a LedgerDatabase, write the reconciler yourself, and prove it reconciles and self-heals a deleted child object on your own kind cluster.

🐢 Timmy’s checkpoint

1. What’s the difference between a CRD and a custom resource? 2. Name two things the OpenAPI openAPIV3Schema does for you with zero controller code. 3. What does the /status subresource give you, and why split it out? 4. An operator creates a StatefulSet for a Database. What makes the StatefulSet get deleted automatically when the Database is deleted — and what handles cleaning up an external cloud resource? 5. Why is “level-triggered” reconciliation self-healing? 6. Where in the API request path would you enforce “every Database must carry an owner label,” and what’s the risk of that webhook?

Check your answers
  1. A CRD (CustomResourceDefinition) defines a new type — the schema and API endpoint; a custom resource is an instance of that type, the actual object a developer writes (e.g. one Database named orders-db).
  2. Any two of: validation (enum, required, pattern, ranges, CEL rules reject bad objects at apply time) and defaulting (fills in omitted fields), plus structural typing — all enforced by the API server before your operator runs.
  3. It puts status on its own endpoint so users edit spec (desired) while the operator owns status (observed); it also stops status writes from bumping metadata.generation, so the controller can cheaply tell when the spec really changed.
  4. An owner reference on the StatefulSet triggers Kubernetes garbage collection (cascading delete); a finalizer holds the object open so the operator can deprovision the external resource / take a final backup before it’s removed.
  5. Because it repeatedly drives actual state toward the desired level rather than reacting to one-off events — so a deleted or drifted child is simply rebuilt on the next reconcile.
  6. In a validating admission webhook (e.g. Gatekeeper or Kyverno), which runs before the object is stored. The risk: it’s synchronous on every write, so a slow/down webhook with failurePolicy: Fail can wedge the API for that resource — keep it fast, HA, and tightly scoped.