In a Kyverno variable, why does an unquoted nodeSelector key containing dots fail to resolve?
answer
- the dot is an operator, not a character
- label keys carry dots and a slash
- quote the identifier inside the path
- unresolved is an error, not empty
- ConfigMap values arrive as strings
basics
~20 sKyverno substitutes double-brace expressions with JMESPath, which reads every dot as a step into a nested field. A key like topology.kubernetes.io/zone is parsed as three nested lookups that do not exist, so quote the whole key inside the path.
solid answer
~50 sInside `{{ }}` Kyverno evaluates JMESPath, and in JMESPath a dot is the field-separator operator, not a character in a name. So `spec.nodeSelector.topology.kubernetes.io/zone` is read as `topology`, then `kubernetes`, then `io/zone` - none of which exist under `nodeSelector`. The fix is to quote the whole key as one identifier: `spec.nodeSelector."topology.kubernetes.io/zone"`, which in YAML usually means a double-quoted scalar with the inner quotes escaped. Two related traps sit next to it. Kyverno treats a variable it cannot resolve as an error for that rule rather than as an empty value, which is why authors write `|| ''` to supply a default when the field is legitimately optional - and then add a precondition so absent-field requests are skipped rather than judged. And a ConfigMap value is a string, so `split(placement.data.zones, ',')` is what turns an allow list into something you can test membership against.
code
yaml · 18 lines...
# broken - JMESPath walks each dot as a step into a nested field, so
# this is read as topology -> kubernetes -> "io/zone", none of which
# exist: {{ request.object.spec.nodeSelector.topology.kubernetes.io/zone }}
context:
- name: placement
configMap:
name: allowed-placement # data.zones = "euw1-a,euw1-b"
namespace: platform-policy
validate:
message: "zone must be one of {{ placement.data.zones }}"
deny:
conditions:
all:
# quote the whole key; split the ConfigMap string into a list
- key: "{{ request.object.spec.nodeSelector.\"topology.kubernetes.io/zone\" || '' }}"
operator: AnyNotIn
value: "{{ split(placement.data.zones, ',') }}"go deeper
Know that double braces are JMESPath and that a dot means 'go one level deeper', so Kubernetes keys with dots in them must be quoted inside the path.
Be ready to write the corrected expression from memory, including the escaped quotes in YAML, and to say what happens when a variable does not resolve.
Show how you would catch it: an errored rule result in the report rather than a rejected policy, and a CLI run against a fixture before it ever reaches a cluster.
Push on why this class of bug is silent by default and what the team's standard shape should be - preconditions for applicability, defaults only where a field is genuinely optional.
## The substitution language Everything between `{{` and `}}` in a Kyverno policy is a JMESPath expression evaluated against a document that includes `request` (the admission request), any context entries you declared by name, and a few Kyverno-provided values. JMESPath is a query language for JSON, and its most basic operator is the dot: `a.b.c` means "field `a`, then field `b` inside it, then field `c` inside that". That is fine until the key itself contains dots. Kubernetes label and selector keys almost always do: ``` nodeSelector: topology.kubernetes.io/zone: euw1-a ``` Here `nodeSelector` has exactly one key, whose *name* is the eleven-character-plus string `topology.kubernetes.io/zone`. Written unquoted, `nodeSelector.topology.kubernetes.io/zone` asks JMESPath for a field called `topology`, then `kubernetes` inside that, then `io/zone` inside that. There is no `topology` field, so the expression yields nothing. ## Quoting JMESPath spells a literal identifier with double quotes: `nodeSelector."topology.kubernetes.io/zone"`. Because the surrounding YAML scalar is usually itself double-quoted, the quotes get escaped, and the line looks noisier than the idea behind it. The same applies to annotation keys, to label keys like `app.kubernetes.io/name`, and to any ConfigMap key with a dot in it - a very common case, since teams like `zones.euw1` style keys. ## Unresolved is an error, not empty The reason this matters beyond neatness is what Kyverno does with a variable it cannot resolve: it treats it as an error for that rule rather than substituting an empty value. That is deliberate - silently comparing against nothing is how a guardrail becomes decorative - but it means an optional field needs an explicit default. JMESPath's `||` returns its right side when the left is unresolved or falsy, so `{{ request.object.metadata.labels.owner || '' }}` yields an empty string when the label is absent. A default alone is often not the right answer, though. If the empty string then fails your membership test, every Pod without the field is refused. The clean shape is: a **precondition** that checks the field is present and skips the rule when it is not, and a deny condition that judges only the requests the rule is actually about. That splits "this rule does not apply" from "this request is bad", which are two very different outcomes for the developer on the other end. ## Strings, not lists The second half of the same class of bug is typing. A ConfigMap's `data` values are strings. `zones: "euw1-a,euw1-b"` resolves to one string of thirteen characters, not a two-element list. Two ways out: - `split(placement.data.zones, ',')` turns it into a list, which pairs with the `AnyIn` / `AnyNotIn` operators. - Store the value as a JSON array in the ConfigMap and let it be parsed as a list. What you must **not** do is reach for `contains()` on the raw string. In JMESPath, `contains` on a string is a substring test, so `contains("euw1-a,euw1-b", "euw1-")` is true - and a rule built that way accepts values that were never on the list. It is the sort of bug that only surfaces when someone tries the near-miss value, which by then is production. ## How you find these A policy with an unresolvable variable is accepted at creation - the structure is valid, the expression's target is not knowable until a request arrives. So the failure is a runtime one, visible as an error result for that rule in the policy report and in the controller's logs, not as a rejected policy. Testing with the Kyverno CLI against a fixture resource is the fast loop: it will show you the rule producing an error instead of the pass or fail you expected, which is the signal that the path, not the logic, is wrong.
- What does Kyverno do when a variable in a deny condition cannot be resolved?It treats the rule as errored rather than substituting an empty value, so the rule produces neither a pass nor a violation. That is the safer default - comparing against nothing would quietly allow everything - but it means genuinely optional fields need a `|| ''` default, usually paired with a precondition that skips requests the rule cannot judge.
- Why is contains() the wrong way to test a comma-separated allow list?In JMESPath, contains() over a string is a substring test, so a value that merely appears inside the joined list passes - a near-miss zone or pool name slips through. Split the string into a list first and use AnyIn or AnyNotIn, or store the value as a JSON array in the ConfigMap so it resolves as a list to begin with.
saying these in an interview costs you the question
- Thinks the dotted label key resolves as written
- Assumes an unresolved variable evaluates to empty
- Uses contains() on a joined string as a membership test
- Expects the API server to reject the policy at creation
- Adds a default without a precondition, blocking absent fields