CNPE — Cloud Native Platform Engineer
A CRD Is a Contract, Not a Feature
One-line summary
A CRD registers a new kind of object with the API. To complete a platform contract, you must also decide the values it will accept, the changes it will allow, the versions it will support, and who writes the status. A successful object creation is not the same as the service being ready.
Why this was needed
The following is a practice scenario. A team created an AppClaim to request a database. kubectl apply succeeded, but there is no address to connect to. The API merely stored the request, and there is not yet a controller that creates the actual database. Applying the CRD again is not the fix here. First separate which stage is missing: intake or reconciliation.
A CRD by itself does not create workloads. Only when you combine it with a controller does something work to bring the user's desired state to the actual state. This lab covers the CRD, validation, RBAC, and quota, and does not include implementing the AppClaim controller. Official Custom Resources concepts
How it works
The schema checks shape, and CEL checks relationships
The fact that a type is integer does not guarantee replicas <= maxReplicas. For example, 9 and 4 are each integers, but the requested number is greater than the cap. Place the rule below at the location in the spec schema that contains both fields. This is a fragment to place at that location, not the whole CRD.
type: object
required: [tier, replicas, maxReplicas]
properties:
tier:
type: string
enum: [bronze, silver, gold]
replicas: {type: integer, minimum: 1}
maxReplicas: {type: integer, minimum: 1}
x-kubernetes-validations:
- rule: "self.replicas <= self.maxReplicas"
message: "replicas는 maxReplicas 이하여야 합니다"
required checks for missing fields at that location, enum checks the set of allowed values, and CEL checks the relationship between two values. If the API requires the spec itself to be present, you must also declare required: [spec] in the parent object schema. A required at a lower level does not make the parent object required. Official CRD schema and validation
The location of an immutability rule determines the comparison scope
In a default transition rule, oldSelf means the corresponding previous value. A rule that does not use optionalOldSelf, as in this lab, is skipped on creation because there is no previous value. self.tier == oldSelf.tier at the spec location compares only the tier. If you use self == oldSelf at the same location, the whole spec must be the same, so it blocks even a normal replicas change. Conversely, self == oldSelf placed on the tier field itself compares only tier. Rather than memorizing the string, check the location where the rule is attached. Official CEL transition rule explanation
Immutability is not a concept that only CEL can implement. Here we chose the CEL built into the CRD, and there are also designs that use separate admission validation. Also, in recent Kubernetes, optionalOldSelf: true evaluates the rule even when there is no previous value and changes oldSelf to an Optional type. So generalizing to "a rule with oldSelf is never run on creation" is wrong. Conditions and behavior of optionalOldSelf
Supported versions and the storage version are different promises
| Item | Meaning | What this alone does not guarantee |
|---|---|---|
| served | Provides the API path for that version | The same validation rules as other versions |
| storage | The storage version used for new writes, exactly one | Completion of bulk conversion of existing objects |
| conversion | How representations are converted between versions | Creating external services or replacing policy validation |
Reading through two versions does not create two objects. It reads an object with the same namespace/name in a different API representation. Even if you change the storage version, existing stored objects are not all automatically rewritten. Removing an old version is work that includes confirming client migration, stored data migration, and cleanup of status.storedVersions. Serving an old version forever unconditionally is not the right answer, and neither is turning it off at the same moment you add a new version.
The default conversion.strategy: None does not implement a field rename for you. If the contract renames size to capacity, you need a separate conversion design. While the old version's API is open, check both valid input and forbidden input through that version as well. Official versioning and removal procedure
status is not a separate object but a separate write path
If you turn on subresources.status: {}, POST, PUT, and PATCH to the regular object ignore status changes. Changes sent to /status ignore changes other than status. This separation is a device for dividing who writes the desired values and who writes the observed values. It does not make status get filled in automatically or verify that the text "Ready" is true. Official status subresource contract
So you design separately the permission for users to modify the spec and the permission for the controller to modify the status. additionalPrinterColumns only displays values so that people can check quickly, and does not replace the controller that computes those values.
What it looks like in the field
When we checked an existing LabHub lab on a real k3s API, v1 rejected replicas=9, maxReplicas=4 and tier=platinum, but v1alpha1 allowed the same requests. The cause was that only v1's rules were required because the storage version was v1. We fixed it so that it checks the schemas of both served versions and real requests together.
The key question in this case is not "are the rules in one place?" but "do all the API paths a user can use honor the same contract?" In the validation list, put side by side a healthy creation per version, an invalid capacity, an undefined tier, a rejected tier change, and an allowed replicas change. If you check only rejections, you miss a wrong rule that blocks every request.
What to do in the next lab
You define the AppClaim contract for two versions and send direct server dry-run requests. Confirm that valid values pass and invalid values are rejected with a validation error on the relevant field. Do not call it validation success merely because a command failed. The next reading covers a separate topic, a principal's permissions and the cap on the number issued.