validation

10 min read

Declarative validation rules evaluated at admission time (via webhook) and at reconcile time. Declared on a CRDEntry or inside a Motif’s admission block.

validation:
  rules:
    - field: spec.replicas
      operator: lte
      value: "10"
      valueType: int
      message: "replicas must not exceed 10"

    - field: spec.engine
      equals: postgres
      message: "only postgres engine is supported"
      action: deny

    - field: spec.image
      prefix: "myregistry.example.com/"
      message: "image must come from the internal registry"
      action: warn

    - field: spec.productionApproval
      operator: exists
      message: "production deploys require an approval ticket"
      action: deny
      when:
        - field: spec.environment
          equals: production

    - field: spec.domain
      operator: exists
      message: "spec.domain is required for cert workloads"
      action: deny
      when:
        - field: spec.workloadType
          equals: cert
      or:
        - field: spec.workloadType
          equals: cert
        - field: spec.workloadType
          equals: monitoring

validation.include

Pull rules from an external file to keep the Katalog compact. The file contains a rules: key with the same list structure as inline rules.

validation:
  include: ./admission/apprequest.yaml   # relative to the katalog file
  rules:
    - field: spec.tier                   # appended after included rules
      operator: in
      value: "free,pro,enterprise"
      action: deny

admission/apprequest.yaml:

rules:
  - field: spec.team
    operator: exists
    message: "spec.team is required"
    action: deny
  - field: spec.environment
    operator: in
    value: "staging,production"
    message: "spec.environment must be staging or production"
    action: deny

Included rules come first. Inline rules: append after. The include: path is resolved relative to the katalog file’s directory — the katalog can be run from any working directory. The field is cleared from the runtime bundle after expansion.

validation.rules

Each rule describes one check. Rules are evaluated in order.

FieldRequiredDescription
fieldyesDot-notation path in the CR, or a Go template expression (see below).
messageyesError or warning message. Supports Go templates resolved against the CR and notes FuncMap.
actionnodeny (default) — reject; warn — allow but log a warning
operator + valueyes*Explicit comparison (see operators). value supports Go templates.
valueTypenostring (default), int, float, bool
whennoAll conditions must pass for this rule to be evaluated (AND). Empty means unconditional. Conditions support Go template expressions via EvaluateConditions.
ornoAt least one condition must pass for this rule to be evaluated (OR). When both when and or are declared, both blocks must pass.
linknoThe serve.fields/serve labels/annotations key this rule concerns, when field: isn’t already a plain, self-describing path — see Linking a rule to its form field below.
fires.reconcilenotrue (default). Set false to make this rule admission-only — the reconciler skips it. Use for rules that read .request.* (raw intent) which is only present at the serve-layer admission boundary, not during reconcile.

*Use either an operator+value pair or a shorthand field.

when and or use the same Condition type as resource templates — see 06-when-conditions for the full operator reference.

Required fields are enforced automatically

required: true on an serve.fields or serve labels/annotations entry isn’t just a form hint — at katalog load time, every required: true entry gets an implicit exists rule synthesized automatically, with message: already matching the field’s label:. This is enforced at the API server for every client — the Control Center form, curl, a CI pipeline, a custom UI, kubectl apply — not only the one that happens to render a required-field asterisk. You don’t hand-write a validation.rules entry for plain “this field must be present” checks — marking the field required is enough:

serve:
  fields:
    targetRevision:
      label: "Branch / Tag"
      required: true
# → synthesizes: { field: spec.targetRevision, operator: exists,
#                  message: "Branch / Tag is required", action: deny }

serve.labels/.annotations entries synthesize the same way, through getLabel/getAnnotation rather than a raw dot-path — required annotation keys with dots (the Kubernetes-recommended prefix/name shape) are handled correctly without you needing to know that dot-path resolution would otherwise misparse them.

Enum fields are validated automatically

type: enum on an serve.fields or serve labels/annotations entry also synthesizes a rule — an in check against the declared enum: list, with the same label-matching message. Membership is checked only when the field has a value: an enum field that isn’t also required: true can still be omitted entirely, it just can’t be set to something outside the list.

serve:
  fields:
    workloadType:
      label: "Workload Type"
      type: enum
      enum: [app, cert, monitoring, infra]
      required: true
# → synthesizes: { field: spec.workloadType, operator: in,
#                  value: "app,cert,monitoring,infra",
#                  message: "Workload Type must be one of: app, cert, monitoring, infra",
#                  action: deny }

serve.fields entries backed by a spec field don’t infer enum: from the CRD’s OpenAPI schema for this purpose — declare it explicitly to opt a field into this synthesis, the same way serve labels/annotations already requires type/enum since those have no CRD schema to infer from.

Serve-aware messages for hand-written rules

Auto-synthesis covers plain existence and enum membership. For anything more specific — a range, a prefix, a cross-field comparison — you still write the validation.rules entry by hand, and there the same principle applies: write message: using the field’s label: instead of its raw spec.* path. A developer submitting through the Control Center form saw the label, not the YAML path — an error that echoes the path back is a translation the developer has to do themselves.

serve:
  fields:
    image:
      label: "Container Image"

validation:
  rules:
    # before — leaks the internal field name
    - field: spec.image
      prefix: "myorg/"
      message: "spec.image must start with myorg/"

    # after — matches what the developer actually saw on the form
    - field: spec.image
      prefix: "myorg/"
      message: "Container Image must start with myorg/"

This applies whether the rule fires from a form submission, kubectl apply, or a CI pipeline — the message is the same either way, so keep it in the vocabulary of the person reading it, not the API shape.

message: fixes what the developer reads. link: fixes what the Control Center (or any Gateway API client) highlights — which requires knowing which rendered field a violation concerns, not just a human-readable sentence about it.

For a plain field: spec.image, that’s free: the violation reports "spec.image", and a client strips the spec. prefix to get "image", the same key the field was rendered under. But serve labels/annotations entries (labels and annotations) can’t be a plain dot-path — they resolve through getLabel/getAnnotation template expressions instead, because e.g. an annotation key following the Kubernetes prefix/name shape contains dots that a dot-path resolver would misparse as extra segments. A hand-written rule on a spec field can end up in the same situation, wrapping it in something like isValidGitRepository instead of comparing it directly. Either way, the violation reports the raw expression — {{ getLabel . "team" }} or {{ isValidGitRepository .spec.repoURL }} — which isn’t a field name a client can match against anything it rendered.

link: closes that gap — it’s the plain key, reported instead of field: in the violation:

serve:
  labels:
    team:
      label: "Team"
      required: true

validation:
  rules:
    - field: '{{ isDNS1123Subdomain (getLabel . "team") }}'
      link: team
      equals: "true"
      message: "team must be a valid DNS subdomain"
      action: deny

Without link: team, this violation reports field: '{{ isDNS1123Subdomain (getLabel . "team") }}' — nothing a form can highlight. With it, the violation reports field: "team", matching the rendered field directly.

link: is validated at katalog-load time: it must name a key declared in serve.fields, serve.labels, or serve.annotations on the same CRD — a typo or a stale reference to a renamed/removed field is a load-time error, not a silently-broken highlight discovered later. Pointing it at a spec field whose field: is already exactly spec.<name> is also an error — at that point field: is already a clean key on its own, so the link is redundant.

Required/enum rules synthesized from serve labels/annotations set link: automatically — you only write it by hand for custom rules.

One thing this unlocks: multiple rules can target the same field. Before link:, there was pressure to cram every check for one field into a single expression, because that expression doubled as the only thing identifying which field it concerned. With link: decoupling “which check” from “which field,” each check can be its own rule with its own message:

validation:
  rules:
    - field: '{{ isDNS1123Subdomain (getLabel . "team") }}'
      link: team
      equals: "true"
      message: "team must be a valid DNS subdomain"
      action: deny

    - field: '{{ isReservedTeamName (getLabel . "team") }}'
      link: team
      equals: "false"
      message: "team cannot be a reserved platform namespace prefix"
      action: deny

Both rules highlight the same field, each with a message specific to what actually failed.

Template expressions in rules

field:, comparison values (equals:, prefix:, min:, value:, …), and message: are all resolved as Go templates before evaluation. The full CR fields and notes FuncMap are available.

When field: is a template expression, the resolved value is used in the comparison directly — not looked up as a path in the CR. The original expression is preserved in violation messages.

notes:
  functions:
    - name: inBusinessHours
      expression: '{{ and weekday (timeInWindow "09:00" "18:00") }}'
    - name: allowedRegistry
      expression: "myorg/"

validation:
  rules:
    - field: "{{ inBusinessHours }}"
      equals: "true"
      action: deny
      message: "deployments are only allowed during business hours"

    - field: spec.image
      prefix: "{{ allowedRegistry }}"
      action: deny
      message: "image must come from {{ allowedRegistry }}"

validation.external

External HTTP calls can be declared directly under validation:. They fire before any rule is evaluated, and their results are available in field: expressions as .external.<name>.*.

validation:
  external:
    - name: healthCheck
      url: "{{ .spec.healthCheckUrl }}/health"
      expectedStatus: 200
      continueOnError: true
      fires:
        reconcile: false   # admission-only — skip during reconcile resyncs

  rules:
    - field: "{{ .external.healthCheck.status }}"
      equals: "200"
      action: deny
      message: "health check failed — CR rejected"

fires.reconcile: false means the call only runs at kubectl apply time. When omitted (default), the call also runs on every reconcile — the result is available the same way as onReconcile.external calls.

See 13-external for the full field reference.

Operators

validation.rules uses the exact same operator set and shorthand fields as when:/or: — both are backed by the same Condition/ConditionOperator evaluation code (pkg/types/validation_eval.go and pkg/types/when.go share one operator table so the two can’t drift apart). See when/or conditions § Operators for the full list.

One operator worth calling out: unique — field value must be unique across all existing instances of this CRD. It works the same way in validation.rules and in when:/or: (e.g. gating a template source or mutation rule on whether a field is still available), and it’s enforced at both reconcile and admission time — just via two different checks with different guarantees:

  • Reconcile time — the reconciler lists other instances via a live call against the API server. This is the authoritative check: immune to cache staleness, always correct.
  • Admission time — the gateway asks the runtime’s own /katalog/{crd}/cr?field= endpoint (served from its informer cache, not a live API call) whether any other instance already has this value. This is a fast, best-effort early rejection, not a second source of truth — if the runtime’s cache is a moment stale, a duplicate can still slip through admission, but it’s always caught on the very next reconcile regardless. Nothing about the reconcile-time guarantee depends on admission catching it first.

Testable with ork simulate by adding a second document of the same kind to the CR file — see the cr field in Simulate schema. ork simulate only exercises the reconcile-time path (admission webhooks aren’t registered in the fake cluster); the admission-time path needs a real cluster — see ork e2e.

action

ValueEffect
deny (default)Webhook returns a rejection; reconcile fails with an error.
warnWebhook allows the operation; a warning is logged.

When validation runs

  • At admission: if security.webhooks.admission.enabled: true and the CRD’s webhooks.validation: true.
  • At reconcile: always — even without a webhook, rules are checked during each cycle.