engenho_runtime/runtime.rs
1//! The [`Runtime`] — single-process assembly of every engenho subsystem.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use engenho_apiserver::{
8 ApiServer, ChainAuthenticator, ClientMaterial, RbacAuthorizer, RouterHandlerSink, RouterState,
9 ServerSanInputs, StoreRbacEnv, TlsMaterial, client_verifier,
10 handlers_from_catalog_with_admission, issue_admin_client_material, issue_server_material,
11 load_or_generate_ca,
12};
13use engenho_config::{
14 ConfigError, EngenhoConfig, KubeletBackendKind as CfgBackendKind, ResolvedDatapath,
15};
16use engenho_controllers::{
17 CrdController, CronJobController, DaemonSetController, DeploymentController,
18 DynamicHandlerSink, EndpointsController, FakeRouter, GcController, IptablesRouter, IpvsRouter,
19 JobController, KindFilter, NamespaceController, PodDisruptionBudgetController,
20 PvBinderController, ReplicaSetController, ServiceRouter, ServiceRoutingController,
21 StatefulSetController, WallClock, WatchDriver, WatchDriverConfig,
22 admission::{AdmissionChain, AdmissionMode, AdmissionWebhook},
23 cluster_ip::{ClusterIpDefaultingWebhook, StoreServiceIpSource},
24};
25use engenho_kube_client::{emit_kubeconfig, emit_kubeconfig_with_admin};
26use engenho_kubelet::config_bridge::KubeletBackendKind;
27use engenho_kubelet::{ContainerRuntime, Kubelet, LogOptions, make_container_runtime};
28use engenho_scheduler::{Scheduler, make_scheduling_strategy};
29use engenho_store::{
30 InProcessRouter, ResourceKey, StoreMesh,
31 command::{Reason, ResourceCommand},
32 default_config,
33};
34use engenho_types::generated_v1_34::rbac_v1::{
35 ClusterRole, ClusterRoleBinding, PolicyRule, RoleRef, Subject,
36};
37use tokio::task::JoinHandle;
38use tracing::info;
39
40use crate::error::RuntimeError;
41
42/// The assembled single-node runtime. Owns the store spine, the
43/// apiserver, every driver task, and the container backend (retained
44/// for shutdown + test inspection).
45pub struct Runtime {
46 config: EngenhoConfig,
47 store: Arc<StoreMesh>,
48 apiserver: ApiServer,
49 drivers: Vec<JoinHandle<()>>,
50 /// Kept alive for the runtime's lifetime so the kubelet's backend
51 /// outlives every driver tick; tests pass their own clone to
52 /// [`Runtime::start_with_backend`] and inspect it.
53 #[allow(dead_code)]
54 backend: Arc<dyn ContainerRuntime>,
55}
56
57impl Runtime {
58 /// Boot every subsystem over one [`StoreMesh`]. Returns once the
59 /// apiserver is bound + every driver is spawned. The container
60 /// backend is constructed from `config.runtime.kubelet_backend`.
61 ///
62 /// # Errors
63 ///
64 /// See [`RuntimeError`] — config invalid, store start / leadership
65 /// failure, apiserver bind failure, or an unparseable listen addr.
66 pub async fn start(config: EngenhoConfig) -> Result<Self, RuntimeError> {
67 let backend = build_backend(&config);
68 Self::start_inner(config, backend).await
69 }
70
71 /// Boot with an explicit pre-built [`ContainerRuntime`] (e.g.
72 /// `Arc<FakeBackend>`) so the caller holds the inspection handle.
73 /// The config's `kubelet_backend` field is ignored — `backend` IS
74 /// the runtime the kubelet drives.
75 ///
76 /// # Errors
77 ///
78 /// Same as [`Runtime::start`].
79 pub async fn start_with_backend(
80 config: EngenhoConfig,
81 backend: Arc<dyn ContainerRuntime>,
82 ) -> Result<Self, RuntimeError> {
83 Self::start_inner(config, backend).await
84 }
85
86 async fn start_inner(
87 config: EngenhoConfig,
88 backend: Arc<dyn ContainerRuntime>,
89 ) -> Result<Self, RuntimeError> {
90 // 1. Validate the whole config (every section + cross-section).
91 config.validate()?;
92
93 // 2. Bring up the store spine. Durable = restart-safe
94 // start_or_resume; ephemeral = in-memory start +
95 // initialize_singleton (test path).
96 let store = boot_store(&config).await?;
97
98 // 3. Wait for raft leadership — MUST precede any propose.
99 let timeout_s = config.runtime.leadership_timeout_seconds;
100 if !store
101 .wait_for_leadership(Duration::from_secs(u64::from(timeout_s)))
102 .await
103 {
104 return Err(RuntimeError::LeadershipTimeout { seconds: timeout_s });
105 }
106 info!(node = %config.runtime.node_name, "store reached leadership");
107
108 // 4. Register THIS node so the scheduler has a schedulable
109 // target (the missing brick — no other code does this).
110 register_node(&store, &config.runtime.node_name).await?;
111
112 // 4.5. Seed the bootstrap RBAC policy (Brick B) — cluster-admin +
113 // system:discovery + system:basic-user + system:public-info-viewer
114 // ClusterRoles + their ClusterRoleBindings — BEFORE the apiserver
115 // binds, so the very first request authorizes against a seeded store.
116 // Idempotent (Put preserves uid across restarts, same as
117 // register_node). MUST precede step 5 so anonymous discovery + bound
118 // roles resolve through real bindings from the first request.
119 seed_bootstrap_rbac(&store).await?;
120
121 // 5. Bind the apiserver, backed by the same store.
122 let listen_addr: SocketAddr =
123 config
124 .runtime
125 .listen_addr
126 .parse()
127 .map_err(|source| RuntimeError::ListenAddr {
128 addr: config.runtime.listen_addr.clone(),
129 source,
130 })?;
131
132 // 5a. Build the TLS material BEFORE binding (when tls.enabled).
133 // load-or-generate the data_dir-persisted cluster CA, then
134 // issue a server cert whose SANs cover loopback + node name +
135 // the concrete listen IP (skipping 0.0.0.0/:: which aren't
136 // valid SAN IPs — loopback access rides on 127.0.0.1/localhost).
137 // `ca_cert_pem` is captured for the boot-time kubeconfig write.
138 //
139 // For authn: also issue + persist the admin CLIENT cert, build the
140 // OPTIONAL client-cert verifier (rooted at the SAME CA), attach the
141 // verifier to the server material, and capture the admin material
142 // for the admin-cert kubeconfig.
143 let mut ca_cert_pem: Option<String> = None;
144 let mut admin_material: Option<ClientMaterial> = None;
145 let tls: Option<TlsMaterial> = if config.runtime.tls.enabled {
146 let ca = load_or_generate_ca(&config.runtime.data_dir)
147 .map_err(|e| RuntimeError::Server(e.into()))?;
148 ca_cert_pem = Some(ca.cert_pem().to_string());
149 let listen_ip = san_listen_ip(listen_addr);
150 let material = issue_server_material(
151 &ca,
152 &ServerSanInputs {
153 node_name: &config.runtime.node_name,
154 listen_ip,
155 },
156 )
157 .map_err(|e| RuntimeError::Server(e.into()))?;
158 // OPTIONAL client-cert verifier (allow_unauthenticated): existing
159 // token/anonymous kubectl keeps connecting; a presented cert is
160 // verified against the CA before the handshake completes.
161 let verifier = client_verifier(&ca).map_err(|e| RuntimeError::Server(e.into()))?;
162 let material = material.with_client_verifier(verifier);
163 // Issue + persist the admin client cert (for the operator's
164 // kubeconfig + `kubectl auth whoami → engenho-admin`).
165 let admin =
166 issue_admin_client_material(&ca).map_err(|e| RuntimeError::Server(e.into()))?;
167 persist_admin_material(&config.runtime.data_dir, &admin)?;
168 admin_material = Some(admin);
169 Some(material)
170 } else {
171 None
172 };
173
174 // Load-or-generate the bootstrap admin BEARER token (a second admin
175 // credential alongside the client cert). Persisted under
176 // data_dir/pki/admin.token (0600) + logged so the operator can
177 // `curl -H "Authorization: Bearer <token>"`. Minted REGARDLESS of TLS:
178 // the token is a bearer SECRET (an `Authorization:` header value), not
179 // TLS material — and with Brick B's default-deny it is the ONLY way a
180 // plaintext-mode operator/test gets an admin (system:masters) identity
181 // to write through the authorizer. (Pre-Brick-B the plaintext floor had
182 // no admin token because authorize-ALL made one unnecessary.)
183 let admin_token: Option<String> =
184 Some(load_or_generate_admin_token(&config.runtime.data_dir)?);
185 if admin_token.is_some() {
186 info!("bootstrap admin bearer token available at data_dir/pki/admin.token");
187 }
188
189 // Admission chain dispatched on every API-boundary create / patch
190 // / delete. Controller writes (Reason::Controller) never flow through
191 // a handler, so they bypass admission.
192 //
193 // The ClusterIP defaulting webhook is the FIRST registered hook: on
194 // Service create with no explicit `clusterIP` it allocates a free VIP
195 // from `networking.service_cidr` and stamps `spec.clusterIP` +
196 // `spec.clusterIPs`. It reads the live Service set off the SAME store
197 // (restart-persistent + collision-free — the Services are the ledger).
198 // FailClosed so a misconfigured CIDR / exhausted pool denies the
199 // create rather than admitting a half-built Service.
200 let cluster_ip_hook: Arc<dyn AdmissionWebhook> = Arc::new(ClusterIpDefaultingWebhook::new(
201 config.networking.service_cidr.clone(),
202 Arc::new(StoreServiceIpSource::new(store.clone())),
203 ));
204 let admission = Arc::new(AdmissionChain::new(
205 vec![cluster_ip_hook],
206 AdmissionMode::FailClosed,
207 ));
208
209 // The typed authenticator chain (X509 → SA → admin-token → anonymous),
210 // carrying the configured bootstrap admin bearer token. Installed into
211 // the RouterState so the authn middleware resolves the admin bearer +
212 // admin client cert to the admin identity; everything else is unchanged.
213 let authenticator = Arc::new(ChainAuthenticator::bootstrap(admin_token));
214
215 // Build the RouterState HERE (not inside ApiServer::start) so the
216 // SAME table is shared with the CrdController's DynamicHandlerSink.
217 // A controller-driven `register()` mutates this exact ArcSwap, and
218 // the swap is visible to in-flight requests this server dispatches.
219 // The typed RBAC authorizer (Brick B), over the SAME store. Default-deny
220 // for non-admin identities; the system:masters short-circuit keeps the
221 // admin kubeconfig allow-all so every existing live proof passes; the
222 // seeded bootstrap policy (step 4.5) grants anonymous discovery + the
223 // basic-user self-review surface through real bindings.
224 let authorizer: Arc<dyn engenho_apiserver::Authorizer> =
225 Arc::new(RbacAuthorizer::new(StoreRbacEnv::new(store.clone())));
226
227 let router_state = RouterState::new(handlers_from_catalog_with_admission(
228 store.clone(),
229 admission.clone(),
230 ))
231 .with_authenticator(authenticator)
232 .with_authorizer(authorizer);
233 // The CRD handler sink: builds a StoreBackedHandler (admission-
234 // dispatched, opaque-JSON) per served CRD version + registers it
235 // into the SAME router_state. Shared (as Arc<dyn DynamicHandlerSink>)
236 // with the CrdController spawned in spawn_drivers.
237 let handler_sink: Arc<dyn DynamicHandlerSink> =
238 RouterHandlerSink::new(store.clone(), admission.clone(), router_state.clone())
239 .into_dyn();
240 // Keep a router_state clone so the Pod `/log` handler (which needs the
241 // kubelet, built later in spawn_drivers) can be registered after the
242 // kubelet exists. RouterState is Arc-backed: this clone shares the SAME
243 // handler ArcSwap the apiserver dispatches on, so a `register()` here is
244 // visible to in-flight requests (identical mechanism to the CRD sink).
245 let router_state_for_logs = router_state.clone();
246
247 let apiserver = ApiServer::start_with_state(listen_addr, router_state, tls).await?;
248 let bound_addr = apiserver.local_addr();
249 info!(addr = %bound_addr, tls = config.runtime.tls.enabled, "apiserver bound");
250
251 // 5b. Boot-time kubeconfig write (TLS only — handing kubectl an
252 // anonymous-over-plaintext kubeconfig makes no sense). Uses the
253 // ACTUALLY-bound port so an ephemeral `:0` config still yields a
254 // usable kubeconfig, and the SAME CA the server cert chains to so
255 // kubectl's certificate-authority-data verifies the presented cert.
256 //
257 // With the admin client cert issued, the kubeconfig embeds it as a
258 // CLIENT-CERT user (→ `kubectl auth whoami` = engenho-admin /
259 // system:masters). Without it (shouldn't happen when TLS is on) it
260 // falls back to the anonymous-token kubeconfig.
261 if let Some(ca_pem) = ca_cert_pem.as_deref() {
262 write_boot_kubeconfig(&config, bound_addr, ca_pem, admin_material.as_ref())?;
263 }
264
265 // 6. Construct the scheduling strategy from config. A
266 // designed-but-unimplemented strategy (BinPack/Affinity) is a
267 // typed error here — never a silent downgrade to round-robin.
268 // (config.validate() already rejects these in step 1; this is
269 // the load-bearing construction-time guard so the fallible
270 // factory can never be bypassed.)
271 let strategy = make_scheduling_strategy(&config.scheduler).map_err(|e| match e {
272 engenho_scheduler::SchedulerError::UnsupportedStrategy { requested } => {
273 RuntimeError::Config(ConfigError::InvalidField {
274 field: "scheduler.strategy".into(),
275 reason: format!("unsupported scheduling strategy: {requested:?}"),
276 })
277 }
278 other => RuntimeError::Config(ConfigError::Incoherent(other.to_string())),
279 })?;
280
281 // 7. Spawn the controller / scheduler / kubelet drivers (incl. the
282 // CrdController, which registers CR handlers into the shared
283 // router table via handler_sink). Returns the Arc<Kubelet> so the
284 // Pod `/log` reader can be wired in.
285 let (drivers, kubelet) = spawn_drivers(&config, &store, &backend, strategy, &handler_sink);
286 info!(count = drivers.len(), "drivers spawned");
287
288 // 7b. Register the Pod `/log` handler — a StoreBackedHandler for the
289 // Pod kind whose `logs` delegates to the in-process kubelet (the
290 // KubeletLogReader adapter). This REPLACES the catalog-built Pod
291 // handler (which had no log reader → /log returned NotFound) with
292 // one that serves real container stdout. Single-node: the kubelet
293 // IS this process's kubelet, so the read is in-process. `register`
294 // keys on (group, version, plural) so it overwrites the Pod entry
295 // atomically (same swap mechanism the CRD sink uses).
296 let log_reader: Arc<dyn engenho_apiserver::PodLogReader> =
297 Arc::new(KubeletLogReader { kubelet });
298 if let Some(pod_handler) = build_pod_log_handler(&store, &admission, log_reader) {
299 router_state_for_logs.register(pod_handler);
300 info!("registered Pod /log handler (in-process kubelet log reader)");
301 }
302
303 Ok(Self {
304 config,
305 store,
306 apiserver,
307 drivers,
308 backend,
309 })
310 }
311
312 /// The address the apiserver actually bound to (resolves the
313 /// `:0` ephemeral-port case to the OS-assigned port).
314 #[must_use]
315 pub fn local_addr(&self) -> SocketAddr {
316 self.apiserver.local_addr()
317 }
318
319 /// A clone of the store spine — for tools + tests.
320 #[must_use]
321 pub fn store(&self) -> Arc<StoreMesh> {
322 self.store.clone()
323 }
324
325 /// The loaded config — for tools + tests.
326 #[must_use]
327 pub fn config(&self) -> &EngenhoConfig {
328 &self.config
329 }
330
331 /// Graceful shutdown: abort + await every driver task, shut the
332 /// apiserver down (2s grace, severs open watches), then terminate
333 /// the store.
334 ///
335 /// `terminate` consumes [`StoreMesh`] and requires the SOLE strong
336 /// `Arc` ref. The driver tasks + apiserver handlers each hold a
337 /// clone; aborting + awaiting the tasks and shutting the apiserver
338 /// down drops those clones, so `Arc::try_unwrap` then succeeds.
339 ///
340 /// # Errors
341 ///
342 /// [`RuntimeError::Server`] on apiserver shutdown failure,
343 /// [`RuntimeError::StoreStillShared`] if a store clone leaked past
344 /// shutdown, or [`RuntimeError::Store`] on `terminate` failure.
345 pub async fn shutdown(self) -> Result<(), RuntimeError> {
346 // Abort each driver then await it so its captured Arc<StoreMesh>
347 // (and the controller it owns) is actually dropped before we try
348 // to unwrap the store. Awaiting an aborted JoinHandle returns a
349 // Cancelled JoinError — expected, not an error here.
350 for handle in &self.drivers {
351 handle.abort();
352 }
353 for handle in self.drivers {
354 let _ = handle.await;
355 }
356
357 // Shut the apiserver down — severs open watch long-polls (each
358 // holds a StoreBackedHandler → Arc<StoreMesh> clone) within a
359 // bounded 2s grace.
360 self.apiserver.shutdown().await?;
361
362 // Now the Runtime should hold the only strong ref. Take it.
363 let store = Arc::try_unwrap(self.store).map_err(|arc| RuntimeError::StoreStillShared {
364 strong_count: Arc::strong_count(&arc),
365 })?;
366 store.terminate().await?;
367 Ok(())
368 }
369}
370
371/// Adapter making the in-process [`Kubelet`] satisfy the apiserver's
372/// [`engenho_apiserver::PodLogReader`] seam (single-node: the apiserver +
373/// kubelet share one process, so the Pod `/log` subresource queries the
374/// kubelet's local bookkeeping directly). Translates the apiserver's typed
375/// [`engenho_apiserver::LogQuery`] → the kubelet's [`LogOptions`] and maps
376/// `KubeletError` → `ApiError`.
377///
378/// This adapter is the layering bridge: the apiserver (below the kubelet) only
379/// knows the `PodLogReader` trait; the runtime (above both) supplies the
380/// concrete kubelet behind it. A multi-node future swaps this for a node-proxy
381/// reader with no apiserver change.
382struct KubeletLogReader {
383 kubelet: Arc<Kubelet>,
384}
385
386#[async_trait::async_trait]
387impl engenho_apiserver::PodLogReader for KubeletLogReader {
388 async fn read_pod_logs(
389 &self,
390 namespace: &str,
391 name: &str,
392 query: &engenho_apiserver::LogQuery,
393 ) -> Result<String, engenho_apiserver::ApiError> {
394 let opts = LogOptions {
395 tail: query.tail_lines,
396 timestamps: query.timestamps,
397 };
398 self.kubelet
399 .container_logs(namespace, name, query.container.as_deref(), &opts)
400 .await
401 .map_err(|e| match e.kind() {
402 // A pod not on this node / a missing container → a typed 404
403 // (the K8s "could not find the requested resource" shape).
404 "invalid_pod" => engenho_apiserver::ApiError::NotFound(format!(
405 "could not get logs for pod {namespace}/{name}: {e}"
406 )),
407 // A backend read failure → 500 (never a fake-empty log).
408 _ => engenho_apiserver::ApiError::Internal(format!(
409 "log read failed for pod {namespace}/{name}: {e}"
410 )),
411 })
412 }
413}
414
415/// Build the Pod `/log`-capable handler: a `StoreBackedHandler` for the Pod
416/// kind (from the generated catalog descriptor) carrying the SAME admission
417/// chain as the catalog-built handlers PLUS the in-process kubelet log reader.
418/// Registered into the router (overwriting the no-log-reader Pod handler) so
419/// `kubectl logs` resolves the `/log` subresource to real container stdout.
420///
421/// Returns `None` only if the Pod descriptor is somehow absent from the
422/// catalog (impossible — Pod is always cataloged); the caller logs + continues
423/// (the existing no-log Pod handler stays, and `/log` returns NotFound — never
424/// a panic).
425fn build_pod_log_handler(
426 store: &Arc<StoreMesh>,
427 admission: &Arc<AdmissionChain>,
428 log_reader: Arc<dyn engenho_apiserver::PodLogReader>,
429) -> Option<Arc<dyn engenho_apiserver::ResourceHandler>> {
430 let pod_descriptor = engenho_types::generated_v1_34::RESOURCE_CATALOG
431 .iter()
432 .find(|d| d.kind == "Pod" && d.group.is_empty())?;
433 let handler =
434 engenho_apiserver::StoreBackedHandler::from_descriptor(store.clone(), pod_descriptor)
435 .with_admission(admission.clone())
436 .with_log_reader(log_reader);
437 Some(Arc::new(handler))
438}
439
440/// Construct the container backend from the operator's config choice.
441fn build_backend(config: &EngenhoConfig) -> Arc<dyn ContainerRuntime> {
442 let kind = match config.runtime.kubelet_backend {
443 CfgBackendKind::Podman => KubeletBackendKind::Podman,
444 CfgBackendKind::Fake => KubeletBackendKind::Fake,
445 };
446 make_container_runtime(kind, config.runtime.podman_binary.as_deref())
447}
448
449/// Bring up the store spine — durable or ephemeral per config.
450async fn boot_store(config: &EngenhoConfig) -> Result<Arc<StoreMesh>, RuntimeError> {
451 let cfg = default_config(&config.cluster.name)?;
452 let router = InProcessRouter::new();
453 // Single-node self-loop address; registration happens inside start.
454 let listen = "in-process://1".to_string();
455
456 if config.runtime.durable {
457 let store_path = config.runtime.data_dir.join("store");
458 let (mesh, fresh) = StoreMesh::start_or_resume(1, listen, router, cfg, store_path).await?;
459 info!(fresh, "durable store opened");
460 Ok(Arc::new(mesh))
461 } else {
462 let mesh = StoreMesh::start(1, listen, router, cfg).await?;
463 mesh.initialize_singleton().await?;
464 info!("ephemeral store initialized");
465 Ok(Arc::new(mesh))
466 }
467}
468
469/// Idempotent Node self-registration. Re-Put on restart is fine — the
470/// store preserves `metadata.uid` across updates. K8s shape mirrors the
471/// scheduler's `is_schedulable` expectation: `spec.unschedulable=false`
472/// + a Ready=True condition.
473///
474/// Writes `status.allocatable` (+ `status.capacity`) for cpu/memory.
475/// This is LOAD-BEARING: engenho-scheduler's M0.1 resource-fit predicate
476/// uses a zero-on-absent allocatable policy (an un-sized node fits NO
477/// pod that requests cpu/memory). Without these values, every pod that
478/// declares a request would stay Pending forever. We report the host's
479/// actual logical-CPU count + total memory so the single-node cluster
480/// advertises real capacity.
481async fn register_node(store: &StoreMesh, node_name: &str) -> Result<(), RuntimeError> {
482 let (cpu, memory) = host_capacity();
483 let mut value = serde_json::json!({
484 "kind": "Node",
485 "apiVersion": "v1",
486 "metadata": { "name": node_name },
487 "spec": { "unschedulable": false },
488 "status": {
489 "capacity": { "cpu": cpu, "memory": memory },
490 "allocatable": { "cpu": cpu, "memory": memory },
491 "conditions": [{ "type": "Ready", "status": "True" }]
492 }
493 });
494 // Route the self-registered Node through the SAME boundary stamp the
495 // apiserver create path uses, so AGE works on the Node too. Frozen once
496 // into the replicated Put.
497 stamp_creation_timestamp_value(&mut value);
498 store
499 .propose(ResourceCommand::Put {
500 key: ResourceKey::cluster_scoped("", "v1", "Node", node_name),
501 value,
502 expected: None,
503 reason: Reason::Operator,
504 })
505 .await?;
506 info!(node = %node_name, "registered schedulable node");
507 Ok(())
508}
509
510/// Inject `metadata.creationTimestamp` (if absent) into an opaque JSON
511/// body from the typed RFC3339 boundary render — the non-handler `Put`
512/// seeders (register_node) route through this so every born object,
513/// including the self-registered Node, carries a real creationTimestamp.
514/// Mirrors the apiserver handler's `stamp_creation_timestamp`.
515fn stamp_creation_timestamp_value(body: &mut serde_json::Value) {
516 if let Some(obj) = body.as_object_mut() {
517 let metadata = obj
518 .entry("metadata".to_string())
519 .or_insert_with(|| serde_json::json!({}));
520 if let Some(meta_obj) = metadata.as_object_mut() {
521 let absent = !meta_obj.contains_key("creationTimestamp")
522 || meta_obj.get("creationTimestamp") == Some(&serde_json::Value::Null);
523 if absent {
524 meta_obj.insert(
525 "creationTimestamp".to_string(),
526 serde_json::Value::String(engenho_types::time::now_rfc3339_utc()),
527 );
528 }
529 }
530 }
531}
532
533/// The RBAC group + version every seed key carries.
534const RBAC_GROUP: &str = "rbac.authorization.k8s.io";
535const RBAC_VERSION: &str = "v1";
536
537/// Seed the bootstrap RBAC policy (Brick B). Idempotently `Put`s the canonical
538/// bootstrap ClusterRoles + ClusterRoleBindings so:
539///
540/// * `system:masters` resolves `*.*` through a REAL binding (cluster-admin),
541/// belt-and-suspenders behind the authorizer's short-circuit — so
542/// `kubectl auth can-i --list` shows `*.*` via a binding too.
543/// * anonymous + authenticated DISCOVERY (`/api`, `/apis`, `/openapi/v3`, …)
544/// resolves through the `system:discovery` (authenticated) +
545/// `system:public-info-viewer` (anonymous) bindings — TIER-2 reachability,
546/// so kubectl's pre-auth discovery works without 403'ing.
547/// * every authenticated user gets the minimal `system:basic-user` self-review
548/// surface (selfsubject* creates).
549///
550/// Each seed is a TYPED Rust value (`ClusterRole`/`ClusterRoleBinding`) →
551/// `serde_json::to_value` → `ResourceCommand::Put` (TYPED EMISSION — no `json!()`
552/// of the policy bodies; only the Put envelope helper). Idempotent because Put
553/// preserves `metadata.uid` across restarts, exactly like `register_node`.
554async fn seed_bootstrap_rbac(store: &StoreMesh) -> Result<(), RuntimeError> {
555 // ── cluster-admin: full access to everything (resources + non-resource). ──
556 let cluster_admin = ClusterRole {
557 metadata: rbac_meta("cluster-admin"),
558 rules: vec![
559 PolicyRule {
560 verbs: vec!["*".into()],
561 api_groups: vec!["*".into()],
562 resources: vec!["*".into()],
563 ..Default::default()
564 },
565 PolicyRule {
566 verbs: vec!["*".into()],
567 non_resource_urls: vec!["*".into()],
568 ..Default::default()
569 },
570 ],
571 ..Default::default()
572 };
573 let cluster_admin_binding = ClusterRoleBinding {
574 metadata: rbac_meta("cluster-admin"),
575 role_ref: cluster_role_ref("cluster-admin"),
576 subjects: vec![group_subject("system:masters")],
577 };
578
579 // ── system:discovery: GET the discovery + openapi non-resource URLs. ──
580 let discovery = ClusterRole {
581 metadata: rbac_meta("system:discovery"),
582 rules: vec![PolicyRule {
583 verbs: vec!["get".into()],
584 non_resource_urls: vec![
585 "/api".into(),
586 "/api/*".into(),
587 "/apis".into(),
588 "/apis/*".into(),
589 "/openapi".into(),
590 "/openapi/*".into(),
591 "/version".into(),
592 "/version/*".into(),
593 "/healthz".into(),
594 "/livez".into(),
595 "/readyz".into(),
596 ],
597 ..Default::default()
598 }],
599 ..Default::default()
600 };
601 let discovery_binding = ClusterRoleBinding {
602 metadata: rbac_meta("system:discovery"),
603 role_ref: cluster_role_ref("system:discovery"),
604 subjects: vec![group_subject("system:authenticated")],
605 };
606
607 // ── system:basic-user: the minimal selfsubject* create surface. ──
608 let basic_user = ClusterRole {
609 metadata: rbac_meta("system:basic-user"),
610 rules: vec![
611 PolicyRule {
612 verbs: vec!["create".into()],
613 api_groups: vec!["authorization.k8s.io".into()],
614 resources: vec![
615 "selfsubjectaccessreviews".into(),
616 "selfsubjectrulesreviews".into(),
617 ],
618 ..Default::default()
619 },
620 PolicyRule {
621 verbs: vec!["create".into()],
622 api_groups: vec!["authentication.k8s.io".into()],
623 resources: vec!["selfsubjectreviews".into()],
624 ..Default::default()
625 },
626 ],
627 ..Default::default()
628 };
629 let basic_user_binding = ClusterRoleBinding {
630 metadata: rbac_meta("system:basic-user"),
631 role_ref: cluster_role_ref("system:basic-user"),
632 subjects: vec![group_subject("system:authenticated")],
633 };
634
635 // ── system:public-info-viewer: GET health/version for ALL (incl. anon). ──
636 let public_info = ClusterRole {
637 metadata: rbac_meta("system:public-info-viewer"),
638 rules: vec![PolicyRule {
639 verbs: vec!["get".into()],
640 non_resource_urls: vec![
641 "/healthz".into(),
642 "/livez".into(),
643 "/readyz".into(),
644 "/version".into(),
645 "/version/*".into(),
646 // Anonymous discovery: kubectl hits these before any
647 // authenticated call; granting them to BOTH authenticated +
648 // unauthenticated keeps the existing anonymous-kubeconfig path.
649 "/api".into(),
650 "/api/*".into(),
651 "/apis".into(),
652 "/apis/*".into(),
653 "/openapi".into(),
654 "/openapi/*".into(),
655 ],
656 ..Default::default()
657 }],
658 ..Default::default()
659 };
660 let public_info_binding = ClusterRoleBinding {
661 metadata: rbac_meta("system:public-info-viewer"),
662 role_ref: cluster_role_ref("system:public-info-viewer"),
663 subjects: vec![
664 group_subject("system:authenticated"),
665 group_subject("system:unauthenticated"),
666 ],
667 };
668
669 // Put each typed value. ClusterRole + ClusterRoleBinding are cluster-scoped.
670 put_cluster_role(store, &cluster_admin).await?;
671 put_cluster_role_binding(store, &cluster_admin_binding).await?;
672 put_cluster_role(store, &discovery).await?;
673 put_cluster_role_binding(store, &discovery_binding).await?;
674 put_cluster_role(store, &basic_user).await?;
675 put_cluster_role_binding(store, &basic_user_binding).await?;
676 put_cluster_role(store, &public_info).await?;
677 put_cluster_role_binding(store, &public_info_binding).await?;
678
679 info!(
680 "seeded bootstrap RBAC policy (cluster-admin + system:discovery + system:basic-user + system:public-info-viewer)"
681 );
682 Ok(())
683}
684
685/// Typed [`ObjectMeta`] carrying just a name — the shape every bootstrap RBAC
686/// object needs. The `creationTimestamp` is stamped at the Put boundary
687/// (`put_cluster_role` / `put_cluster_role_binding` route the serialized
688/// value through `stamp_creation_timestamp_value`), so every seeded RBAC
689/// object carries a real timestamp exactly like the apiserver create path.
690fn rbac_meta(name: &str) -> engenho_types::meta::ObjectMeta {
691 engenho_types::meta::ObjectMeta {
692 name: name.to_string(),
693 ..Default::default()
694 }
695}
696
697/// A `RoleRef` pointing at a cluster-scoped `ClusterRole` by name.
698fn cluster_role_ref(name: &str) -> RoleRef {
699 RoleRef {
700 api_group: RBAC_GROUP.to_string(),
701 kind: "ClusterRole".to_string(),
702 name: name.to_string(),
703 }
704}
705
706/// A `Group` subject — the bootstrap bindings bind to groups
707/// (`system:masters`, `system:authenticated`, `system:unauthenticated`).
708fn group_subject(name: &str) -> Subject {
709 Subject {
710 kind: "Group".to_string(),
711 api_group: Some(RBAC_GROUP.to_string()),
712 name: name.to_string(),
713 namespace: None,
714 }
715}
716
717/// `Put` a typed `ClusterRole` (cluster-scoped) with `Reason::Operator`. The
718/// body is serialized from the typed value (TYPED EMISSION); only the Put
719/// envelope is hand-built.
720async fn put_cluster_role(store: &StoreMesh, cr: &ClusterRole) -> Result<(), RuntimeError> {
721 let mut value = serde_json::to_value(cr)
722 .map_err(|e| RuntimeError::Server(seed_serialize_err("ClusterRole", &e)))?;
723 stamp_creation_timestamp_value(&mut value);
724 let name = cr.metadata.name.clone();
725 store
726 .propose(ResourceCommand::Put {
727 key: ResourceKey::cluster_scoped(RBAC_GROUP, RBAC_VERSION, "ClusterRole", name),
728 value,
729 expected: None,
730 reason: Reason::Operator,
731 })
732 .await?;
733 Ok(())
734}
735
736/// `Put` a typed `ClusterRoleBinding` (cluster-scoped) with `Reason::Operator`.
737async fn put_cluster_role_binding(
738 store: &StoreMesh,
739 crb: &ClusterRoleBinding,
740) -> Result<(), RuntimeError> {
741 let mut value = serde_json::to_value(crb)
742 .map_err(|e| RuntimeError::Server(seed_serialize_err("ClusterRoleBinding", &e)))?;
743 stamp_creation_timestamp_value(&mut value);
744 let name = crb.metadata.name.clone();
745 store
746 .propose(ResourceCommand::Put {
747 key: ResourceKey::cluster_scoped(RBAC_GROUP, RBAC_VERSION, "ClusterRoleBinding", name),
748 value,
749 expected: None,
750 reason: Reason::Operator,
751 })
752 .await?;
753 Ok(())
754}
755
756/// A serialize-failure during seeding (effectively impossible for the concrete
757/// typed structs) becomes a typed apiserver ServerError so boot fails loudly —
758/// never a silent skip.
759fn seed_serialize_err(kind: &str, e: &serde_json::Error) -> engenho_apiserver::ServerError {
760 engenho_apiserver::ServerError::Serve(std::io::Error::other(format!(
761 "failed to serialize bootstrap {kind}: {e}"
762 )))
763}
764
765/// Host capacity advertised on the self-registered Node: `(cpu, memory)`
766/// as K8s quantity strings.
767///
768/// CPU is the host's logical-core count (`std::thread::available_parallelism`,
769/// falling back to 1). Memory is a conservative fixed default (`8Gi`,
770/// the documented engenho-local VM size) — a real total-memory probe is
771/// a follow-up (would add a sysinfo dep); the value only needs to be a
772/// truthful lower bound for the resource-fit predicate to admit normal
773/// workloads. Both are integer/SI quantities the scheduler parses back
774/// through the typed `Quantity` surface.
775fn host_capacity() -> (String, String) {
776 let cpus = std::thread::available_parallelism()
777 .map(std::num::NonZeroUsize::get)
778 .unwrap_or(1);
779 (cpus.to_string(), "8Gi".to_string())
780}
781
782/// The listen IP to add as a server-cert SAN, or `None` when it isn't a
783/// usable SAN IP. `0.0.0.0` / `::` are *unspecified* bind addresses, not
784/// valid SAN IPs — a cert with an unspecified-IP SAN verifies against no
785/// real connection, so we drop them and let the always-present
786/// `127.0.0.1` + `localhost` SANs carry loopback access.
787fn san_listen_ip(addr: SocketAddr) -> Option<std::net::IpAddr> {
788 let ip = addr.ip();
789 if ip.is_unspecified() { None } else { Some(ip) }
790}
791
792/// Write `data_dir/kubeconfig` (mode 0600) so an operator can immediately
793/// `kubectl --kubeconfig <data_dir>/kubeconfig get nodes`. server_url is
794/// `https://127.0.0.1:<bound_port>` (loopback SAN + the real bound port);
795/// `ca_pem` is the cluster CA the server cert chains to.
796///
797/// When `admin` is supplied, the kubeconfig embeds the admin CLIENT CERT (→
798/// `kubectl auth whoami` = engenho-admin / system:masters); otherwise it falls
799/// back to the anonymous-token kubeconfig (the plaintext / no-admin path).
800///
801/// The admin form embeds `client-key-data` — the admin private key, verbatim —
802/// so the file IS a credential and is owner-only. The operator reaching for
803/// `kubectl --kubeconfig` is the owner, so 0600 costs that path nothing. The
804/// anonymous form carries only a public placeholder token, but it is written
805/// through the same path at the same mode rather than branching: one mode for
806/// one filename means the admin case cannot inherit the laxer one.
807fn write_boot_kubeconfig(
808 config: &EngenhoConfig,
809 bound_addr: SocketAddr,
810 ca_pem: &str,
811 admin: Option<&ClientMaterial>,
812) -> Result<(), RuntimeError> {
813 // Loopback server URL with the actually-bound port (handles `:0`).
814 let server_url = loopback_server_url(bound_addr);
815 let yaml = match admin {
816 Some(admin) => emit_kubeconfig_with_admin(
817 &config.cluster.name,
818 &server_url,
819 ca_pem.as_bytes(),
820 admin.cert_pem.as_bytes(),
821 admin.key_pem.as_bytes(),
822 ),
823 None => emit_kubeconfig(&config.cluster.name, &server_url, ca_pem.as_bytes()),
824 }
825 .map_err(|e| RuntimeError::Kubeconfig(e.to_string()))?;
826 let path = config.runtime.data_dir.join("kubeconfig");
827 write_kubeconfig_file(&path, &yaml)?;
828 info!(path = %path.display(), server = %server_url, admin = admin.is_some(), "kubeconfig written");
829 Ok(())
830}
831
832/// Persist the admin client cert + key under `data_dir/pki/` (cert 0644, key
833/// 0600) so the operator's kubeconfig has a STABLE admin credential across
834/// boots (matches the server-cert / CA persistence shape).
835fn persist_admin_material(
836 data_dir: &std::path::Path,
837 admin: &ClientMaterial,
838) -> Result<(), RuntimeError> {
839 let pki = data_dir.join("pki");
840 create_pki_dir(&pki)?;
841 write_at_mode(&pki.join("admin.crt"), &admin.cert_pem, 0o644)?;
842 write_at_mode(&pki.join("admin.key"), &admin.key_pem, 0o600)?;
843 Ok(())
844}
845
846/// Load-or-generate the bootstrap admin BEARER token, persisted at
847/// `data_dir/pki/admin.token` (0600). Restart-stable: an already-distributed
848/// `Authorization: Bearer <token>` keeps working across reboots. The token is
849/// 32 random bytes hex-encoded (no external crate — uses `getrandom` via
850/// `rand`-free `std`-adjacent entropy from the OS).
851fn load_or_generate_admin_token(data_dir: &std::path::Path) -> Result<String, RuntimeError> {
852 let pki = data_dir.join("pki");
853 let token_path = pki.join("admin.token");
854 if let Ok(existing) = std::fs::read_to_string(&token_path) {
855 let trimmed = existing.trim().to_string();
856 if !trimmed.is_empty() {
857 return Ok(trimmed);
858 }
859 }
860 create_pki_dir(&pki)?;
861 let token = random_admin_token();
862 write_at_mode(&token_path, &token, 0o600)?;
863 Ok(token)
864}
865
866/// Generate a 32-byte random admin token as a 64-char lowercase hex string,
867/// seeded from OS entropy (`getrandom`). On the (effectively impossible) OS
868/// entropy failure, fall back to a process-+time-derived value so boot never
869/// hard-fails on the secret-mint path (the token is still 32 bytes; it just
870/// isn't CSPRNG-grade in that degenerate case — logged is acceptable for a
871/// single-node bootstrap admin token).
872fn random_admin_token() -> String {
873 let mut bytes = [0u8; 32];
874 if getrandom::fill(&mut bytes).is_err() {
875 // Degenerate fallback: mix process id + nanos. Never expected.
876 let pid = std::process::id().to_le_bytes();
877 let nanos = std::time::SystemTime::now()
878 .duration_since(std::time::UNIX_EPOCH)
879 .map(|d| d.as_nanos())
880 .unwrap_or(0)
881 .to_le_bytes();
882 for (i, b) in bytes.iter_mut().enumerate() {
883 *b = pid[i % pid.len()] ^ nanos[i % nanos.len()] ^ (i as u8);
884 }
885 }
886 let mut out = String::with_capacity(64);
887 for b in bytes {
888 out.push(char::from_digit(u32::from(b >> 4), 16).unwrap());
889 out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap());
890 }
891 out
892}
893
894/// Create `data_dir/pki` at 0700 — it holds the admin private key and the
895/// admin bearer token, and a 0600 file inside a 0755 directory is still
896/// listable. Matches the mode `engenho-apiserver`'s PKI loader already gives
897/// the same directory, whichever of the two reaches it first.
898#[cfg(all(unix, feature = "with-cofre"))]
899fn create_pki_dir(pki: &std::path::Path) -> Result<(), RuntimeError> {
900 cofre_fs::create_secret_dir(pki, 0o700).map_err(|source| RuntimeError::KubeconfigIo {
901 path: pki.to_path_buf(),
902 source,
903 })
904}
905
906/// Unix without cofre: create dir then set mode via chmod.
907#[cfg(all(unix, not(feature = "with-cofre")))]
908fn create_pki_dir(pki: &std::path::Path) -> Result<(), RuntimeError> {
909 std::fs::create_dir_all(pki).map_err(|source| RuntimeError::KubeconfigIo {
910 path: pki.to_path_buf(),
911 source,
912 })?;
913 use std::os::unix::fs::PermissionsExt;
914 std::fs::set_permissions(pki, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
915 RuntimeError::KubeconfigIo {
916 path: pki.to_path_buf(),
917 source,
918 }
919 })
920}
921
922/// Non-unix: no modes to set.
923#[cfg(not(unix))]
924fn create_pki_dir(pki: &std::path::Path) -> Result<(), RuntimeError> {
925 std::fs::create_dir_all(pki).map_err(|source| RuntimeError::KubeconfigIo {
926 path: pki.to_path_buf(),
927 source,
928 })
929}
930
931/// Create `path` holding `contents` with exactly `mode`, set by `open(2)`
932/// itself rather than by a follow-up `chmod`.
933///
934/// `cofre_fs::write_secret` owns that property: the bits land in the syscall
935/// that creates the inode, so there is no interval during which the admin key
936/// or the bearer token is 0644-and-world-readable, and `create_new` after an
937/// unlink means a pre-placed symlink is not written through. The mode is a
938/// required argument there, which is why it stays one here.
939#[cfg(all(unix, feature = "with-cofre"))]
940fn write_at_mode(path: &std::path::Path, contents: &str, mode: u32) -> Result<(), RuntimeError> {
941 cofre_fs::write_secret(path, contents.as_bytes(), mode).map_err(|source| {
942 RuntimeError::KubeconfigIo {
943 path: path.to_path_buf(),
944 source,
945 }
946 })
947}
948
949/// Unix without cofre: write then chmod. Has a brief window at default perms.
950#[cfg(all(unix, not(feature = "with-cofre")))]
951fn write_at_mode(path: &std::path::Path, contents: &str, mode: u32) -> Result<(), RuntimeError> {
952 std::fs::write(path, contents.as_bytes()).map_err(|source| RuntimeError::KubeconfigIo {
953 path: path.to_path_buf(),
954 source,
955 })?;
956 use std::os::unix::fs::PermissionsExt;
957 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
958 RuntimeError::KubeconfigIo {
959 path: path.to_path_buf(),
960 source,
961 }
962 })
963}
964
965/// Non-unix: file modes don't apply, so this is a plain write. cofre-fs is
966/// `#![cfg(unix)]` for the same reason.
967#[cfg(not(unix))]
968fn write_at_mode(path: &std::path::Path, contents: &str, _mode: u32) -> Result<(), RuntimeError> {
969 std::fs::write(path, contents.as_bytes()).map_err(|source| RuntimeError::KubeconfigIo {
970 path: path.to_path_buf(),
971 source,
972 })
973}
974
975/// `https://127.0.0.1:<port>` — the loopback URL kubectl targets. We use
976/// loopback (not the bound IP) because `127.0.0.1` is always a server-cert
977/// SAN, so the kubeconfig is usable even when `listen_addr` is `0.0.0.0`.
978fn loopback_server_url(bound_addr: SocketAddr) -> String {
979 let mut url = String::from("https://127.0.0.1:");
980 url.push_str(&bound_addr.port().to_string());
981 url
982}
983
984/// Write the kubeconfig at mode 0600 (see [`write_boot_kubeconfig`] for why
985/// owner-only). Creates the parent dir if missing (it normally exists — the
986/// durable store already opened `data_dir/store`); the parent is `data_dir`
987/// itself, which holds non-secret state too, so its mode is left alone.
988fn write_kubeconfig_file(path: &std::path::Path, contents: &str) -> Result<(), RuntimeError> {
989 if let Some(parent) = path.parent() {
990 std::fs::create_dir_all(parent).map_err(|source| RuntimeError::KubeconfigIo {
991 path: parent.to_path_buf(),
992 source,
993 })?;
994 }
995 write_at_mode(path, contents, 0o600)
996}
997
998/// Build + spawn every driver gated on `controllers.enable.*`. The
999/// scheduler + kubelet always run (a single-node runtime that can't
1000/// schedule or run containers is useless); the four reconcilers are
1001/// individually toggleable.
1002///
1003/// Returns `(handles, kubelet)` — the spawned driver tasks PLUS an
1004/// `Arc<Kubelet>` clone. The kubelet is built once, shared (via the
1005/// `Controller for Arc<C>` blanket impl) between its WatchDriver AND the
1006/// apiserver's Pod `/log` reader, so the driver's ticks + the log queries see
1007/// the SAME local bookkeeping.
1008fn spawn_drivers(
1009 config: &EngenhoConfig,
1010 store: &Arc<StoreMesh>,
1011 backend: &Arc<dyn ContainerRuntime>,
1012 strategy: Box<dyn engenho_scheduler::SchedulingStrategy>,
1013 handler_sink: &Arc<dyn DynamicHandlerSink>,
1014) -> (Vec<JoinHandle<()>>, Arc<Kubelet>) {
1015 let mut handles = Vec::new();
1016
1017 // Namespace scope: empty string in config means "all namespaces".
1018 let ns: Option<String> = {
1019 let n = &config.controllers.namespace;
1020 if n.is_empty() { None } else { Some(n.clone()) }
1021 };
1022
1023 let debounce = Duration::from_millis(u64::from(config.controllers.debounce_milliseconds));
1024 let fallback = Duration::from_secs(u64::from(config.controllers.fallback_interval_seconds));
1025
1026 let driver_config = |kinds: &[&str]| WatchDriverConfig {
1027 filter: KindFilter::Kinds(kinds.iter().map(|k| (*k).to_string()).collect()),
1028 debounce,
1029 fallback_interval: fallback,
1030 };
1031
1032 let enable = &config.controllers.enable;
1033
1034 if enable.deployment {
1035 let c = DeploymentController::new(store.clone(), ns.clone());
1036 handles.push(
1037 WatchDriver::new(
1038 c,
1039 store.clone(),
1040 driver_config(&["Deployment", "ReplicaSet"]),
1041 )
1042 .spawn(),
1043 );
1044 }
1045 if enable.replicaset {
1046 let c = ReplicaSetController::new(store.clone(), ns.clone());
1047 handles.push(
1048 WatchDriver::new(c, store.clone(), driver_config(&["ReplicaSet", "Pod"])).spawn(),
1049 );
1050 }
1051 if enable.statefulset {
1052 let c = StatefulSetController::new(store.clone(), ns.clone());
1053 handles.push(
1054 WatchDriver::new(c, store.clone(), driver_config(&["StatefulSet", "Pod"])).spawn(),
1055 );
1056 }
1057 if enable.daemonset {
1058 let c = DaemonSetController::new(store.clone(), ns.clone());
1059 handles.push(
1060 WatchDriver::new(
1061 c,
1062 store.clone(),
1063 driver_config(&["DaemonSet", "Pod", "Node"]),
1064 )
1065 .spawn(),
1066 );
1067 }
1068 if enable.job {
1069 let c = JobController::new(store.clone(), ns.clone());
1070 handles.push(WatchDriver::new(c, store.clone(), driver_config(&["Job", "Pod"])).spawn());
1071 }
1072 // CronJob: parses spec.schedule (5-field cron) against the WallClock and
1073 // creates a batch/v1 Job from the jobTemplate on schedule; the
1074 // JobController above then runs that Job's Pods. Watches CronJob (its own
1075 // kind) + Job (to observe owned-Job activity for the concurrency policy);
1076 // the fallback tick is what actually drives the time-based firing (a
1077 // CronJob has no spec edit each minute to wake a pure event watch).
1078 if enable.cronjob {
1079 let c = CronJobController::new(store.clone(), Arc::new(WallClock), ns.clone());
1080 handles
1081 .push(WatchDriver::new(c, store.clone(), driver_config(&["CronJob", "Job"])).spawn());
1082 }
1083 if enable.pdb {
1084 let c = PodDisruptionBudgetController::new(store.clone(), ns.clone());
1085 handles.push(
1086 WatchDriver::new(
1087 c,
1088 store.clone(),
1089 driver_config(&["PodDisruptionBudget", "Pod"]),
1090 )
1091 .spawn(),
1092 );
1093 }
1094 if enable.endpoints {
1095 let c = EndpointsController::new(store.clone(), ns.clone());
1096 handles.push(
1097 WatchDriver::new(
1098 c,
1099 store.clone(),
1100 driver_config(&["Service", "Pod", "Endpoints", "EndpointSlice"]),
1101 )
1102 .spawn(),
1103 );
1104 }
1105 // Service routing: resolves Service + Endpoints → typed ServiceRoutes
1106 // and drives the platform-selected datapath backend. The backend is
1107 // chosen by `networking.datapath_mode` resolved against the host
1108 // platform (Auto → iptables on Linux, compute-only off-Linux) so on a
1109 // Darwin dev host the controller still runs + computes + observes the
1110 // desired rules without ever shelling to a non-existent
1111 // `iptables-restore`. Watches the same kinds the EndpointsController
1112 // does (Service + Endpoints/EndpointSlice) plus the fallback tick.
1113 if enable.service_routing {
1114 let resolved = config
1115 .networking
1116 .datapath_mode
1117 .resolve(cfg!(target_os = "linux"));
1118 let backend = make_service_router(resolved);
1119 info!(
1120 datapath = backend.name(),
1121 mode = ?config.networking.datapath_mode,
1122 "service routing backend selected"
1123 );
1124 let c = ServiceRoutingController::new(store.clone(), backend, ns.clone());
1125 handles.push(
1126 WatchDriver::new(
1127 c,
1128 store.clone(),
1129 driver_config(&["Service", "Endpoints", "EndpointSlice"]),
1130 )
1131 .spawn(),
1132 );
1133 }
1134 if enable.gc {
1135 let c = GcController::new(store.clone(), ns.clone());
1136 handles.push(
1137 WatchDriver::new(
1138 c,
1139 store.clone(),
1140 WatchDriverConfig {
1141 filter: KindFilter::All,
1142 debounce,
1143 fallback_interval: fallback,
1144 },
1145 )
1146 .spawn(),
1147 );
1148 }
1149
1150 // Namespace: cascade-deletion of a Terminating namespace's contents +
1151 // finalizer clear. Watches the cluster-scoped Namespace kind (KindFilter
1152 // matches on ev.key.kind, so cluster-scoped works) PLUS the fallback tick
1153 // so a Terminating namespace drains over a few reconcile cycles. Mirrors
1154 // the gc block; gated on controllers.enable.namespace.
1155 if enable.namespace {
1156 let c = NamespaceController::new(store.clone(), ns.clone());
1157 handles.push(WatchDriver::new(c, store.clone(), driver_config(&["Namespace"])).spawn());
1158 }
1159
1160 // PV/PVC binder: binds Pending PersistentVolumeClaims to matching
1161 // Available PersistentVolumes (capacity ≥ request, accessModes ⊇ requested,
1162 // storageClassName equal, volumeName pre-bind) and dynamically provisions a
1163 // node-local hostPath PV (under data_dir/local-path) via the local-path
1164 // provisioner / default StorageClass when no static PV matches. Watches the
1165 // three storage kinds; the fallback tick covers a PV/SC that appears after
1166 // the PVC (and vice versa). The host effect (mkdir of the local-path
1167 // backing dir) is the HostProvisionerEnv default; gated on
1168 // controllers.enable.pv_binder.
1169 if enable.pv_binder {
1170 let local_path_root = config
1171 .runtime
1172 .data_dir
1173 .join("local-path")
1174 .to_string_lossy()
1175 .into_owned();
1176 let c = PvBinderController::new(store.clone(), ns.clone(), local_path_root);
1177 handles.push(
1178 WatchDriver::new(
1179 c,
1180 store.clone(),
1181 driver_config(&["PersistentVolumeClaim", "PersistentVolume", "StorageClass"]),
1182 )
1183 .spawn(),
1184 );
1185 }
1186
1187 // CRD: CustomResourceDefinition → dynamic CR-handler registration. The
1188 // controller registers a StoreBackedHandler per served CRD version into
1189 // the shared RouterState via `handler_sink`, so CR instances become
1190 // routable + discoverable with no parallel codepath. Filtered to
1191 // ["CustomResourceDefinition"] so only CRD writes wake it; the fallback
1192 // tick covers cold start / missed events (so a CRD installed before the
1193 // driver subscribed still gets registered on the first fallback tick).
1194 if enable.crd {
1195 let c = CrdController::new(store.clone(), handler_sink.clone());
1196 handles.push(
1197 WatchDriver::new(
1198 c,
1199 store.clone(),
1200 driver_config(&["CustomResourceDefinition"]),
1201 )
1202 .spawn(),
1203 );
1204 }
1205
1206 // Scheduler: pending Pod → spec.nodeName. Watches Pods + Nodes.
1207 // The strategy was constructed fallibly by the caller (a typed error
1208 // for unimplemented strategies — never a silent round-robin fallback).
1209 {
1210 let c = Scheduler::new(store.clone(), strategy, ns.clone());
1211 handles.push(WatchDriver::new(c, store.clone(), driver_config(&["Pod", "Node"])).spawn());
1212 }
1213
1214 // Kubelet: bound Pod → container via the backend. Watches Pods. Built
1215 // ONCE as an Arc<Kubelet> so the SAME instance is shared between its
1216 // WatchDriver (via the `Controller for Arc<C>` blanket impl) and the
1217 // apiserver's Pod `/log` reader — both see one local bookkeeping map.
1218 let kubelet = Arc::new(Kubelet::new(
1219 store.clone(),
1220 backend.clone(),
1221 config.runtime.node_name.clone(),
1222 ));
1223 handles.push(WatchDriver::new(kubelet.clone(), store.clone(), driver_config(&["Pod"])).spawn());
1224
1225 (handles, kubelet)
1226}
1227
1228/// Construct the `ServiceRouter` backend for a resolved datapath choice.
1229///
1230/// Typed dispatch — `ResolvedDatapath` (the pure output of
1231/// `DatapathMode::resolve`) maps 1:1 to a backend: `Iptables`/`Ipvs` are
1232/// the kernel backends (a Linux node installs the VIP datapath), and
1233/// `ComputeOnly` is the `FakeRouter` (routes computed + observable, nothing
1234/// installed in the kernel — the fail-safe off-Linux dev path, surfacing
1235/// `DatapathInstall::Computed`). No `cfg!` lives here: the platform was
1236/// already folded into `resolved` by the caller, so this stays directly
1237/// testable.
1238fn make_service_router(resolved: ResolvedDatapath) -> Arc<dyn ServiceRouter> {
1239 match resolved {
1240 ResolvedDatapath::Iptables => Arc::new(IptablesRouter::new()),
1241 ResolvedDatapath::Ipvs => Arc::new(IpvsRouter::new()),
1242 ResolvedDatapath::ComputeOnly => Arc::new(FakeRouter::new()),
1243 }
1244}
1245
1246// `make_scheduling_strategy` returns `Result<Box<dyn SchedulingStrategy>,
1247// SchedulerError>`; the boxed strategy is unwrapped fallibly in
1248// `start_inner` (typed error for unimplemented strategies) and handed to
1249// `spawn_drivers`. `Scheduler::new<S: SchedulingStrategy + 'static>`
1250// accepts the box (Box<dyn Trait> implements Trait via the blanket impl).
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use engenho_config::KubeletBackendKind as CfgKind;
1256 use shikumi::TieredConfig;
1257
1258 fn ephemeral_test_config() -> EngenhoConfig {
1259 let mut cfg = EngenhoConfig::prescribed_default();
1260 cfg.runtime.listen_addr = "127.0.0.1:0".into();
1261 cfg.runtime.durable = false;
1262 cfg.runtime.node_name = "node-A".into();
1263 cfg.runtime.kubelet_backend = CfgKind::Fake;
1264 cfg.runtime.leadership_timeout_seconds = 5;
1265 // Plaintext: these unit tests assert subsystem assembly, not TLS.
1266 cfg.runtime.tls.enabled = false;
1267 // A WRITABLE data_dir under the system temp root — the prescribed
1268 // `/var/lib/engenho` isn't writable in CI, and since Brick B the
1269 // bootstrap admin BEARER token is minted (persisted under data_dir/pki)
1270 // on EVERY boot (plaintext incl.), so a writable data_dir is required
1271 // even for the ephemeral path. A unique per-test subdir avoids
1272 // cross-test collisions; the small PKI dir is left for the OS temp
1273 // sweeper (no cleanup handle needed for a unit test).
1274 //
1275 // pid + a process-local COUNTER, not a timestamp. `SystemTime` here
1276 // resolves to microseconds on macOS (the nanos always end in `000`),
1277 // so two `#[tokio::test]`s entering this function in the same
1278 // microsecond got the SAME data_dir and raced over
1279 // `pki/admin.token`. That was invisible while the token was written
1280 // with `fs::write` (last writer wins); `cofre_fs::write_secret` uses
1281 // `create_new`, which reports the collision as `AlreadyExists`
1282 // instead of absorbing it. The counter makes the name unique by
1283 // construction rather than by clock luck.
1284 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1285 let unique = format!(
1286 "engenho-test-{}-{}",
1287 std::process::id(),
1288 SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1289 );
1290 cfg.runtime.data_dir = std::env::temp_dir().join(unique);
1291 cfg.controllers.fallback_interval_seconds = 1;
1292 cfg.controllers.debounce_milliseconds = 20;
1293 cfg
1294 }
1295
1296 #[tokio::test]
1297 async fn runtime_boots_all_subsystems_and_registers_node() {
1298 let rt = Runtime::start(ephemeral_test_config()).await.unwrap();
1299 // apiserver bound to an ephemeral port.
1300 assert_ne!(rt.local_addr().port(), 0);
1301 // The Node we self-registered is in the store.
1302 let key = ResourceKey::cluster_scoped("", "v1", "Node", "node-A");
1303 let node = rt.store().get(&key).await.expect("Node registered");
1304 assert_eq!(node.get("kind").unwrap(), "Node");
1305 assert_eq!(
1306 node.get("spec").unwrap().get("unschedulable").unwrap(),
1307 false
1308 );
1309 // Drivers: 12 reconcilers (deployment, replicaset, statefulset,
1310 // daemonset, job, cronjob, endpoints, pdb, service_routing, gc,
1311 // namespace, pv_binder, crd) + scheduler + kubelet = 15.
1312 assert_eq!(rt.drivers.len(), 15);
1313 rt.shutdown().await.unwrap();
1314 }
1315
1316 #[tokio::test]
1317 async fn runtime_rejects_invalid_config() {
1318 let mut cfg = ephemeral_test_config();
1319 cfg.runtime.node_name = String::new();
1320 // `Runtime` isn't `Debug` (holds ApiServer + trait-object
1321 // backend), so match the result rather than `unwrap_err()`.
1322 match Runtime::start(cfg).await {
1323 Err(RuntimeError::Config(_)) => {}
1324 Err(other) => panic!("expected Config error, got {other:?}"),
1325 Ok(_) => panic!("expected validation failure, got a booted Runtime"),
1326 }
1327 }
1328
1329 #[tokio::test]
1330 async fn runtime_rejects_unimplemented_scheduling_strategy() {
1331 // A config asking for BinPack must fail fast at boot with a typed
1332 // Config error — NEVER silently boot a round-robin cluster.
1333 let mut cfg = ephemeral_test_config();
1334 cfg.scheduler.strategy = engenho_config::SchedulerStrategyKind::BinPack;
1335 match Runtime::start(cfg).await {
1336 Err(RuntimeError::Config(ConfigError::InvalidField { field, .. })) => {
1337 assert_eq!(field, "scheduler.strategy");
1338 }
1339 Err(other) => panic!("expected Config/InvalidField, got {other:?}"),
1340 Ok(_) => panic!("expected a typed strategy error, got a booted Runtime"),
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn registered_node_advertises_allocatable() {
1346 // The companion fix: the self-registered Node MUST carry
1347 // status.allocatable so the resource-fit predicate (zero-on-absent)
1348 // admits normal workloads.
1349 let rt = Runtime::start(ephemeral_test_config()).await.unwrap();
1350 let key = ResourceKey::cluster_scoped("", "v1", "Node", "node-A");
1351 let node = rt.store().get(&key).await.expect("Node registered");
1352 let alloc = node
1353 .get("status")
1354 .and_then(|s| s.get("allocatable"))
1355 .expect("status.allocatable present");
1356 assert!(
1357 alloc.get("cpu").and_then(|c| c.as_str()).is_some(),
1358 "allocatable.cpu must be set; node={node:#}"
1359 );
1360 assert!(
1361 alloc.get("memory").and_then(|m| m.as_str()).is_some(),
1362 "allocatable.memory must be set; node={node:#}"
1363 );
1364 rt.shutdown().await.unwrap();
1365 }
1366
1367 #[test]
1368 fn make_service_router_dispatches_by_resolved_datapath() {
1369 // The typed backend dispatch: each ResolvedDatapath arm maps to the
1370 // matching ServiceRouter implementation (by stable backend name).
1371 assert_eq!(
1372 make_service_router(ResolvedDatapath::Iptables).name(),
1373 "iptables"
1374 );
1375 assert_eq!(make_service_router(ResolvedDatapath::Ipvs).name(), "ipvs");
1376 assert_eq!(
1377 make_service_router(ResolvedDatapath::ComputeOnly).name(),
1378 "fake"
1379 );
1380 }
1381
1382 #[test]
1383 fn datapath_auto_selects_compute_only_off_linux() {
1384 // The platform-selection contract, exercised through the config
1385 // resolve + backend construction the runtime uses — tested with an
1386 // explicit platform arg (not cfg!): Auto off-Linux is compute-only
1387 // (so a Darwin dev host never shells to iptables), Auto on Linux
1388 // installs the iptables kernel datapath.
1389 let mode = engenho_config::DatapathMode::Auto;
1390 assert_eq!(
1391 make_service_router(mode.resolve(false)).name(),
1392 "fake",
1393 "Auto off-Linux must be the compute-only FakeRouter"
1394 );
1395 assert_eq!(
1396 make_service_router(mode.resolve(true)).name(),
1397 "iptables",
1398 "Auto on Linux installs the iptables kernel datapath"
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn disabling_service_routing_drops_one_driver() {
1404 // Gating works: turning off enable.service_routing removes exactly
1405 // one spawned driver (15 → 14).
1406 let mut cfg = ephemeral_test_config();
1407 cfg.controllers.enable.service_routing = false;
1408 let rt = Runtime::start(cfg).await.unwrap();
1409 assert_eq!(rt.drivers.len(), 14);
1410 rt.shutdown().await.unwrap();
1411 }
1412}