Skip to main content

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}