- Status: Accepted
- Date: 2026-06-13
- Deciders: Erick Bourgeois, 5-Spot team
- Supersedes: —
- Related: ADR 0006
(introduces the public
spotschedules.5spot.finos.orgcontract this policy governs);src/crd.rs(#[kube(...)]derive, source of truth);crdgen(src/bin/crdgen.rs); theregen-crds/regen-api-docsskills.
5-Spot's CRDs are served at a single version, v1alpha1, today
(5spot.finos.org/v1alpha1, kind ScheduledMachine). ADR 0006 introduces a
second API group, spotschedules.5spot.finos.org, whose status.active
contract is implemented by third parties — it is a public API surface, not
an internal type. Public contracts evolve: fields get added, semantics get
clarified, an alpha graduates to beta to stable. We need a deliberate story for
serving more than one version of a CRD before we generate the first one, so
the contract can mature without breaking existing ScheduledMachines or
out-of-tree providers.
The constraints that shape the decision:
- kube-rs derives one version per struct.
#[kube(version = "…")]on aCustomResourcederive emits exactly one version. Serving N versions means N Rust structs and a merged CRD whosespec.versions[]lists all of them with exactly onestorage: true. - Conversion needs either compatibility or a webhook. Kubernetes converts
between served versions one of two ways:
conversion.strategy: None, which works only when every served version round-trips losslessly through the stored object (the API server just relabelsapiVersion); or a conversion webhook, which can transform fields but requires a TLS-served HTTPS endpoint plus a cert lifecycle (cert-manager /caBundlerotation). - 5-Spot runs no webhook server today. The controller exposes only
/metricsand/healthz. Standing up an admission/conversion webhook is a non-trivial new failure domain (TLS, availability, fail-open/closed posture).
Options weighed:
- Stay single-version, bump in place. Rejected: breaks every stored object and every out-of-tree provider the moment the contract changes; unacceptable for a public API.
- Multi-version with a conversion webhook from day one. Rejected for now: pays the full webhook/TLS/cert cost before any breaking change exists to justify it.
- Multi-version with
conversion.strategy: None+ additive-only evolution. Chosen. Serve multiple versions that are all structurally round-trippable through the storage version; constrain changes to be additive (new optional fields, never a rename/retype/removal of a served field) soNoneconversion stays correct. The first genuinely breaking change is the documented trigger to introduce a webhook via a superseding ADR.
-
5-Spot CRDs are multi-version-capable. Each served version of a CRD is a distinct Rust struct in
src/crd.rs(e.g.ScheduledMachineatv1alpha1, a futureScheduledMachineV1Beta1atv1beta1), andcrdgenemits a single CRD object per kind whosespec.versions[]lists every served version with exactly one markedstorage: true. This policy applies to both5spot.finos.org/ScheduledMachineand the newspotschedules.5spot.finos.orgprovider group from ADR 0006. -
Conversion strategy is
None; evolution is additive-only. Generated CRDs setspec.conversion.strategy: None. Because the API server performs no field transformation underNone, every served version must be round-trippable through the storage schema. We therefore constrain cross-version changes to be additive — new optional fields only; never rename, retype, remove, or change the meaning of a field that an older served version exposes. Defaulting is handled in the Rust types / reconciler, not by conversion. -
Resolvers and watchers are version-agnostic. No code keys off a single hardcoded
apiVersionstring. The spot-schedule resolver and dynamic watch (ADR 0006 §5) key offgroup+kindand accept any served version of a provider; the CEL pin onspotSchedule.apiVersionvalidates the group, not a specific version. -
Generation and CI guards.
crdgenproduces multi-version YAML; serde round-trip tests cover every served version of every kind; a CI/test guard asserts the single-storage: trueinvariant per CRD. CRD shape changes still start insrc/crd.rsand flow throughregen-crds→regen-api-docs(LAST), never hand-edited YAML. -
The webhook trigger is recorded, not built. The first change that cannot be expressed additively (a true breaking change to a served version) is the trigger to introduce a conversion webhook. That is a new architectural decision — it gets its own ADR superseding the relevant part of this one, covering the webhook's TLS/cert lifecycle and fail posture. Until then, 5-Spot ships no webhook.
- Easier: the public
spotschedulescontract (andScheduledMachineitself) can grow new versions without breaking stored objects or out-of-tree providers, and without 5-Spot standing up a webhook server — no TLS, cert, or webhook-availability burden while the contract is young. - Harder / constraints accepted: cross-version changes are disciplined to additive-only; a rename/retype/removal is not a routine change — it forces the webhook ADR. Reviewers must police this on every CRD edit. Multiple Rust structs per kind add some duplication, and round-trip tests multiply by served version count.
- Operational invariant: exactly one version per CRD is
storage: true; the CI guard fails the build otherwise. Reading/writing always converges on the storage version with no lossy transformation. - Ruled out (for now): conversion webhooks; non-additive version changes; version-pinned resolver/watch code. Each is reintroduced only via a superseding ADR.
- CALM impact: none. This is a generation/versioning policy for the CRD artifacts and the type layer — it changes neither the running system's topology (controller, agents, CAPI/provider resources, flows) nor any trust boundary. CALM models the system, not the CRD-version-evolution policy, so there is no node/relationship/flow to add.