Why can't an admission check enforce at most three LoadBalancer Services per namespace from the request alone?
answer
- one request, one write
- no siblings in the payload
- counting needs the other Services
- cache or a read, never the request
basics
~20 sAn admission request carries only the submitted object, its previous version, and the requesting user - not the namespace's other Services. Counting needs those siblings, so the rule must get them from somewhere outside the request.
solid answer
~50 sA webhook is called once per write, and the payload describes that one write: the object being admitted, `oldObject` on an update, the operation and resource, the namespace, the requesting user and groups, and a `dryRun` flag. It carries no other object and no cluster state, so a rule asking how many LoadBalancer Services already exist here has nothing to count. To answer, the engine needs siblings from elsewhere - typically a local copy of selected kinds kept current by watching the API, sometimes a read issued during the decision. That turns a self-contained field check into a cross-object rule, and cross-object rules are weaker: whatever the engine reads is a view of some earlier moment, not a lock. An in-process CEL expression policy cannot fetch anything at all; the only outside data it gets is a parameter object bound to the policy.
code
json · 14 lines{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "9f1c...",
"operation": "CREATE",
"resource": { "group": "", "version": "v1", "resource": "services" },
"namespace": "team-a",
"userInfo": { "username": "system:serviceaccount:team-a:deployer", "groups": ["..."] },
"dryRun": false,
"object": { "kind": "Service", "metadata": { "name": "edge" }, "spec": { "type": "LoadBalancer" } },
"oldObject": null
}
}go deeper
Be ready to list what an admission request contains - the object, its old version, the operation, the user, dryRun - and to say plainly that other objects are not in there.
Explain the two ways an engine gets siblings, a watched local copy or a read during the decision, and what each costs on the write path.
Show that you classify rules before writing them, and that you say out loud which of your rules are guarantees and which are best effort because they depend on outside state.
Own the position that cross-object invariants bought at admission are cheap but soft, and decide deliberately which invariants deserve a stronger mechanism than a per-request check.
## The request is one write, not a picture of the cluster Kubernetes admission is a callout on the write path. When a client creates or updates an object, the API server pauses that one request and asks the configured policy: is this allowed? The message it sends (an AdmissionReview) contains, in its `request`: - `operation` - CREATE, UPDATE, DELETE or CONNECT - `resource` / `subresource` - what kind is being written - `namespace` and `name` - `userInfo` - the requesting username, uid and groups - `object` - the object as it would be stored - `oldObject` - the previous version, on an update or a delete - `dryRun` - whether this request will actually be persisted - request options And that is the whole of it. There is no list of the namespace's other objects, no cluster state, no history of what was admitted yesterday, no record of who was blocked last week. This is the single fact policy authors most often get wrong, and it draws the line that this whole family of rules sits on. ## Self-contained rules versus cross-object rules A rule is self-contained when everything it needs is inside the submitted object: - every container sets a non-root user - the Service carries an owner label - the image reference is not a bare mutable tag Those are pure functions of `object` (sometimes of `object` and `oldObject` together, if the rule cares about what changed). They are cheap, deterministic, and easy to test: give the checker the JSON and it answers. A rule is cross-object when the answer depends on things the request does not contain: - at most three LoadBalancer Services per namespace - needs every other Service in that namespace - no two Ingresses claim the same host - needs every Ingress in the cluster - a Namespace must come with a companion policy object - needs an object that may not exist yet Counting, uniqueness and any invariant spanning more than one object all fall on this side. ## Where the siblings come from There are only two shapes, and neither is free: 1. The engine keeps a local copy of chosen kinds, populated and updated by watching the API server. The rule reads that copy. Fast on the write path, but it reflects a slightly earlier moment, and after an engine restart it is empty and filling. 2. The engine issues a read while deciding. Fresher, but it puts a synchronous round trip on the hot path of every matching write, and it is still a point-in-time read with no lock behind it. There is a third case worth knowing precisely: an in-process expression policy evaluated by the API server itself (a ValidatingAdmissionPolicy written in CEL) has no I/O at all. CEL expressions cannot open a connection or read storage. The only outside data such a policy receives is a parameter resource bound to it, which is a configured knob - a threshold, an allow-list - not a live view of the cluster. If your rule needs to count siblings, an expression-only policy is the wrong instrument. ## Why the distinction matters beyond authoring - **Strength.** A self-contained rule is an assertion about the object; a cross-object rule is an assertion about state the engine believed a moment ago. The first cannot be wrong; the second can. - **Cost.** Every count is work done inside somebody's `kubectl apply`. Replicating a hot, high-cardinality kind to satisfy one rule is a real memory and churn decision. - **Blind spots.** Whatever kinds you did not replicate simply are not there, and a rule that reads a missing kind gets nothing rather than an error you notice. - **Testing.** A self-contained rule is tested with one document. A cross-object rule needs the sibling state supplied explicitly in the fixture, and the interesting tests are the ones where that state is wrong. ## The check to run while authoring Ask one question about every rule you write: *if all I received were this one JSON document, could I answer yes or no?* If yes, keep it self-contained and it will behave. If no, name exactly what else you need and where it will come from - then accept that the rule is now best effort rather than a guarantee.
- Does oldObject help a counting rule at all?No. `oldObject` is the previous version of the same object, present on an update or a delete. It lets you write rules about what changed - a field that may be set once but never edited, for instance - but it is never another object, so it contributes nothing to a count of siblings.
- Give me two rules that are self-contained and two that are not.Self-contained: every container must set a non-root user; the Service must carry an owner label. Both are decided from the submitted object alone. Cross-object: at most three LoadBalancer Services per namespace; no two Ingresses may claim the same host. Both need objects the request does not carry, so the engine has to bring that state from elsewhere.
- What does the dryRun flag mean for a rule that counts?A dry-run request is evaluated but never persisted, so nothing it would have created ever appears in a later count. Treat a dry-run pass as feedback to the author, never as evidence that state changed - and make sure any side effect your rule performs is skipped when dryRun is true.
saying these in an interview costs you the question
- Says the webhook receives the whole namespace with the request
- Assumes oldObject holds other objects in the namespace
- Thinks the API server pre-computes counts for the rule
- Treats a cross-object count as exact as a field check
- Believes a pure expression policy can look up siblings