A CRD teaches the API server a new noun. That is all it does: storage, validation, RBAC integration, kubectl support and watch semantics, all inherited for free. Behaviour needs a controller (section 3.3). Splitting those two in your head (schema versus behaviour) is what makes the whole domain legible.

needsmake upmake api

Orientation

competency 3.1 · designing and creating CRDs

Everything in this section needs nothing installed; the whole point is that a CRD needs no controller to exist. Apply one and you immediately have a typed, validated, RBAC-aware, watchable, kubectl explain-documented API, with zero behaviour behind it. That gap is where platform engineering lives.

What a task looks like

"Create a CRD for X with required field Y, an enum, and a printer column; create one valid instance; show that an invalid one is rejected." Every clause is graded by an object or an error message. Typing a CRD from memory under time pressure is a genuine skill; do it by hand at least three times before exam day.

Anatomy, field by field that matters

group · names · scope · versions

group + names (plural, singular, kind, shortNames, categories) + scope (Namespaced or Cluster) + versions[]. The metadata name is not free-form: it must be exactly <plural>.<group>, and getting that wrong is the most common first error.

FieldWhy it matters
scopeNamespaced gets you RBAC per namespace and quota via count/<plural>.<group>. Cluster-scoped resources cannot be owned by namespaced ones, a real constraint in Crossplane (3.5).
names.shortNamescluster-global and first-come. Two CRDs claiming tb collide, and kubectl warns you. A platform API designer owns that namespace collision.
names.categoriesputs your kind into kubectl get all-style groupings. Cheap usability win.
versions[].servedwhether the API answers for this version at all
versions[].storageexactly one version is what etcd holds; the rest are converted on the fly
versions[].deprecatedemits a warning to clients using it, the polite way to sunset

Versioning, at exam depth: deprecating a version means served: true with storage moved on; removing it means served: false. That three-state dance is the entire story unless the schemas actually differ, in which case you need conversion: strategy: None (same shape, different name) or a Webhook converter (real transformation). Know that the choice exists and what each costs.

Schema: where design happens

The schema is OpenAPI v3: types, required, enum, pattern, minimum/maximum, default, format. Two extensions worth knowing by name:

  • x-kubernetes-validations: CEL rules with custom messages, evaluated by the API server. This is how you express cross-field constraints (self.max >= self.min) and immutability (self == oldSelf) without a webhook. The message you write is the message your user sees, so write it like a human.
  • x-kubernetes-preserve-unknown-fields: opts a subtree out of pruning. By default anything not in the schema is silently pruned on write, which looks like data loss and is actually the schema doing its job.
  • Honourable mentions: x-kubernetes-list-type: map with listMapKeys (makes server-side apply merge lists sanely instead of replacing them), and x-kubernetes-int-or-string.
Pruning is not an error

Apply a field the schema does not declare and it disappears with no complaint, no event, no warning by default. Users report "my setting is ignored"; the truth is it was never stored. This is why a permissive `preserve-unknown-fields` subtree is tempting and why a platform API mostly should not have one: you would be trading a clear rejection for an invisible mystery.

Subresources and presentation

  • subresources.status splits /status into its own endpoint, and that split cuts both ways: writes to the main resource then ignore the status stanza, and writes to /status ignore everything else. So the classic "my controller's status writes vanish" is the subresource working: the controller is calling Update() where it needs UpdateStatus(), or kubectl patch without --subresource=status. Without the subresource, status is just another field and anyone can clobber it, which is why every operator convention in section 3.3 assumes it is on.
  • subresources.scale makes kubectl scale and HPAs work against your kind; three JSONPaths and your CRD is autoscalable.
  • additionalPrinterColumns decides what kubectl get shows. A platform API without a useful READY column is user-hostile, per section 3.1.

Once applied, wait for the Established condition; until then, requests for the new kind may 404. After that the kind behaves like any built-in: RBAC rules can name it, quota can count/ it, admission webhooks and policy engines see it, and kubectl explain documents it from the descriptions you wrote. Descriptions are not decoration; they are the docs your users get.

Where CRDs stop

and what you reach for instead
  • Aggregated API servers: for when you need custom storage, huge object counts, or non-etcd backends. Named in the docs, almost never the answer.
  • Admission webhooks: for validation CEL cannot express (cross-object lookups) or mutation with logic. Every webhook is a new availability dependency for the whole API server; failurePolicy is where you choose which risk you prefer (section 5.2).
  • Operators: for behaviour. A CRD with no controller is a typed ConfigMap, which is occasionally exactly what you want (Crossplane's XRs, Argo's Applications and Tekton's Tasks are all "data with a controller elsewhere").
  • Generators: Crossplane's XRD and kro's ResourceGraphDefinition both generate CRDs for you. Sections 3.5 and 3.6 are exactly that: the same thing you hand-write here, produced by a higher-level API.
Command reflex on an unfamiliar cluster

kubectl api-resources | grep <tool> to find the nouns, kubectl explain <kind> --recursive to read the schema, kubectl get crd <name> -o yaml to see printer columns, versions and validation. Three commands and you can operate an operator you have never met, which is precisely the exam scenario.

Exercises

tick the dot when its check passes

A platform-flavoured example, typed out rather than pasted, because the exam gives you a task description, not a starting file:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: tenantbuckets.platform.lab.local
spec:
  group: platform.lab.local
  scope: Namespaced
  names: { plural: tenantbuckets, singular: tenantbucket, kind: TenantBucket, shortNames: [tb] }
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources: { status: {} }
      additionalPrinterColumns:
        - { name: Tier,     type: string,  jsonPath: .spec.tier }
        - { name: SizeGB,   type: integer, jsonPath: .spec.sizeGB }
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [tier]
              properties:
                tier:   { type: string, enum: [bronze, silver, gold], description: "Service tier; sets replication and backup policy." }
                sizeGB: { type: integer, minimum: 1, maximum: 500, default: 10 }
              x-kubernetes-validations:
                - rule: "self.tier != 'bronze' || self.sizeGB <= 50"
                  message: "bronze tier is capped at 50GB"
            status:
              type: object
              properties:
                phase: { type: string }

Apply it, then make the API server prove each design decision:

kubectl wait --for=condition=Established crd/tenantbuckets.platform.lab.local
kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: TenantBucket
metadata: { name: good, namespace: default }
spec: { tier: silver, sizeGB: 100 }
EOF
kubectl get tb    # printer columns show Tier and SizeGB, default filled in elsewhere

With cicd installed, that last command also prints a warning that tb could match Tekton's triggerbindings. Keep it; it is teaching you that short names are cluster-global and first-come, which is exactly the kind of collision a platform API designer owns.

verify: the rejections, which are the real test of the schema: tier: platinum must fail on the enum, sizeGB: 900 on the maximum, and tier: bronze, sizeGB: 100 on the CEL rule with your message in the error. Three different validators refusing three different ways; know which produced which text.

Apply a TenantBucket with an extra field spec.color: red, read it back, and observe the field is gone with no error. Then kubectl explain tenantbucket.spec --recursive and see your descriptions serving as live documentation.

verify: you can state the flag a schema author would set to keep unknown fields, and why a platform API mostly should not.

kubectl patch tenantbucket good --subresource=status --type=merge -p '{"status":{"phase":"Ready"}}', then confirm kubectl get tb good -o jsonpath='{.status.phase}' says Ready and that a plain spec-edit did not clear it.

verify: status survives spec edits and vice versa. This is the mechanism every operator in section 3.3 relies on.

kubectl get crd appenvironments.platform.lab.local -o yaml (Crossplane generated it from the lab's XRD). Compare its schema and printer columns against yours; note what a machine-generated platform API includes that your hand-rolled one lacks (conditions conventions, connection details).

verify: you can name three things to steal, and add at least one of them to your TenantBucket.

Self-check

answer before opening
A user says their field "does not work". kubectl get -o yaml shows it missing entirely, and no error was printed. What happened?

Pruning: the field is not in the schema, so the API server dropped it on write. Either add it to the schema or, rarely, mark that subtree with x-kubernetes-preserve-unknown-fields. The user experience argument is for adding it properly.

Your controller's status writes vanish. One likely cause?

The status subresource is enabled and the writer is going through the main endpoint, which ignores the status stanza. Use UpdateStatus() (or kubectl patch --subresource=status). The mirror-image bug also exists: with no subresource at all, a spec update from anyone overwrites the status your controller just wrote.

You must forbid changing spec.tier after creation. How, without a webhook?

A CEL validation on the field: rule: "self == oldSelf" with a message like "tier is immutable". x-kubernetes-validations is evaluated by the API server and can compare against the old object, which is exactly the immutability case.

You want to serve v1alpha1 and v1beta1 with different shapes. What do you need?

Both versions served, exactly one marked storage, and a conversion strategy: None only if the shapes are compatible, otherwise a conversion webhook. Everything read from etcd is stored in the storage version and converted on the way out.

What does a CRD give you for free, and what does it definitively not give you?

Free: persistence, validation, defaulting, RBAC integration, watch/informers, kubectl support and explain docs, quota counting, policy-engine visibility. Not free: any behaviour whatsoever. Nothing happens until a controller acts on the object.

Docs to know your way around

study time, not exam time
  • kubernetes.io: "Extend the Kubernetes API with CustomResourceDefinitions" (one long page covering versions, pruning, CEL validation and defaults), and "Versions in CustomResourceDefinitions".
  • Offline: kubectl explain crd.spec.versions --recursive when you forget field placement, which everyone does; kubectl get crd <name> -o yaml to learn from an operator's own schema.