Skip to content

PATTERN Cited by 1 source

Reuse runtime logic on admission path

Problem

A control-plane resource (Kubernetes Ingress / RouteGroup, a CRD, a config-map) carries a domain-specific DSL in an annotation, string field, or opaque payload. The API server can validate the shape of the resource (is this an object, are the required fields present, is the type correct) but has no knowledge of the DSL itself. So a manifest can be syntactically valid YAML containing a reference to a predicate, filter, function, or identifier that does not exist — or exists but has wrong argument types — and the API server will happily write it to etcd.

The broken object then propagates to whichever controller reads it at runtime. The failure surfaces there: in request logs, in a rejected request, in an alert — far from the kubectl apply that introduced the mistake, often to a different team's inbox. Fixing it requires bisecting back to the original change.

A naïve solution is to write a second validator — a standalone schema checker, an OPA policy, a CI lint — that encodes the DSL's rules. That validator will slowly diverge from the runtime's rules: features land in the runtime, fixes land in the runtime, edge-cases land in the runtime; the secondary validator lags. Eventually the two disagree: the CI/admission validator rejects something the runtime would accept, or vice versa. Both outcomes are worse than no validator at all.

Solution

Treat the runtime's validator as a library and call it from the admission path. When a validating admission webhook receives an AdmissionReview, have it extract the domain-specific payload and pass it to the same validation code the production binary uses at request time. The answer the webhook returns is by construction the same answer the runtime would give — they cannot drift because they are literally the same code.

Operational shape:

  1. The production code organises its validator as a callable module (filter registry lookup, predicate spec check, route parser, backend parser), not tangled into request- handling.
  2. A thin admission-webhook adapter takes the AdmissionReview JSON, extracts the domain payload (e.g. the zalando.org/skipper-predicate annotation from an Ingress), calls the validator, and turns its result into an AdmissionReview allow/deny response.
  3. The same binary can run in either mode (runtime or webhook) or the validator lives in a shared library that both binaries import.
  4. A feature flag scoping the new strictness (so baseline validation keeps working if the new rules over-reject) is kept on the webhook side.

The contract the pattern guarantees: if the admission path accepts an object, the runtime path will accept the same object; if the admission path rejects it, the runtime path would also reject it. Drift is structurally impossible.

Why "just write a second validator" loses

Two validators means two places to fix bugs, two places to add features, two places for tests to cover. In practice:

  • The runtime validator is the one that must be correct — it's between real traffic and real user impact. So that's where engineering attention goes.
  • The admission-side validator becomes the lagging copy. It accumulates false positives (rejects things the runtime accepts) until teams start filing tickets, and false negatives (accepts things the runtime rejects, so the original problem surfaces again).
  • Eventually someone gives up on the admission-side validator, rolls it back, and the system is back to the original no-admission-validation state — but now with more scar tissue.

The pattern avoids this by making the admission-side validator not a copy. There is only one validator; it just has two call sites.

When not to use

  • The runtime's validator requires cluster-wide state to decide. E.g. "is this key unique across the keyspace" — a runtime validator that does that has to query etcd or the datastore; at admission-time scale that is too slow or introduces cross-object serialization issues a webhook is poorly suited for.
  • The runtime's validator is side-effectful. A validator that mutates state while validating cannot be called at admission time without consequences. Separation of pure validation from side-effects is a prerequisite for using this pattern.
  • The runtime is in a language the API server's webhook infrastructure can't conveniently embed. If the runtime is in language X and the webhook host is constrained to language Y, the library-sharing model breaks; you may have to run the validator as a separate service the webhook calls over RPC, which is still one implementation and qualifies, but adds latency.

When to prefer policy engines (OPA/Kyverno) instead

Use a policy engine when the rules you want to enforce are cross-cutting, cluster-wide, not domain-DSL-specific — things like "pods must declare resource limits," "no privileged containers in namespace X," "images must be signed." These rules are not in any runtime binary's validator; the policy engine is their canonical home. This pattern is specifically for the case where the runtime already knows how to validate the DSL, and the question is just who else should call that code.

Seen in

  • sources/2026-04-08-zalando-rejecting-invalid-ingress-routes-at-apply-timecanonical instance. Zalando's Skipper HTTP router already has a full validator: its filter registry (list of callable filters), predicate specifications (allowed predicate names and argument types), route parser (eskip DSL), and backend parser (URL and service reference validation). The ingress-admitter.teapot.zalan.do webhook calls the exact same validator on the AdmissionReview for any Ingress or RouteGroup. Zavodskikh's framing: "The webhook no longer answers: 'Does this string parse?' — instead it answers: 'Would Skipper accept this route?' That is the difference between basic admission checks and admission- time route validation." At 250+ clusters × ~200k routes × 15k Ingresses, the pattern is what makes the admission-time validator practically maintainable: every time Skipper gains a new filter or predicate, the admission-time check updates automatically; there is nothing to sync. Paired with -enable-advanced-validation for fast rollback and skipper_route_invalid{route_id, reason} as the rollout gate. Shipped in open-source Skipper v0.24.18, so every Skipper operator gets the same architectural shape.
Last updated · 507 distilled / 1,218 read