Skip to content

Commit a52948b

Browse files
committed
Add support for different versions of CAPI bootstrap and infrastructure specs, passthrough
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent e530266 commit a52948b

8 files changed

Lines changed: 442 additions & 37 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,62 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-06-22 12:00] - CAPI Machine API version: passthrough from bootstrapSpec + mandatory discovery (no hardcoded version)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/constants.rs`: removed the hardcoded `CAPI_MACHINE_API_VERSION` /
18+
`CAPI_MACHINE_API_VERSION_FULL` consts. The version is now a runtime value
19+
(`OnceLock`) set once at startup via `set_capi_machine_api_version`; the accessor
20+
`capi_machine_api_version()` panics if read before resolution (an invariant —
21+
`main` resolves before any reconcile) and returns `v1beta1` only under
22+
`#[cfg(test)]` for fixtures. Group/kind (`cluster.x-k8s.io`/`Machine`) stay fixed
23+
by the CAPI contract; only the version is data-driven.
24+
- `src/main.rs`: `resolve_capi_machine_version` now returns `Result` and discovery
25+
is **mandatory** — precedence is `CAPI_MACHINE_API_VERSION` env override →
26+
`kube::discovery` of the served `machines.cluster.x-k8s.io` version → error (6×5s
27+
retry, then the controller exits and is restarted by Kubernetes). No silent
28+
default. The Machine watch uses the resolved version.
29+
- `src/reconcilers/helpers.rs`: new `machine_api_version_for` helper derives the
30+
Machine version from the version component of the user's `bootstrapSpec.apiVersion`
31+
(passthrough), falling back to the discovered served version. Machine **create**
32+
and **delete** both use it; **watch**/**fetch** use the discovered version (CAPI
33+
conversion webhooks bridge any mismatch). `extract_machine_refs` relaxed so a
34+
v1beta2 `nodeRef` (only `name` guaranteed) still resolves (defaults apiVersion→`v1`,
35+
kind→`Node`).
36+
- `src/reconcilers/helpers_tests.rs`: +5 unit tests — 4 for `machine_api_version_for`
37+
(v1beta2/v1beta1 passthrough, `None` fallback, malformed fallback) and a mock-client
38+
test asserting `remove_machine_from_cluster` issues the Machine DELETE at the
39+
bootstrapSpec version.
40+
- `docs/src/operations/configuration.md`: documented the new `CAPI_MACHINE_API_VERSION`
41+
env override and added a "CAPI Machine API version" section explaining the
42+
passthrough + mandatory-discovery model.
43+
- `docs/src/advanced/capi-integration.md`: added a "CAPI API version (passthrough)"
44+
note to the Machine Creation Flow — the Machine version follows `bootstrapSpec`,
45+
the `v1beta1` examples are illustrative not fixed.
46+
- `docs/src/operations/troubleshooting.md`: new entry for the controller exiting at
47+
startup when Cluster API isn't discoverable ("could not resolve the CAPI Machine
48+
API version").
49+
50+
### Why
51+
5-Spot v0.2.2 hardcoded the CAPI Machine version to `v1beta1`, which crash-loops
52+
modern k0smotron / CAPI ≥ v1.11 (they serve/watch `v1beta2`). Making the version
53+
data-driven (user's bootstrapSpec) with mandatory served-version discovery lets one
54+
controller image work against both v1beta1 (CAPD) and v1beta2 (modern k0smotron)
55+
clusters, with no hardcoded opinion. Enables the workshop's k0smotron tier.
56+
57+
### Impact
58+
- [ ] Breaking change
59+
- [x] Requires cluster rollout
60+
- [ ] Config change only
61+
- [ ] Documentation only (code + docs)
62+
63+
Note: the controller now **requires** CAPI to be discoverable at startup (it retries
64+
~30s then exits→restarts) instead of silently defaulting to `v1beta1`. Security model
65+
unchanged — the bootstrap/infra **group** allowlist (`validate_api_group`) is the
66+
control; the Machine group is fixed and the version is not a security boundary.
67+
1268
## [2026-06-17 09:00] - Pin deploy manifest image tags to the release version; crdgen back to stdout
1369

1470
**Author:** Erick Bourgeois

docs/src/advanced/capi-integration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,18 @@ sequenceDiagram
107107
5S->>SM: Update status: Active
108108
```
109109
110+
> **CAPI API version (passthrough).** The `Machine` 5-Spot creates is always in the
111+
> fixed `cluster.x-k8s.io` group, but its **version** is not hardcoded — it follows
112+
> the version component of your `bootstrapSpec.apiVersion`. Declare
113+
> `bootstrap.cluster.x-k8s.io/v1beta1` and 5-Spot creates (and deletes) a `v1beta1`
114+
> Machine; declare `…/v1beta2` and it uses `v1beta2`, so the Machine shares one CAPI
115+
> contract version with your bootstrap and infrastructure objects. For watching and
116+
> reading Machines the controller uses the version the cluster *serves* (discovered
117+
> at startup) and **requires** Cluster API to be installed — see
118+
> [Configuration → CAPI Machine API version](../operations/configuration.md#capi-machine-api-version).
119+
> The `cluster.x-k8s.io/v1beta1` shown in the examples above is therefore one valid
120+
> choice, not a fixed requirement.
121+
110122
## Machine Deletion Flow
111123

112124
```mermaid

docs/src/operations/configuration.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
| `LEASE_DURATION_SECONDS` | `15` | How long the Lease is considered valid; a new leader is elected if not renewed in time |
2020
| `LEASE_RENEW_DEADLINE_SECONDS` | `10` | The leader must renew the Lease within this many seconds; grace = duration − deadline |
2121
| `LEASE_RETRY_PERIOD_SECONDS` | `2` | Documented for ops parity; not a direct LeaseManager parameter |
22+
| `CAPI_MACHINE_API_VERSION` | _(discovered)_ | Pins the Cluster API `Machine` version (`v1beta1` / `v1beta2`). Normally **left unset** — the controller discovers the version the cluster serves. Set only to override discovery (e.g. an air-gapped cluster where API discovery is restricted). See [CAPI Machine API version](#capi-machine-api-version). |
2223

2324
## Command-Line Arguments
2425

@@ -69,6 +70,28 @@ Non-leader replicas watch for leadership changes and take over automatically wit
6970

7071
> **Note:** Leader election and multi-instance sharding (`OPERATOR_INSTANCE_COUNT > 1`) are alternative HA strategies. Use leader election for active/standby HA; use instance sharding to distribute load across all replicas.
7172
73+
### CAPI Machine API version
74+
75+
5-Spot creates and watches Cluster API `Machine` objects. The `Machine` *group* and
76+
*kind* (`cluster.x-k8s.io` / `Machine`) are fixed by the CAPI contract, but the
77+
served **version** varies by cluster — `v1beta1` on CAPI ≤ v1.10, `v1beta2` on
78+
v1.11+ and modern k0smotron. The controller resolves it at startup with **no
79+
hardcoded default**:
80+
81+
1. **`CAPI_MACHINE_API_VERSION`** — if set, this exact version is used (operator override).
82+
2. **Discovery** — otherwise the controller discovers the version the API server
83+
serves for `machines.cluster.x-k8s.io`.
84+
85+
Discovery is **mandatory**: if neither source yields a version (for example Cluster
86+
API is not installed yet), the controller retries briefly, then exits and is
87+
restarted by Kubernetes — it never guesses a version.
88+
89+
Per `ScheduledMachine`, the **created** `Machine` follows the version component of
90+
your `bootstrapSpec.apiVersion` (passthrough), so the Machine, bootstrap, and
91+
infrastructure objects all share one CAPI contract version. One controller image
92+
therefore works against both `v1beta1` (e.g. CAPD) and `v1beta2` (e.g. modern
93+
k0smotron) clusters with no configuration.
94+
7295
## Reclaim agent (DaemonSet)
7396

7497
The node-side `5spot-reclaim-agent` is a separate binary deployed

docs/src/operations/troubleshooting.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,26 @@ kubectl describe machine <name>
181181
- Check CAPI provider health
182182
- Review CAPI controller logs
183183

184+
### Controller exits at startup: "could not resolve the CAPI Machine API version"
185+
186+
**Symptoms:**
187+
- The controller pod crash-loops shortly after starting (`CrashLoopBackOff`)
188+
- Logs end with `could not resolve the CAPI Machine API version after 6 attempts … is Cluster API installed?`
189+
- Preceding lines show `CAPI Machine API version not resolved yet; retrying`
190+
191+
**Cause:**
192+
The controller resolves which `cluster.x-k8s.io` Machine version the cluster serves
193+
at startup (see [Configuration → CAPI Machine API version](configuration.md#capi-machine-api-version)).
194+
Discovery is **mandatory** — it never guesses a version. If Cluster API is not
195+
installed, or its CRDs / aggregated discovery aren't ready yet, the controller
196+
retries (~30s) and then exits so Kubernetes restarts it.
197+
198+
**Solution:**
199+
- Install Cluster API before or alongside 5-Spot — `kubectl get crd machines.cluster.x-k8s.io` must succeed.
200+
- If discovery is restricted (air-gapped / locked-down clusters), set
201+
`CAPI_MACHINE_API_VERSION` (`v1beta1` or `v1beta2`) to skip discovery.
202+
- Once CAPI is present the controller resolves on its next restart — no manual action needed.
203+
184204
### Reconciliation Retrying with Increasing Delay
185205

186206
**Symptoms:**

src/constants.rs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
//! - Operator instance environment variables
1818
//! - Security constraints (`MAX_DURATION_SECS`, `RESERVED_LABEL_PREFIXES`, …)
1919
20+
use std::sync::OnceLock;
21+
2022
// ============================================================================
2123
// Kubernetes API Constants
2224
// ============================================================================
@@ -623,11 +625,52 @@ pub const CAPI_MACHINE_API_GROUP: &str = "cluster.x-k8s.io";
623625
/// CAPI API group
624626
pub const CAPI_GROUP: &str = "cluster.x-k8s.io";
625627

626-
/// CAPI Machine API version
627-
pub const CAPI_MACHINE_API_VERSION: &str = "v1beta1";
628-
629-
/// Full CAPI Machine API version string
630-
pub const CAPI_MACHINE_API_VERSION_FULL: &str = "cluster.x-k8s.io/v1beta1";
628+
/// Runtime-resolved CAPI Machine API version (`"v1beta1"` / `"v1beta2"`), recorded
629+
/// once at startup by `main` via **mandatory discovery** (or the
630+
/// `CAPI_MACHINE_API_VERSION` env override). There is deliberately NO compile-time
631+
/// default version: the controller refuses to start if it can't determine which
632+
/// version the cluster serves, rather than silently guessing one. The Machine
633+
/// *group* and *kind* (`cluster.x-k8s.io` / `Machine`) are fixed by the CAPI
634+
/// contract; only the version varies, and it is never hardcoded for production.
635+
static CAPI_MACHINE_API_VERSION_RUNTIME: OnceLock<String> = OnceLock::new();
636+
637+
/// Record the CAPI Machine API version resolved at startup (discovery or the
638+
/// `CAPI_MACHINE_API_VERSION` env override). Idempotent — only the first call
639+
/// takes effect; later calls are ignored (the served version is fixed for the
640+
/// controller's lifetime).
641+
pub fn set_capi_machine_api_version(version: String) {
642+
let _ = CAPI_MACHINE_API_VERSION_RUNTIME.set(version);
643+
}
644+
645+
/// The CAPI Machine API version the controller should create/watch Machines at.
646+
///
647+
/// Panics if called before [`set_capi_machine_api_version`] has run — that is an
648+
/// invariant violation: `main` resolves the version (mandatory discovery) before
649+
/// any reconcile runs, so the value is always present in production. Unit tests,
650+
/// which never perform real discovery, get the `v1beta1` their fixtures assume.
651+
#[must_use]
652+
pub fn capi_machine_api_version() -> &'static str {
653+
match CAPI_MACHINE_API_VERSION_RUNTIME.get() {
654+
Some(version) => version.as_str(),
655+
None => {
656+
#[cfg(test)]
657+
{
658+
"v1beta1"
659+
}
660+
#[cfg(not(test))]
661+
panic!(
662+
"CAPI Machine API version not resolved — set_capi_machine_api_version() \
663+
must run (after mandatory discovery) before any reconcile"
664+
)
665+
}
666+
}
667+
}
668+
669+
/// Full `cluster.x-k8s.io/<version>` string for the active CAPI Machine version.
670+
#[must_use]
671+
pub fn capi_machine_api_version_full() -> String {
672+
format!("{CAPI_GROUP}/{}", capi_machine_api_version())
673+
}
631674

632675
/// CAPI cluster name label
633676
pub const CAPI_CLUSTER_NAME_LABEL: &str = "cluster.x-k8s.io/cluster-name";

src/main.rs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ use std::sync::Arc;
1818
use anyhow::Result;
1919
use clap::Parser;
2020
use five_spot::constants::{
21-
CAPI_GROUP, CAPI_MACHINE_API_VERSION, CAPI_RESOURCE_MACHINES, CHILD_NODE_EVENT_CHANNEL_CAP,
22-
DEFAULT_LEASE_DURATION_SECS, DEFAULT_LEASE_GRACE_SECS, DEFAULT_LEASE_NAME,
23-
DEFAULT_LEASE_NAMESPACE, DEFAULT_LEASE_RENEW_DEADLINE_SECS, DEFAULT_LEASE_RETRY_PERIOD_SECS,
24-
HEALTH_PORT, K8S_API_TIMEOUT_SECS, METRICS_PORT, SPOT_SCHEDULE_EVENT_CHANNEL_CAP,
21+
capi_machine_api_version, set_capi_machine_api_version, CAPI_GROUP, CAPI_RESOURCE_MACHINES,
22+
CHILD_NODE_EVENT_CHANNEL_CAP, DEFAULT_LEASE_DURATION_SECS, DEFAULT_LEASE_GRACE_SECS,
23+
DEFAULT_LEASE_NAME, DEFAULT_LEASE_NAMESPACE, DEFAULT_LEASE_RENEW_DEADLINE_SECS,
24+
DEFAULT_LEASE_RETRY_PERIOD_SECS, HEALTH_PORT, K8S_API_TIMEOUT_SECS, METRICS_PORT,
25+
SPOT_SCHEDULE_EVENT_CHANNEL_CAP,
2526
};
2627
use five_spot::crd::ScheduledMachine;
2728
use five_spot::health::{start_health_server, HealthState};
@@ -179,6 +180,15 @@ async fn main() -> Result<()> {
179180
// Mark Kubernetes as connected
180181
health_state.set_k8s_connected(true);
181182

183+
// Resolve the CAPI Machine API version the cluster actually serves (v1beta1 on
184+
// CAPI ≤ v1.10, v1beta2 on v1.11+ / modern k0smotron), so we create and watch
185+
// Machines at the version downstream controllers expect. Env override wins;
186+
// discovery otherwise. MANDATORY — if it can't be resolved the controller errors
187+
// out (and is restarted by Kubernetes) rather than guessing a version.
188+
let capi_machine_version = resolve_capi_machine_version(&client).await?;
189+
info!(version = %capi_machine_version, "Using CAPI Machine API version");
190+
set_capi_machine_api_version(capi_machine_version);
191+
182192
// Create shared context
183193
let context = Arc::new(
184194
Context::new(client.clone(), cli.instance_id, cli.instance_count)
@@ -296,7 +306,7 @@ async fn main() -> Result<()> {
296306
// `SM.status.nodeRef.name` closes the watch-amplification vector
297307
// documented in Phase 3 of the 2026-04-25 security audit roadmap.
298308
let machine_ar = ApiResource::from_gvk_with_plural(
299-
&GroupVersionKind::gvk(CAPI_GROUP, CAPI_MACHINE_API_VERSION, "Machine"),
309+
&GroupVersionKind::gvk(CAPI_GROUP, capi_machine_api_version(), "Machine"),
300310
CAPI_RESOURCE_MACHINES,
301311
);
302312
let machines_api: Api<DynamicObject> = Api::all_with(client.clone(), &machine_ar);
@@ -422,6 +432,57 @@ async fn main() -> Result<()> {
422432
Ok(())
423433
}
424434

435+
/// Resolve which `cluster.x-k8s.io` Machine API version this cluster serves.
436+
///
437+
/// Precedence: the `CAPI_MACHINE_API_VERSION` env var (explicit operator pin) →
438+
/// API discovery of the group's recommended/served version. Discovery is
439+
/// **mandatory** — there is no compile-time default. If the version can't be
440+
/// determined (Cluster API not installed / not yet ready) this returns an error and
441+
/// the controller exits, to be restarted by Kubernetes — we never guess a version.
442+
/// One binary works against CAPI lines serving `v1beta1` (≤ v1.10) or `v1beta2`
443+
/// (v1.11+ / modern k0smotron) with no config.
444+
async fn resolve_capi_machine_version(client: &Client) -> Result<String> {
445+
if let Ok(pinned) = std::env::var("CAPI_MACHINE_API_VERSION") {
446+
let pinned = pinned.trim();
447+
if !pinned.is_empty() {
448+
info!(version = %pinned, "CAPI Machine API version pinned via env");
449+
return Ok(pinned.to_string());
450+
}
451+
}
452+
// Retry briefly so a controller that starts moments before CAPI finishes
453+
// installing converges instead of crash-looping on the first probe.
454+
const ATTEMPTS: u32 = 6;
455+
let mut last_reason = String::from("discovery not attempted");
456+
for attempt in 1..=ATTEMPTS {
457+
match kube::discovery::group(client, CAPI_GROUP).await {
458+
Ok(apigroup) => match apigroup.recommended_kind("Machine") {
459+
Some((ar, _caps)) => {
460+
info!(version = %ar.version, "Discovered CAPI Machine API version");
461+
return Ok(ar.version);
462+
}
463+
None => {
464+
last_reason =
465+
format!("group '{CAPI_GROUP}' is served but exposes no Machine kind");
466+
}
467+
},
468+
Err(error) => {
469+
last_reason = format!("discovery of '{CAPI_GROUP}' failed: {error}");
470+
}
471+
}
472+
warn!(
473+
attempt,
474+
max = ATTEMPTS,
475+
reason = %last_reason,
476+
"CAPI Machine API version not resolved yet; retrying"
477+
);
478+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
479+
}
480+
Err(anyhow::anyhow!(
481+
"could not resolve the CAPI Machine API version after {ATTEMPTS} attempts ({last_reason}); \
482+
is Cluster API installed? set the CAPI_MACHINE_API_VERSION env var to override discovery"
483+
))
484+
}
485+
425486
/// Check if the `ScheduledMachine` CRD is installed in the cluster
426487
async fn check_crd_installed(api: &Api<ScheduledMachine>) -> Result<()> {
427488
// Try to list resources with limit 0 - this will fail if CRD doesn't exist

0 commit comments

Comments
 (0)