normalize:

3 min read

normalize: runs before mutation, validation, and reconcile. It collapses input format variation into one canonical shape — without touching etcd, without a webhook, without a multi-version CRD.

Use it when you want to accept multiple CR shapes for the same field without branching logic downstream.


How it works

Declare a normalize: block in the Katalog alongside mutation: and validation:. Each field expression runs against the raw CR as submitted. The normalised values replace the raw values in the in-memory resolver — the stored CR is unchanged.

normalize:
  spec:
    schedule: "{{ cronFromAny .spec.schedule }}"

After normalize: runs, .spec.schedule is always a five-field cron string — whether the user wrote "0 2 * * 1-5" or {minute: "0", hour: "2", ...}. Every downstream expression, validation rule, and template sees the canonical form. No branching required.


The pipeline order

CR submitted
normalize:      ← raw input → canonical shape (in-memory only)
mutation:       ← apply defaults to the normalised CR
validation:     ← check required fields after defaults are set
onCreate / onReconcile   ← templates see canonical fields throughout

normalize: runs before mutation:, so defaults apply to the normalised value. If a user submits neither a string nor a map, normalize: can supply a default via {{ default "0 * * * *" (cronFromAny .spec.schedule) }}.


Accepting two schedule formats

# Both of these work. The reconciler sees the same thing either way.

# Cron string
spec:
  schedule: "0 2 * * 1-5"

# Structured object
spec:
  schedule:
    minute: "0"
    hour: "2"
    dayOfMonth: "*"
    month: "*"
    dayOfWeek: "1-5"

The Katalog:

normalize:
  spec:
    schedule: "{{ cronFromAny .spec.schedule }}"

mutation:
  rules:
    - field: spec.concurrencyPolicy
      default: "Allow"

onCreate:
  cronJobs:
    - name: "{{ .metadata.name }}"
      schedule: "{{ .spec.schedule }}"   # always a cron string here
      image: "{{ .spec.image }}"
      reconcile: true

cronFromAny accepts a cron string or a structured map and always returns a five-field cron string. @-macros (@hourly, @daily) are expanded transparently.


Alternative: branch at reconcile time

If you want to preserve the raw format in etcd and handle both shapes explicitly, use typeString and typeMap in when: conditions instead of normalize::

onReconcile:
  cronJobs:
    # Path A — cron string
    - name: "{{ .metadata.name }}"
      schedule: "{{ .spec.schedule }}"
      when:
        - field: "{{ typeString .spec.schedule }}"
          equals: "true"

    # Path B — structured object
    - name: "{{ .metadata.name }}"
      schedule: "{{ cronFromMap .spec.schedule }}"
      when:
        - field: "{{ typeMap .spec.schedule }}"
          equals: "true"

Only one path fires per reconcile. The child resource is identical regardless of which path ran. Use this when you want the stored format to remain as the user submitted it — for audit trails, or when the raw shape carries meaning.


Deprecating a format without schema migration

When you are ready to steer users away from the string format, add a validation rule — no etcd migration, no CRD version bump:

validation:
  rules:
    - field: spec.schedule
      operator: typeOf
      value: map
      message: "spec.schedule as a string is deprecated — use the structured object. See migration guide."
      action: warn   # change to deny when ready

Old CRs continue to reconcile normally. normalize: still handles them. The warning surfaces at kubectl apply time — at the next admission call for each CR, not at migration time.


Try it

ork init --pack use-cases/crd-conversion/without-webhooks
cd without-webhooks
# Follow the steps in README

This runs the CronJob operator accepting both schedule formats. Apply both CRs and observe that the operator reconciles identically regardless of which shape was submitted.


Where to go next