Skip to content

Commit 8e2e114

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 8e2e114

4 files changed

Lines changed: 331 additions & 37 deletions

File tree

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

src/reconcilers/helpers.rs

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,15 @@ use tracing::{debug, error, info, warn};
4242

4343
use super::{Context, ReconcilerError};
4444
use crate::constants::{
45-
ALLOWED_BOOTSTRAP_API_GROUPS, ALLOWED_INFRASTRUCTURE_API_GROUPS, API_VERSION_FULL,
46-
CAPI_CLUSTER_NAME_LABEL, CAPI_GROUP, CAPI_MACHINE_API_VERSION, CAPI_MACHINE_API_VERSION_FULL,
47-
CAPI_RESOURCE_MACHINES, CONDITION_STATUS_TRUE, CONDITION_TYPE_READY, DEFAULT_INSTANCE_ID,
48-
ENV_OPERATOR_INSTANCE_ID, ERROR_REQUEUE_SECS, FINALIZER_CLEANUP_TIMEOUT_SECS,
49-
FINALIZER_SCHEDULED_MACHINE, MAX_BACKOFF_SECS, MAX_CLUSTER_NAME_LEN, MAX_DURATION_SECS,
50-
MAX_KILL_IF_COMMANDS_COUNT, MAX_KILL_IF_COMMAND_LEN, MAX_RECONCILE_RETRIES, PHASE_ACTIVE,
51-
PHASE_ERROR, PHASE_INACTIVE, PHASE_SHUTTING_DOWN, PHASE_TERMINATED,
52-
POD_EVICTION_GRACE_PERIOD_SECS, RBAC_VERB_CREATE, REASON_GRACE_PERIOD, REASON_KILL_SWITCH,
53-
REASON_RECONCILE_SUCCEEDED, RESERVED_LABEL_PREFIXES, SCHEDULED_MACHINE_LABEL,
54-
TIMER_REQUEUE_SECS,
45+
capi_machine_api_version, ALLOWED_BOOTSTRAP_API_GROUPS, ALLOWED_INFRASTRUCTURE_API_GROUPS,
46+
API_VERSION_FULL, CAPI_CLUSTER_NAME_LABEL, CAPI_GROUP, CAPI_RESOURCE_MACHINES,
47+
CONDITION_STATUS_TRUE, CONDITION_TYPE_READY, DEFAULT_INSTANCE_ID, ENV_OPERATOR_INSTANCE_ID,
48+
ERROR_REQUEUE_SECS, FINALIZER_CLEANUP_TIMEOUT_SECS, FINALIZER_SCHEDULED_MACHINE,
49+
MAX_BACKOFF_SECS, MAX_CLUSTER_NAME_LEN, MAX_DURATION_SECS, MAX_KILL_IF_COMMANDS_COUNT,
50+
MAX_KILL_IF_COMMAND_LEN, MAX_RECONCILE_RETRIES, PHASE_ACTIVE, PHASE_ERROR, PHASE_INACTIVE,
51+
PHASE_SHUTTING_DOWN, PHASE_TERMINATED, POD_EVICTION_GRACE_PERIOD_SECS, RBAC_VERB_CREATE,
52+
REASON_GRACE_PERIOD, REASON_KILL_SWITCH, REASON_RECONCILE_SUCCEEDED, RESERVED_LABEL_PREFIXES,
53+
SCHEDULED_MACHINE_LABEL, TIMER_REQUEUE_SECS,
5554
};
5655
use crate::crd::{Condition, NodeRef, ScheduledMachine, ScheduledMachineStatus};
5756
use crate::metrics::{
@@ -1113,6 +1112,23 @@ pub fn validate_schedule_ref(
11131112
// CAPI Resource Creation
11141113
// ============================================================================
11151114

1115+
/// Derive the CAPI `Machine` API version (`v1beta1` / `v1beta2`) for a
1116+
/// `ScheduledMachine` from the version component of its bootstrapSpec `apiVersion`
1117+
/// (passthrough — the Machine shares the one CAPI contract version the user
1118+
/// declared for bootstrap + infra). Falls back to the runtime-resolved *served*
1119+
/// version ([`capi_machine_api_version`]) when bootstrap carries no `/<version>`
1120+
/// segment. The Machine group/kind (`cluster.x-k8s.io`/`Machine`) are fixed by the
1121+
/// CAPI contract; only the version is data-driven, never hardcoded for production.
1122+
fn machine_api_version_for(bootstrap_api_version: Option<&str>) -> String {
1123+
bootstrap_api_version
1124+
.and_then(|bv| bv.rsplit_once('/').map(|(_, ver)| ver))
1125+
.filter(|ver| !ver.is_empty())
1126+
.map_or_else(
1127+
|| capi_machine_api_version().to_string(),
1128+
|ver| ver.to_string(),
1129+
)
1130+
}
1131+
11161132
/// Add machine to cluster by creating bootstrap, infrastructure, and Machine resources
11171133
///
11181134
/// This function:
@@ -1166,6 +1182,12 @@ pub async fn add_machine_to_cluster(
11661182
"bootstrapSpec missing required field 'apiVersion'".to_string(),
11671183
)
11681184
})?;
1185+
// The created CAPI Machine shares the contract version the user declared on
1186+
// bootstrapSpec (passthrough); see machine_api_version_for.
1187+
let machine_api_version_full = format!(
1188+
"{CAPI_GROUP}/{}",
1189+
machine_api_version_for(Some(bootstrap_api_version))
1190+
);
11691191
let bootstrap_kind = resource.spec.bootstrap_spec.kind().ok_or_else(|| {
11701192
ReconcilerError::InvalidConfig("bootstrapSpec missing required field 'kind'".to_string())
11711193
})?;
@@ -1245,7 +1267,7 @@ pub async fn add_machine_to_cluster(
12451267
ensure_can_create(
12461268
client,
12471269
namespace,
1248-
CAPI_MACHINE_API_VERSION_FULL,
1270+
&machine_api_version_full,
12491271
"Machine",
12501272
"machine",
12511273
)
@@ -1370,7 +1392,7 @@ pub async fn add_machine_to_cluster(
13701392
}
13711393

13721394
let machine_obj = json!({
1373-
"apiVersion": CAPI_MACHINE_API_VERSION_FULL,
1395+
"apiVersion": machine_api_version_full.as_str(),
13741396
"kind": "Machine",
13751397
"metadata": {
13761398
"name": name,
@@ -1401,7 +1423,7 @@ pub async fn add_machine_to_cluster(
14011423
create_dynamic_resource(
14021424
client,
14031425
namespace,
1404-
CAPI_MACHINE_API_VERSION_FULL,
1426+
&machine_api_version_full,
14051427
"Machine",
14061428
machine_obj,
14071429
)
@@ -1625,9 +1647,13 @@ pub async fn remove_machine_from_cluster(
16251647
"Deleting CAPI resources"
16261648
);
16271649

1628-
// 1. Delete the Machine resource first (this triggers CAPI cleanup)
1650+
// 1. Delete the Machine resource first (this triggers CAPI cleanup). Use the
1651+
// CAPI contract version the user declared on bootstrapSpec (passthrough,
1652+
// matching create); fall back to the runtime-discovered served version when
1653+
// it has none. CAPI conversion bridges either way.
1654+
let machine_api_version = machine_api_version_for(resource.spec.bootstrap_spec.api_version());
16291655
let ar = kube::api::ApiResource::from_gvk_with_plural(
1630-
&kube::api::GroupVersionKind::gvk(CAPI_GROUP, CAPI_MACHINE_API_VERSION, "Machine"),
1656+
&kube::api::GroupVersionKind::gvk(CAPI_GROUP, &machine_api_version, "Machine"),
16311657
CAPI_RESOURCE_MACHINES,
16321658
);
16331659
let machines: Api<kube::core::DynamicObject> =
@@ -1696,9 +1722,21 @@ pub fn extract_machine_refs(
16961722
.get("status")
16971723
.and_then(|s| s.get("nodeRef"))
16981724
.and_then(|nr| {
1699-
let api_version = nr.get("apiVersion").and_then(serde_json::Value::as_str)?;
1700-
let kind = nr.get("kind").and_then(serde_json::Value::as_str)?;
1725+
// `name` is the only field guaranteed across CAPI versions: v1beta1's
1726+
// nodeRef is a corev1 ObjectReference (apiVersion/kind/uid present),
1727+
// but v1beta2 simplified it to a `MachineNodeReference` carrying just
1728+
// `name` (it is always a core/v1 Node). Require only `name`; default
1729+
// the rest so v1beta2 nodeRef enrichment (node taints, reclaim-agent)
1730+
// still fires.
17011731
let name = nr.get("name").and_then(serde_json::Value::as_str)?;
1732+
let api_version = nr
1733+
.get("apiVersion")
1734+
.and_then(serde_json::Value::as_str)
1735+
.unwrap_or("v1");
1736+
let kind = nr
1737+
.get("kind")
1738+
.and_then(serde_json::Value::as_str)
1739+
.unwrap_or("Node");
17021740
let uid = nr
17031741
.get("uid")
17041742
.and_then(serde_json::Value::as_str)
@@ -1727,7 +1765,7 @@ pub async fn fetch_capi_machine(
17271765
machine_name: &str,
17281766
) -> Result<Option<kube::core::DynamicObject>, ReconcilerError> {
17291767
let ar = kube::api::ApiResource::from_gvk_with_plural(
1730-
&kube::api::GroupVersionKind::gvk(CAPI_GROUP, CAPI_MACHINE_API_VERSION, "Machine"),
1768+
&kube::api::GroupVersionKind::gvk(CAPI_GROUP, capi_machine_api_version(), "Machine"),
17311769
CAPI_RESOURCE_MACHINES,
17321770
);
17331771
let machines: Api<kube::core::DynamicObject> =

0 commit comments

Comments
 (0)