XRDConversionConfig¶
One XRDConversionConfig targets exactly one Crossplane CompositeResourceDefinition (enforced by a unique index plus the admission webhook — two configs can never race to patch the same XRD). It's cluster-scoped, matching the XRD it targets.
Spec¶
apiVersion: terasky.com/v1alpha1
kind: XRDConversionConfig
metadata:
name: xwidgets-conversion
spec:
targetXRD:
name: xwidgets.example.org # the XRD's metadata.name
hubVersion: v3 # must equal the XRD's referenceable version
spokes:
- version: v2
rules: [ ... ] # see the Strategy Reference
- version: v1
rules: [ ... ]
# optional
webhookServerRef:
name: tenant-a-webhook # defaults to whichever instance has spec.default: true
conversionReviewVersions: ["v1"] # default
unmappedFieldPolicy: Error # Error (default) | Warn
unmappedFieldReason: "" # required if unmappedFieldPolicy: Warn
driftPolicy: KeepServingStale # KeepServingStale (default) | FailClosed
| Field | Description |
|---|---|
targetXRD.name |
The XRD's metadata.name (for XRDs, always <plural>.<group>). |
hubVersion |
Must equal the XRD's referenceable: true version — validation fails otherwise. This is Crossplane's storage version; every spoke is converted to/from it. |
spokes[].version / spokes[].rules |
One entry per served spoke version, each with its own rule list. A version not listed here — including the hub itself — needs no rules only if every one of its fields is structurally identical to the hub; otherwise it must appear with an explicit Delete/DefaultValue/etc. rule for the differing fields. |
webhookServerRef |
Pins this config to a specific ConversionWebhookServer by name. Omit to use whichever instance has spec.default: true. |
conversionReviewVersions |
The ConversionReview API versions the webhook accepts, passed through to the XRD's spec.conversion.webhook.conversionReviewVersions. |
unmappedFieldPolicy |
Error (default): any hub or spoke field left unclaimed by a rule and not structurally identical on both sides fails validation — "unknown means assume lossy, never silently pass." Warn downgrades this to a warning; requires unmappedFieldReason. |
unmappedFieldReason |
Human-readable justification for deliberately leaving fields unclaimed. Required when unmappedFieldPolicy: Warn — enforced by the admission webhook and convctl validate. Ignored when the policy is Error. |
driftPolicy |
Governs what happens when the live XRD's schema no longer matches what this config last validated against — see Drift handling. |
Rules¶
Every rule picks a strategy and sets exactly one matching params field:
rules:
- strategy: FieldRename
fieldRename:
hubPath: spec.storageGB
spokePath: spec.storageSize
acknowledgeLossy: false # required true if the engine determines this rule is lossy
reason: "" # encouraged whenever acknowledgeLossy is true
# optional: apply this rule only when a simple path-equals predicate matches
# when:
# path: spec.mode
# equals: advanced
when is a simple path-equals comparison, not CEL. It is evaluated against the conversion input object (hub object on hub→spoke, spoke object on spoke→hub), so the discriminator path should exist with the same name on both versions. Compile still claims the rule's target paths (they are not uncovered-field errors) but reports a partial coverage warning: the rule does not apply to every object. When the predicate does not match, the inner transform is skipped — the destination field is not written.
See the Strategy Reference for every strategy's params, semantics, and worked examples. Every rule the engine determines is lossy in either direction requires acknowledgeLossy: true, or the whole config is rejected as Invalid — this is checked identically by the admission webhook (at kubectl apply time) and the controller (on every reconcile, in case the XRD's schema has drifted since).
status fields work exactly like spec fields¶
A CRD conversion webhook receives the entire stored object, status included, regardless of whether the CRD uses the status subresource — and pkg/engine compiles and converts whatever top-level schema the XRD version declares, never narrowing to .spec. So a fieldRename (or any other strategy) targeting status.somePath behaves identically to one targeting spec.somePath, and the same fail-closed coverage rule applies: a status field that differs in shape between hub and spoke needs an explicit rule, or the config is rejected.
Status¶
status:
observedGeneration: 3
observedXRDGeneration: 7
schemaHash: "a1b2c3..."
phase: Applied
overallLossless: true
assignedWebhookServer: default
webhookPath: /convert/xwidgets.example.org
webhookURL: https://default-webhook-server.declarative-conversion-system.svc/convert/xwidgets.example.org
conditions:
- type: Validated
status: "True"
reason: Validated
- type: XRDHealthy
status: "True"
reason: Established
- type: WebhookServerReady
status: "True"
reason: ServerReady
- type: Applied
status: "True"
reason: Applied
spokeStatuses:
- version: v2
lossless: {hubToSpoke: true, spokeToHub: true}
fieldsUncoveredHub: []
fieldsUncoveredSpoke: []
ruleResults: [...]
Phases¶
| Phase | Meaning |
|---|---|
Pending |
Not yet reconciled. |
Validating |
Reconcile in progress. |
Validated |
Rules compiled cleanly, but the XRD or webhook server isn't healthy yet — see conditions for which. |
Invalid |
Compilation failed: a schema mismatch, an unacknowledged lossy rule, or an uncovered field. The XRD is never touched in this phase. |
Applied |
spec.conversion has been patched onto the XRD. Conversions are live. |
Stale |
Was Applied, but the live XRD's schema has drifted since and re-validation now fails. See Drift handling — the last-known-good plan keeps serving by default. |
Reverting |
Deletion in progress; unpatching the XRD. |
Failed |
An unexpected error, distinct from a validation failure. |
Conditions¶
| Condition | Meaning when True |
|---|---|
Validated |
The rule set compiles cleanly against the XRD's schemas: every field covered, no unacknowledged lossy rule. |
XRDHealthy |
The target XRD exists and its Established condition is True. |
WebhookServerReady |
The assigned ConversionWebhookServer instance's Deployment is Available, its Service has ready endpoints, and its certificate is ready. |
Applied |
spec.conversion has been patched onto the XRD. |
Stale |
The live XRD's schema no longer matches what was last validated (see below). |
DeletionBlocked |
Deletion is being held by the finalizer — see Deletion safety. |
status.spokeStatuses always reflects the result of the last validation attempt, independent of whether that validation ultimately let the config reach Applied — so you can inspect exactly which fields are uncovered, or which rule is lossy, even while the config is Invalid.
Ordering: nothing touches the XRD until every gate passes¶
On every reconcile, in order:
- Fetch the target XRD. Not found →
Invalid, stop. - Extract hub/spoke schemas; confirm
hubVersionmatches the XRD's referenceable version and every spoke isserved. - Compile the rules (
pkg/engine.Compile). Any error, unacknowledged lossy rule, or uncovered field →Invalid, the XRD is never touched. - Resolve the target
ConversionWebhookServer(explicitwebhookServerRef, or whichever instance isdefault). - Confirm the XRD is
Established. - Confirm the assigned
ConversionWebhookServer's Deployment isAvailable, its Service has ready endpoints, and its certificate is ready. - Only now: server-side-apply
spec.conversiononto the XRD, scoped to just that field (plus a couple of tracking annotations) — never a full-object apply, so this never fights any other owner of the XRD.
Drift handling¶
Every reconcile recomputes a hash of the live XRD's schema plus this config's rules. If it still matches what was last validated, nothing changes — a clean re-validation is silent. If it no longer matches:
KeepServingStale(default): the config is markedStaleloudly (condition, event, metric), but keeps serving the last known-good compiled plan. Unpatching or dropping a working conversion webhook on drift-detection would itself be the outage this operator exists to prevent.FailClosed: stops serving conversions for the XRD as soon as drift is detected, for organizations that would rather fail loud immediately than risk serving a plan validated against a schema that no longer exists.
Changing the hub version¶
hubVersion must equal the XRD's live referenceable (storage) version — this is a reconcile-time check, not a one-time gate, so it stays enforced for the config's entire life. That's what makes promoting a new version to be the hub safe, via DriftPolicy: KeepServingStale (the default):
- Add the new version (say
v3) to the XRD as an ordinary served, non-referenceablespoke, with rules in this config relative to the current hub. Clients can start usingv3immediately, round-tripping through the existing hub like any other spoke. - When ready to promote
v3to hub, do both of the following — the order between them doesn't matter: - Update this
XRDConversionConfigto declarehubVersion: v3, with the old hub (sayv2) rewritten as a spoke relative tov3. This isn't a mechanical copy: rule direction and paths are expressed from the hub's perspective, so the old hub's rules need to be re-authored as a spoke's rules, not just swapped. Useconvctl rehubto draft that rewrite (invert the promoted spoke, compose remaining spokes), thenconvctl validate/convctl testbefore apply. - Flip
referenceable: truefromv2tov3on the XRD itself (andreferenceable: falseonv2). - Whichever of those two changes lands first,
hubVersionand the live referenceable version will briefly disagree. The next reconcile re-validates asInvalid/Stale— but perKeepServingStale, the controller never un-patches the XRD; it keeps serving the last-known-good plan (still hub=v2) the whole time. Sincev3was already a working spoke from step 1, conversions keep functioning correctly through the old plan throughout — Kubernetes' conversion webhook protocol converts between whatever version is stored and whatever version was requested per-request, so a router internally pivoting throughv2handles av3-desired request exactly as it always did. - Once both changes have landed, the next reconcile finds
hubVersionmatching the live referenceable version again, validates cleanly, and re-patches the XRD with the newv3-anchored plan — no outage, no manual intervention to "resume."
A worked, apply-able walkthrough of this sequence — including creating a new Composition when the hub moves (spec.compositeTypeRef is immutable) — is the Crossplane XR lifecycle walkthrough.
DriftPolicy: FailClosed does not offer this safety net — a mismatch under FailClosed stops serving conversions immediately, so plan the two updates as a single fast operation (or temporarily switch to KeepServingStale for the migration) if you use that policy.
One K8s-level detail this doesn't handle automatically: status.storedVersions on the generated CRD never shrinks, so Kubernetes rejects dropping an old version from the XRD until that list is pruned — even when every XR has already been rewritten.
On an XRD that is not true of the etcd bytes themselves. Promoting the hub requires a new Composition (compositeTypeRef is immutable) and a write of every existing XR so etcd is stored at the new referenceable version. The staged example patches compositionRef by name. The GitOps path is convctl generate kyverno: a per-XRD policy labels Compositions from compositeTypeRef, and a migrate policy strips the pin and sets compositionSelector.matchLabels.xrd-api-version so Crossplane re-selects. Do not use XRD enforcedCompositionRef to chase hub versions — that field is immutable. Native CRDs have no equivalent "must write every object" step, so empty SSA is the actual rewrite there. For XRDs, run convctl migrate-storage --prune-stored-versions anyway (catches anything you forgot to retarget) — the prune is the part that unblocks deleting the version block:
Deletion safety¶
Deleting an XRDConversionConfig runs a finalizer-gated "safe revert," not an immediate removal:
- If it was never
Applied, or the target XRD is already gone, the finalizer is removed immediately — nothing to revert. - If the XRD still has more than one served version, reverting to
strategy: Nonewould risk serving objects in the wrong shape to any client still using a non-storage version — deletion is blocked (DeletionBlockedcondition) unless the object carries the break-glass annotation, checked live at the moment of the delete reconcile:
kubectl annotate xrdconversionconfig xwidgets-conversion \
conversion.terasky.com/allow-unsafe-delete=true
kubectl delete xrdconversionconfig xwidgets-conversion
- Otherwise (or with the break-glass annotation present), the operator server-side-applies
spec.conversion: {strategy: None}onto the XRD, strips its own tracking annotations, and removes the finalizer.