Skip to main content

boatramp_node/
compute.rs

1//! Compute-backend assembly (moved from the binary — node-library N2).
2//!
3//! Builds this node's compute [`BackendRegistry`](boatramp_core::compute::BackendRegistry)
4//! and scheduler [`Node`](boatramp_core::compute::Node) inventory from the optional
5//! `[compute]` config, capability-detecting Docker, native-container, and
6//! embedded-VMM backends. Lives here (not in the backend-agnostic
7//! `boatramp-server`) because it depends on the concrete backend crates; the
8//! binary and future `assemble()` call [`build_compute`].
9
10use std::sync::Arc;
11
12/// The posture-scaled kernel-trust gate wired into the compute backends: it runs
13/// [`boatramp_core::kernel_trust::verify_kernel`] on the staged kernel right
14/// before boot. The always-on check is the content hash; under the strict
15/// (multi-tenant) posture it additionally requires the pinned hash to be on the
16/// static allow-list and to carry a signature — sourced from the **live fleet
17/// default kernel** — verifying against a static signing key. No daemon, or a hash
18/// that isn't the current signed default, has no signature source and so **fails
19/// closed** under strict: the kernel does not boot.
20#[cfg(target_os = "linux")]
21struct PostureKernelVerifier {
22    strict: bool,
23    signing_keys: Vec<String>,
24    allowed_hashes: Vec<String>,
25    daemon: Option<Arc<boatramp_server::DaemonRuntime>>,
26}
27
28// `KernelVerifier` requires `Debug`, but `DaemonRuntime` isn't `Debug` (it holds a
29// lock + a `Notify`); summarise instead of recursing into it.
30#[cfg(target_os = "linux")]
31impl std::fmt::Debug for PostureKernelVerifier {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("PostureKernelVerifier")
34            .field("strict", &self.strict)
35            .field("signing_keys", &self.signing_keys.len())
36            .field("allowed_hashes", &self.allowed_hashes.len())
37            .field("has_daemon", &self.daemon.is_some())
38            .finish()
39    }
40}
41
42#[cfg(target_os = "linux")]
43impl boatramp_firecracker::KernelVerifier for PostureKernelVerifier {
44    // Fully-qualified: this module aliases `Result<T>` to its own error type.
45    fn verify(&self, bytes: &[u8], expected_hash: &str) -> std::result::Result<(), String> {
46        // The only signature we trust for this hash is the one on the current
47        // fleet default kernel (the operator-vetted kernel); any other hash has no
48        // signature source and fails the strict bar.
49        let sig = self
50            .daemon
51            .as_ref()
52            .and_then(|d| d.effective().default_kernel.clone())
53            .filter(|dk| dk.sha256 == expected_hash)
54            .and_then(|dk| dk.sig);
55        let kref = boatramp_core::daemon_config::KernelRef {
56            source: expected_hash.to_string(),
57            sha256: expected_hash.to_string(),
58            sig,
59        };
60        boatramp_core::kernel_trust::verify_kernel(
61            bytes,
62            &kref,
63            self.strict,
64            &self.signing_keys,
65            &self.allowed_hashes,
66        )
67        .map_err(|e| e.to_string())
68    }
69}
70
71/// The macOS-VMM twin of [`PostureKernelVerifier`], implementing
72/// [`boatramp_vz::KernelVerifier`] with the identical posture-scaled trust logic
73/// so the Virtualization.framework backend enforces the same verify-before-boot
74/// bar as the KVM backend (the kernel is ring-0 code on either substrate).
75#[cfg(target_os = "macos")]
76struct VzPostureKernelVerifier {
77    strict: bool,
78    signing_keys: Vec<String>,
79    allowed_hashes: Vec<String>,
80    daemon: Option<Arc<boatramp_server::DaemonRuntime>>,
81}
82
83#[cfg(target_os = "macos")]
84impl std::fmt::Debug for VzPostureKernelVerifier {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("VzPostureKernelVerifier")
87            .field("strict", &self.strict)
88            .field("signing_keys", &self.signing_keys.len())
89            .field("allowed_hashes", &self.allowed_hashes.len())
90            .field("has_daemon", &self.daemon.is_some())
91            .finish()
92    }
93}
94
95#[cfg(target_os = "macos")]
96impl boatramp_vz::KernelVerifier for VzPostureKernelVerifier {
97    fn verify(&self, bytes: &[u8], expected_hash: &str) -> std::result::Result<(), String> {
98        let sig = self
99            .daemon
100            .as_ref()
101            .and_then(|d| d.effective().default_kernel.clone())
102            .filter(|dk| dk.sha256 == expected_hash)
103            .and_then(|dk| dk.sig);
104        let kref = boatramp_core::daemon_config::KernelRef {
105            source: expected_hash.to_string(),
106            sha256: expected_hash.to_string(),
107            sig,
108        };
109        boatramp_core::kernel_trust::verify_kernel(
110            bytes,
111            &kref,
112            self.strict,
113            &self.signing_keys,
114            &self.allowed_hashes,
115        )
116        .map_err(|e| e.to_string())
117    }
118}
119
120/// Whether this host can run the macOS VMM backend: **Apple silicon** (arm64) on
121/// **macOS 15+** (the Virtualization.framework Linux-container floor). Detected via
122/// `sysctl` — `hw.optional.arm64 == 1` and `kern.osproductversion >= 15`. macOS 26
123/// is recommended (macOS 15 lacks container-to-container networking over vmnet),
124/// but single-node serve works on 15, so 15 is the floor; the operator's macOS
125/// version determines multi-replica cross-VM reachability, not the user surface.
126#[cfg(target_os = "macos")]
127fn macos_supports_vz() -> bool {
128    // Apple silicon: the Virtualization.framework Linux path is arm64-only.
129    if cfg!(not(target_arch = "aarch64")) {
130        return false;
131    }
132    let major = sysctl_string("kern.osproductversion")
133        .and_then(|v| v.split('.').next().and_then(|m| m.parse::<u32>().ok()));
134    matches!(major, Some(m) if m >= 15)
135}
136
137/// Read a string `sysctl` by name (e.g. `kern.osproductversion`). `None` on any
138/// failure — the caller treats an unreadable sysctl as "unsupported" (fail-closed).
139#[cfg(target_os = "macos")]
140fn sysctl_string(name: &str) -> Option<String> {
141    let out = std::process::Command::new("sysctl")
142        .args(["-n", name])
143        .output()
144        .ok()?;
145    if !out.status.success() {
146        return None;
147    }
148    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
149}
150
151/// Build this node's compute [`BackendRegistry`] + scheduler [`Node`] inventory
152/// from the optional `[compute]` config. Backends
153/// are **capability-detected**: a reachable Docker daemon ⇒ `docker`; Linux ⇒ the
154/// native `container` backend; Linux + `/dev/kvm` ⇒ the in-process
155/// `vmm-embedded` microVM backend (strongest isolation). Absent config ⇒ an empty
156/// registry + a node advertising nothing, so the reconcile loop stays a no-op.
157pub async fn build_compute(
158    cfg: Option<&crate::config::ComputeConfig>,
159    storage: std::sync::Arc<dyn boatramp_core::Storage>,
160    data_dir: &std::path::Path,
161    node_id: u64,
162    strict: bool,
163    daemon: Option<Arc<boatramp_server::DaemonRuntime>>,
164    // The binary the re-exec'd container/microVM workers run as; `None` ⇒ this
165    // process's own executable (`current_exe`). An embedding harness points it at a
166    // built `boatramp` binary so the workers find the `__sandbox`/`__vmm-run`/
167    // `__vz-run` subcommands. See [`crate::node::NodeInput::worker_exe`].
168    worker_exe: Option<&std::path::Path>,
169) -> (
170    boatramp_core::compute::BackendRegistry,
171    boatramp_core::compute::Node,
172) {
173    use boatramp_core::compute::{BackendKind, BackendRegistry, Node};
174    let mut backends: BackendRegistry = std::collections::BTreeMap::new();
175    let empty_node = |id| Node {
176        id,
177        region: None,
178        labels: std::collections::BTreeMap::new(),
179        free_vcpus: 0,
180        free_mem_mib: 0,
181        backends: Vec::new(),
182    };
183    let Some(cfg) = cfg else {
184        return (backends, empty_node(node_id));
185    };
186
187    // Remote docker: register only if a daemon actually answers.
188    match boatramp_docker::DockerBackend::connect() {
189        Ok(docker) => {
190            // `writable_root` and `cap_add` are honored only under the single-tenant
191            // posture (`!strict`); the multi-tenant guard keeps the hardened read-only
192            // root and every capability dropped.
193            let docker = docker
194                .with_endpoint(cfg.docker_endpoint)
195                .with_volume_mode(cfg.docker_volume_mode)
196                .with_data_dir(data_dir)
197                .with_writable_root_allowed(!strict)
198                .with_cap_add_allowed(!strict);
199            if docker.reachable().await {
200                backends.insert("docker".to_string(), std::sync::Arc::new(docker));
201            } else {
202                tracing::debug!("no reachable docker daemon; skipping docker backend");
203            }
204        }
205        Err(e) => tracing::debug!(%e, "docker backend unavailable"),
206    }
207
208    // Ensure the shared compute bridge exists before the backends that enslave a
209    // veth/tap to it. boatramp creates it itself over netlink (needs `CAP_NET_ADMIN`)
210    // rather than requiring the operator to pre-create it — so a stock image on a fresh
211    // host is turnkey. If it can't be created, the container + embedded-VMM backends are
212    // skipped rather than advertised and then failing at launch on the missing bridge.
213    // The SINGLE shared IP authority for the compute bridge/subnet (A5): every backend
214    // that places a guest on `cfg.bridge` (the native container backend's veths + the
215    // embedded-VMM backend's taps) draws from and releases to this ONE pool, so two
216    // backends on the same L2 can never be handed the same address. Built once here and
217    // injected (a clone) into each. `None` if the subnet is malformed (both backends are
218    // then skipped, same as before).
219    #[cfg(target_os = "linux")]
220    let shared_ip_authority: Option<boatramp_core::ipam::IpAuthority> =
221        match boatramp_core::ipam::IpAuthority::new(&cfg.subnet) {
222            Ok(a) => Some(a),
223            Err(e) => {
224                tracing::warn!(%e, subnet = %cfg.subnet, "bad compute subnet; container + embedded-VMM backends disabled");
225                None
226            }
227        };
228    #[cfg(target_os = "linux")]
229    let bridge_ready = match &shared_ip_authority {
230        Some(authority) => {
231            match boatramp_container::ensure_bridge(
232                &cfg.bridge,
233                authority.gateway(),
234                authority.prefix_len(),
235            )
236            .await
237            {
238                Ok(()) => true,
239                Err(e) => {
240                    tracing::warn!(%e, bridge = %cfg.bridge, "could not create the compute bridge (need CAP_NET_ADMIN); container + embedded-VMM backends disabled");
241                    false
242                }
243            }
244        }
245        None => false,
246    };
247
248    // Native container backend (Linux only).
249    #[cfg(target_os = "linux")]
250    if bridge_ready {
251        match worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())) {
252            Ok(self_exe) => match boatramp_container::ContainerBackend::new(
253                storage.clone(),
254                data_dir.to_path_buf(),
255                cfg.bridge.clone(),
256                &cfg.subnet,
257                self_exe,
258            ) {
259                Ok(c) => {
260                    // Single-tenant posture (`!strict`) may honor `cap_add`; multi-tenant
261                    // keeps every capability dropped.
262                    let c = c.with_cap_add_allowed(!strict);
263                    // Point each container's resolv.conf at the internal DNS on the
264                    // bridge gateway when the resolver is enabled (default on). The
265                    // node starts the resolver task (see `spawn_internal_dns`); the
266                    // two must agree on the domain, so both read `compute.dns_domain`.
267                    let c = c.with_internal_dns(cfg.internal_dns.then(|| cfg.dns_domain.clone()));
268                    // Share the ONE bridge/subnet IP authority (A5) so a co-located
269                    // embedded-VMM guest and a container can never get the same address.
270                    let c = match &shared_ip_authority {
271                        Some(a) => c.with_ip_authority(a.clone()),
272                        None => c,
273                    };
274                    backends.insert("container".to_string(), std::sync::Arc::new(c));
275                }
276                Err(e) => tracing::warn!(%e, "container backend unavailable"),
277            },
278            Err(e) => tracing::warn!(%e, "current_exe for container backend"),
279        }
280    }
281    // Embedded VMM backend (Linux + x86_64 + `/dev/kvm`): in-process microVMs, no
282    // external `firecracker` binary — the strongest isolation when KVM is available.
283    // Like the container backend it enslaves each tap to `cfg.bridge` (ensured above,
284    // hence the `bridge_ready` gate). The embedded VMM is KVM-x86-specific, so this is
285    // x86_64-only; boatramp
286    // still serves on linux/aarch64 (with the container backend, no embedded VMM).
287    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
288    if bridge_ready && std::path::Path::new("/dev/kvm").exists() {
289        match (
290            worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
291            boatramp_core::ipam::IpPool::new(&cfg.subnet),
292        ) {
293            (Ok(self_exe), Ok(pool)) => {
294                let gateway = pool.gateway().to_string();
295                // Verify-before-boot gate for every kernel this backend stages.
296                let verifier: Arc<dyn boatramp_firecracker::KernelVerifier> =
297                    Arc::new(PostureKernelVerifier {
298                        strict,
299                        signing_keys: cfg.kernel_signing_pubkeys.clone(),
300                        allowed_hashes: cfg.kernel_allowed_hashes.clone(),
301                        daemon: daemon.clone(),
302                    });
303                match boatramp_firecracker::EmbeddedVmmBackend::new(
304                    storage.clone(),
305                    self_exe, // re-exec'd as `__vmm-run` per VM (jailed subprocess)
306                    data_dir.to_path_buf(),
307                    cfg.bridge.clone(),
308                    gateway,
309                    &cfg.subnet,
310                    verifier,
311                ) {
312                    Ok(vmm) => {
313                        // Share the ONE bridge/subnet IP authority with the co-located
314                        // container backend (A5): both draw from and release to one pool,
315                        // so a tap and a veth on the same L2 never collide on an address.
316                        let vmm = match &shared_ip_authority {
317                            Some(a) => vmm.with_ip_authority(a.clone()),
318                            None => vmm,
319                        };
320                        backends.insert("vmm-embedded".to_string(), std::sync::Arc::new(vmm));
321                    }
322                    Err(e) => tracing::warn!(%e, "embedded VMM backend unavailable"),
323                }
324            }
325            (Err(e), _) => tracing::warn!(%e, "current_exe for VMM backend"),
326            (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for VMM backend"),
327        }
328    } else {
329        tracing::debug!("no /dev/kvm; skipping embedded VMM backend");
330    }
331
332    // macOS-native VMM backend (Apple silicon + macOS): each replica is a Linux
333    // microVM under Virtualization.framework, run by a re-exec'd `__vz-run`
334    // worker. Strong isolation (VmKvm), matching the KVM backend's user surface.
335    // Capability-detected + log-skipped on Intel / older macOS, exactly like the
336    // `/dev/kvm` check gates the Linux VMM.
337    #[cfg(target_os = "macos")]
338    if macos_supports_vz() {
339        match (
340            worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
341            boatramp_core::ipam::IpPool::new(&cfg.subnet),
342        ) {
343            (Ok(self_exe), Ok(_pool)) => {
344                let verifier: Arc<dyn boatramp_vz::KernelVerifier> =
345                    Arc::new(VzPostureKernelVerifier {
346                        strict,
347                        signing_keys: cfg.kernel_signing_pubkeys.clone(),
348                        allowed_hashes: cfg.kernel_allowed_hashes.clone(),
349                        daemon: daemon.clone(),
350                    });
351                match boatramp_vz::VzBackend::new(
352                    storage.clone(),
353                    self_exe, // re-exec'd as `__vz-run` per VM
354                    data_dir.to_path_buf(),
355                    &cfg.subnet, // the vmnet range (e.g. 192.168.64.0/24); `.1` = gateway
356                    verifier,
357                ) {
358                    // `writable_root` honored only under the single-tenant posture.
359                    Ok(vz) => {
360                        // The vz backend keeps its own private IP authority (A5): on macOS
361                        // it is the sole compute backend on the vmnet segment (no native
362                        // container / KVM backend to share with), so there is nothing to
363                        // collide with. It still accepts a shared authority via
364                        // `with_ip_authority` for a uniform surface.
365                        let vz = vz.with_writable_root_allowed(!strict);
366                        backends.insert("vmm-vz".to_string(), std::sync::Arc::new(vz));
367                    }
368                    Err(e) => tracing::warn!(%e, "macOS VMM backend unavailable"),
369                }
370            }
371            (Err(e), _) => tracing::warn!(%e, "current_exe for macOS VMM backend"),
372            (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for macOS VMM backend"),
373        }
374    } else {
375        tracing::debug!("not Apple silicon + macOS 15+; skipping macOS VMM backend");
376    }
377
378    let _ = (&storage, data_dir); // used only on Linux/macOS (container / VMM backends)
379                                  // The kernel-trust verifier is wired for the embedded VMM (x86_64 Linux) and
380                                  // the macOS VMM; silence `strict`/`daemon` on the platforms that wire neither
381                                  // (linux/aarch64, and any non-Linux non-macOS host).
382    #[cfg(not(any(all(target_os = "linux", target_arch = "x86_64"), target_os = "macos")))]
383    let _ = (strict, &daemon);
384
385    let free_vcpus = if cfg.vcpus > 0 {
386        cfg.vcpus
387    } else {
388        std::thread::available_parallelism()
389            .map(|n| n.get() as u32)
390            .unwrap_or(1)
391    };
392    let free_mem_mib = if cfg.mem_mib > 0 { cfg.mem_mib } else { 1024 };
393    let advertised: Vec<BackendKind> = backends
394        .iter()
395        .map(|(id, b)| {
396            let caps = b.capabilities();
397            BackendKind {
398                id: id.clone(),
399                isolation: caps.isolation,
400                persistent_volumes: caps.persistent_volumes,
401                scale_to_zero: caps.scale_to_zero,
402            }
403        })
404        .collect();
405    tracing::info!(backends = ?advertised, free_vcpus, free_mem_mib, "compute node inventory");
406    let node = Node {
407        id: node_id,
408        region: cfg.region.clone(),
409        labels: std::collections::BTreeMap::new(),
410        free_vcpus,
411        free_mem_mib,
412        backends: advertised,
413    };
414    (backends, node)
415}
416
417/// Adopt the IPs of already-running replicas into each compute backend's
418/// fresh-on-boot IP pool, so the backend reserves live addresses before the
419/// reconcile loop allocates any new one. Reads every persisted replica across all
420/// projects, parses each endpoint's IPv4 address (from the routable endpoint host,
421/// falling back to the `<ip>:<port>` `backend_ref`), groups them by backend, and
422/// hands each backend the `(workload, replica, ip)` tuples for its own replicas.
423///
424/// Only backends with a per-node IP pool act on it (the native `container` backend);
425/// the rest default to a no-op, and any endpoint outside a backend's own subnet is
426/// skipped by the pool. This is the startup half of the container-IP collision fix:
427/// without it, a fresh pool would re-hand a live `10.0.0.x` to a different workload,
428/// and a relaunch would move a replica's endpoint. A read failure is logged and
429/// adoption is skipped (the reconcile still runs — it just can't guarantee stability
430/// this boot), so a transient KV hiccup never blocks serving.
431pub async fn adopt_running_replica_ips(
432    deploy: &boatramp_core::deploy::DeployStore,
433    backends: &boatramp_core::compute::BackendRegistry,
434) {
435    use std::collections::BTreeMap;
436    use std::net::Ipv4Addr;
437
438    let states = match deploy.list_all_replica_states().await {
439        Ok(s) => s,
440        Err(e) => {
441            tracing::warn!(%e, "could not read replica states for IP adoption; \
442                             the reconcile loop starts without adopting in-use IPs");
443            return;
444        }
445    };
446    // (project, workload, replica, ip) grouped by the backend that owns the replica.
447    // The project is the first dimension of the IPAM key, so two projects' same-named
448    // workloads never share a slot; `list_all_replica_states` already backfilled it
449    // onto the handle from the KV key. Both a Running and a parked (Zero) replica hold
450    // their endpoint address — a Zero replica's IP is reserved for its wake — so adopt
451    // them alike.
452    let mut by_backend: BTreeMap<String, Vec<(String, String, u32, Ipv4Addr)>> = BTreeMap::new();
453    for st in &states {
454        let ip = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
455            st.handle
456                .backend_ref
457                .split(':')
458                .next()
459                .and_then(|s| s.parse::<Ipv4Addr>().ok())
460        });
461        if let Some(ip) = ip {
462            by_backend.entry(st.backend.clone()).or_default().push((
463                st.handle.project.clone(),
464                st.handle.workload.clone(),
465                st.handle.replica,
466                ip,
467            ));
468        }
469    }
470    for (backend_id, replicas) in by_backend {
471        if let Some(backend) = backends.get(&backend_id) {
472            backend.reserve_in_use(&replicas).await;
473            tracing::info!(
474                backend = %backend_id,
475                count = replicas.len(),
476                "adopted in-use compute IPs into the backend pool"
477            );
478        }
479    }
480}
481
482/// Start the per-project **internal DNS** resolver task when it is enabled and the
483/// container backend + bridge are active. Binds UDP `gateway:53` (the bridge
484/// gateway the container backend assigns), forwarding non-internal queries to
485/// `compute.dns_upstream`. Returns the detached task handle, or `None` when the
486/// resolver is off, the container backend isn't present (nothing to serve names
487/// for), or the subnet/upstream is malformed. Linux-only: it binds a socket and is
488/// meaningful only where the container/bridge code runs; a non-Linux node returns
489/// `None` (the seam is compiled out).
490#[cfg(target_os = "linux")]
491pub fn spawn_internal_dns(
492    cfg: Option<&crate::config::ComputeConfig>,
493    backends: &boatramp_core::compute::BackendRegistry,
494    deploy: &boatramp_core::deploy::DeployStore,
495) -> Option<tokio::task::JoinHandle<()>> {
496    let cfg = cfg?;
497    // Off by config, or no container backend on this node (the resolver only serves
498    // names for co-located containers).
499    if !cfg.internal_dns || !backends.contains_key("container") {
500        return None;
501    }
502    let gateway = match boatramp_core::ipam::IpPool::new(&cfg.subnet) {
503        Ok(pool) => pool.gateway(),
504        Err(e) => {
505            tracing::warn!(%e, subnet = %cfg.subnet, "internal DNS: bad compute subnet; resolver not started");
506            return None;
507        }
508    };
509    let upstream: std::net::SocketAddr = match cfg.dns_upstream.parse() {
510        Ok(a) => a,
511        Err(e) => {
512            tracing::warn!(%e, upstream = %cfg.dns_upstream, "internal DNS: bad dns_upstream (want host:port); resolver not started");
513            return None;
514        }
515    };
516    let source: std::sync::Arc<dyn boatramp_container::dns_server::InternalDnsSource> =
517        std::sync::Arc::new(DeployDnsSource::new(deploy.clone()));
518    let domain = cfg.dns_domain.clone();
519    Some(tokio::spawn(async move {
520        if let Err(e) =
521            boatramp_container::dns_server::serve(gateway, upstream, domain, source).await
522        {
523            tracing::warn!(%e, "internal DNS resolver exited (bind/setup error); \
524                                guests keep their static resolv.conf peers");
525        }
526    }))
527}
528
529/// Non-Linux stub: no internal DNS resolver (the container/bridge seam is Linux-only).
530#[cfg(not(target_os = "linux"))]
531pub fn spawn_internal_dns(
532    _cfg: Option<&crate::config::ComputeConfig>,
533    _backends: &boatramp_core::compute::BackendRegistry,
534    _deploy: &boatramp_core::deploy::DeployStore,
535) -> Option<tokio::task::JoinHandle<()>> {
536    None
537}
538
539/// The internal-DNS control-plane source (Linux): snapshots the co-located fleet
540/// from the [`DeployStore`](boatramp_core::deploy::DeployStore) replica state into
541/// the two maps the resolver needs — IP → `(project, workload)` (the source-IP
542/// reverse map, from **every** replica so an unknown source is recognised as such)
543/// and `(project, workload)` → healthy replica IPs (the name → IP forward map,
544/// mirroring [`DeployEndpointResolver`](crate::managed_sql::DeployEndpointResolver)'s
545/// healthy-running filter). A workload resolves ONLY to its own project's healthy
546/// replicas; the isolation scoping itself lives in the pure resolver, which keys
547/// its answer by the source IP's project.
548#[cfg(target_os = "linux")]
549pub struct DeployDnsSource {
550    deploy: boatramp_core::deploy::DeployStore,
551}
552
553#[cfg(target_os = "linux")]
554impl DeployDnsSource {
555    /// Build over the control-plane store.
556    pub fn new(deploy: boatramp_core::deploy::DeployStore) -> Self {
557        Self { deploy }
558    }
559}
560
561#[cfg(target_os = "linux")]
562#[async_trait::async_trait]
563impl boatramp_container::dns_server::InternalDnsSource for DeployDnsSource {
564    async fn snapshot(&self) -> boatramp_container::dns_server::DnsFleet {
565        use boatramp_container::dns::ResolvedAddrs;
566        use boatramp_container::dns_server::DnsFleet;
567        use boatramp_core::compute::ReplicaPhase;
568        use std::net::Ipv4Addr;
569
570        let mut fleet = DnsFleet::default();
571        let states = match self.deploy.list_all_replica_states().await {
572            Ok(s) => s,
573            Err(e) => {
574                // A transient KV hiccup ⇒ an empty snapshot: every query is then
575                // forward-only (no internal answer, no cross-tenant leak) — fail safe.
576                tracing::warn!(%e, "internal DNS: could not read replica states; \
577                                    answering forward-only this query");
578                return fleet;
579            }
580        };
581        for st in &states {
582            // Parse the replica's bridge IP from its endpoint host (fall back to the
583            // `<ip>:<port>` backend_ref, like the IP-adoption path).
584            let v4 = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
585                st.handle
586                    .backend_ref
587                    .split(':')
588                    .next()
589                    .and_then(|s| s.parse::<Ipv4Addr>().ok())
590            });
591            let Some(ip) = v4 else { continue };
592            let key = (st.handle.project.clone(), st.handle.workload.clone());
593            // Reverse map: EVERY replica (running or parked) owns its IP, so a source
594            // that is a real container is always recognised (never treated as unknown
595            // and answered internal names it shouldn't be — the isolation depends on
596            // this being complete).
597            fleet.owners.insert(ip, key.clone());
598            // Forward map: only a healthy, running replica is a valid answer target
599            // (matches DeployEndpointResolver — a parked/unhealthy replica is not a
600            // live endpoint). Ordered by replica index (primary-first) via the KV
601            // list order that `list_all_replica_states` preserves per workload. The
602            // compute bridge is IPv4 today, so every endpoint is an `A` record; a
603            // future v6 bridge would populate `ResolvedAddrs::v6` here.
604            if st.phase == ReplicaPhase::Running && st.healthy {
605                fleet
606                    .addrs
607                    .entry(key)
608                    .or_insert_with(ResolvedAddrs::default)
609                    .v4
610                    .push(ip);
611            }
612        }
613        fleet
614    }
615}
616
617/// The node's [`ComputeExec`](boatramp_core::compute::ComputeExec): resolve a
618/// workload's running replica from the control-plane state, pick its backend, and
619/// run the command inside it. Backs `POST /api/compute/{name}/exec`; the API gates
620/// it behind the `allow_compute_exec` posture. Only the shared-kernel backends
621/// (native `container`, remote `docker`) actually implement
622/// [`ComputeBackend::exec`](boatramp_core::compute::ComputeBackend::exec); the rest
623/// surface as [`ExecError::Unsupported`](boatramp_core::compute::ExecError).
624pub struct NodeComputeExec {
625    backends: boatramp_core::compute::BackendRegistry,
626    deploy: boatramp_core::deploy::DeployStore,
627}
628
629impl NodeComputeExec {
630    /// Build over this node's compute backends + the control-plane store. The
631    /// registry is a cheap `BTreeMap` of `Arc` backends (clone it before the
632    /// reconcile loop consumes the original).
633    pub fn new(
634        backends: boatramp_core::compute::BackendRegistry,
635        deploy: boatramp_core::deploy::DeployStore,
636    ) -> Self {
637        Self { backends, deploy }
638    }
639}
640
641#[async_trait::async_trait]
642impl boatramp_core::compute::ComputeExec for NodeComputeExec {
643    async fn exec(
644        &self,
645        project: &str,
646        workload: &str,
647        argv: &[String],
648        stdin: Option<&[u8]>,
649    ) -> Result<boatramp_core::compute::ExecOutput, boatramp_core::compute::ExecError> {
650        use boatramp_core::compute::{BackendError, ExecError, ReplicaPhase};
651        use boatramp_core::project::ProjectRef;
652        let states = self
653            .deploy
654            .list_replica_states(ProjectRef::new(project), workload)
655            .await
656            .map_err(|e| ExecError::Other(e.to_string()))?;
657        // A running replica — prefer a healthy one, else any running (a just-launched
658        // DB may not be health-marked yet but can still accept an exec).
659        let target = states
660            .iter()
661            .find(|s| s.phase == ReplicaPhase::Running && s.healthy)
662            .or_else(|| states.iter().find(|s| s.phase == ReplicaPhase::Running))
663            .ok_or_else(|| ExecError::NoReplica(workload.to_string()))?;
664        let backend = self
665            .backends
666            .get(&target.backend)
667            .ok_or_else(|| ExecError::Unsupported(target.backend.clone()))?;
668        match backend.exec(&target.handle, argv, stdin).await {
669            Ok(out) => Ok(out),
670            Err(BackendError::Unsupported) => Err(ExecError::Unsupported(target.backend.clone())),
671            Err(e) => Err(ExecError::Other(e.to_string())),
672        }
673    }
674}
675
676/// The node's [`ComputeVolumes`](boatramp_core::compute::ComputeVolumes): list +
677/// reclaim persistent volumes. Backs `GET /api/compute/volumes` +
678/// `DELETE /api/compute/volumes/{name}` (admin-scoped). Lists every
679/// volume-capable backend's on-node volumes, flags which are still referenced by a
680/// registered workload's active spec (in use vs orphaned), and refuses to remove
681/// an in-use volume unless forced — so `compute rm <workload>` (which unregisters
682/// it, then the reconcile loop stops the replica) is the safe precondition for
683/// reclaiming its volume.
684pub struct NodeComputeVolumes {
685    backends: boatramp_core::compute::BackendRegistry,
686    deploy: boatramp_core::deploy::DeployStore,
687}
688
689impl NodeComputeVolumes {
690    /// Build over this node's compute backends + the control-plane store.
691    pub fn new(
692        backends: boatramp_core::compute::BackendRegistry,
693        deploy: boatramp_core::deploy::DeployStore,
694    ) -> Self {
695        Self { backends, deploy }
696    }
697
698    /// The set of volume names still referenced by **any** registered workload's
699    /// active spec, across every project (the `_all` fan-out). A name in this set
700    /// is "in use": a running or relaunching replica mounts it, so removing its
701    /// backing would corrupt live data. Resolves each workload's content-addressed
702    /// spec to read its `volumes[].name`; a workload whose spec can't be resolved
703    /// is skipped (it can't be actively mounting a volume the backend still backs).
704    async fn referenced_volume_names(
705        &self,
706    ) -> Result<std::collections::BTreeSet<String>, boatramp_core::compute::VolumeError> {
707        use boatramp_core::compute::VolumeError;
708        let mut names = std::collections::BTreeSet::new();
709        let workloads = self
710            .deploy
711            .list_compute_workloads_all()
712            .await
713            .map_err(|e| VolumeError::Other(e.to_string()))?;
714        for (_project, workload) in workloads {
715            let spec = self
716                .deploy
717                .get_compute_spec(&workload.active)
718                .await
719                .map_err(|e| VolumeError::Other(e.to_string()))?;
720            if let Some(spec) = spec {
721                for vol in spec.volumes {
722                    names.insert(vol.name);
723                }
724            }
725        }
726        Ok(names)
727    }
728}
729
730#[async_trait::async_trait]
731impl boatramp_core::compute::ComputeVolumes for NodeComputeVolumes {
732    async fn list(
733        &self,
734    ) -> Result<Vec<boatramp_core::compute::VolumeStatus>, boatramp_core::compute::VolumeError>
735    {
736        use boatramp_core::compute::{VolumeError, VolumeStatus};
737        let referenced = self.referenced_volume_names().await?;
738        // Union the volumes every backend reports (dedup by name — a name is unique
739        // per node's volumes dir). A backend that doesn't back volumes returns the
740        // empty default, so this naturally reduces to the volume-capable backend(s).
741        let mut by_name: std::collections::BTreeMap<String, u64> =
742            std::collections::BTreeMap::new();
743        for backend in self.backends.values() {
744            let vols = backend
745                .list_volumes()
746                .await
747                .map_err(|e| VolumeError::Other(e.to_string()))?;
748            for v in vols {
749                // Keep the largest reported size if two backends somehow name-collide.
750                let slot = by_name.entry(v.name).or_insert(0);
751                *slot = (*slot).max(v.size_bytes);
752            }
753        }
754        Ok(by_name
755            .into_iter()
756            .map(|(name, size_bytes)| VolumeStatus {
757                in_use: referenced.contains(&name),
758                info: boatramp_core::compute::VolumeInfo { name, size_bytes },
759            })
760            .collect())
761    }
762
763    async fn remove(
764        &self,
765        name: &str,
766        force: bool,
767    ) -> Result<bool, boatramp_core::compute::VolumeError> {
768        use boatramp_core::compute::{BackendError, VolumeError};
769        // Safety guard: refuse to pull a volume out from under a registered
770        // workload unless the operator forces it. `compute rm <workload>` first is
771        // the safe flow; `--force` is the disposable-data override.
772        if !force && self.referenced_volume_names().await?.contains(name) {
773            return Err(VolumeError::InUse(name.to_string()));
774        }
775        // Remove from whichever backend owns it. `true` from any backend ⇒ existed.
776        // Every backend reports `Unsupported` ⇒ no volume-capable backend here.
777        let mut existed = false;
778        let mut any_supported = false;
779        for backend in self.backends.values() {
780            match backend.remove_volume(name).await {
781                Ok(removed) => {
782                    any_supported = true;
783                    existed |= removed;
784                }
785                Err(BackendError::Unsupported) => {}
786                Err(e) => return Err(VolumeError::Other(e.to_string())),
787            }
788        }
789        if !any_supported {
790            return Err(VolumeError::Unsupported);
791        }
792        Ok(existed)
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use async_trait::async_trait;
800    use boatramp_core::compute::{
801        Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, ComputeVolumes,
802        ComputeWorkload, Health, Instance, InstanceHandle, IsolationClass, IsolationRequirement,
803        LaunchRequest, RestartPolicy, RootSource, VolumeError, VolumeInfo, VolumeRef,
804    };
805    use boatramp_core::deploy::DeployStore;
806    use boatramp_core::project::ProjectRef;
807    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
808    use std::collections::BTreeMap;
809    use std::sync::{Arc, Mutex};
810
811    /// A `Storage` the `DeployStore` never actually reads on the volume paths (the
812    /// spec/workload records live in the KV) — every method is a stub.
813    struct NullStorage;
814    #[async_trait]
815    impl Storage for NullStorage {
816        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
817            Err(StorageError::NotFound(String::new()))
818        }
819        async fn get_range(
820            &self,
821            _: &str,
822            _: u64,
823            _: Option<u64>,
824        ) -> Result<GetObject, StorageError> {
825            Err(StorageError::unsupported("range"))
826        }
827        async fn put(
828            &self,
829            _: &str,
830            _: ByteStream,
831            _: PutMeta,
832        ) -> Result<ObjectMeta, StorageError> {
833            Err(StorageError::unsupported("put"))
834        }
835        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
836            Err(StorageError::NotFound(String::new()))
837        }
838        async fn delete(&self, _: &str) -> Result<(), StorageError> {
839            Ok(())
840        }
841        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
842            Ok(Vec::new())
843        }
844    }
845
846    /// A fake volume-capable backend over an in-memory set of `(name, size)`
847    /// volumes — enough to drive `NodeComputeVolumes` without a real container node.
848    struct FakeVolumeBackend {
849        vols: Mutex<BTreeMap<String, u64>>,
850    }
851    impl FakeVolumeBackend {
852        fn with(names: &[(&str, u64)]) -> Self {
853            Self {
854                vols: Mutex::new(names.iter().map(|(n, s)| (n.to_string(), *s)).collect()),
855            }
856        }
857    }
858    #[async_trait]
859    impl ComputeBackend for FakeVolumeBackend {
860        fn id(&self) -> &'static str {
861            "container"
862        }
863        fn capabilities(&self) -> Capabilities {
864            Capabilities {
865                isolation: IsolationClass::Namespace,
866                scale_to_zero: false,
867                persistent_volumes: true,
868                max_vcpus: None,
869                max_mem_mib: None,
870            }
871        }
872        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
873            Err(BackendError::Unsupported)
874        }
875        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
876            Err(BackendError::Unsupported)
877        }
878        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
879            Ok(())
880        }
881        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
882            Ok(Health::Unknown)
883        }
884        async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
885            Ok(self
886                .vols
887                .lock()
888                .unwrap()
889                .iter()
890                .map(|(name, size)| VolumeInfo {
891                    name: name.clone(),
892                    size_bytes: *size,
893                })
894                .collect())
895        }
896        async fn remove_volume(&self, name: &str) -> Result<bool, BackendError> {
897            Ok(self.vols.lock().unwrap().remove(name).is_some())
898        }
899    }
900
901    fn spec_with_volume(vol: Option<&str>) -> ComputeSpec {
902        ComputeSpec {
903            version: 1,
904            root: RootSource::Image("img".into()),
905            kernel: String::new(),
906            kernel_cmdline: None,
907            vcpus: 1,
908            mem_mib: 64,
909            entrypoint: vec![],
910            env: BTreeMap::new(),
911            port: 8080,
912            restart: RestartPolicy::Always,
913            startup_grace_secs: 30,
914            scale_to_zero: false,
915            volumes: vol
916                .map(|n| {
917                    vec![VolumeRef {
918                        mount: "/data".into(),
919                        name: n.into(),
920                        size_mib: 128,
921                    }]
922                })
923                .unwrap_or_default(),
924            writable_root: false,
925            cap_add: vec![],
926            user: None,
927            isolation: IsolationRequirement::Trusted,
928            prefer_backend: None,
929            bindings: vec![],
930        }
931    }
932
933    /// Build a store with one workload named `wl` whose spec references volume
934    /// `referenced` (or none), plus a `NodeComputeVolumes` over a fake backend that
935    /// backs `backend_vols`.
936    async fn setup(referenced: Option<&str>, backend_vols: &[(&str, u64)]) -> NodeComputeVolumes {
937        let store = DeployStore::new(
938            Arc::new(NullStorage),
939            Arc::new(boatramp_core::kv::MemoryKv::new()),
940        );
941        let spec = spec_with_volume(referenced);
942        let hash = store.put_compute_spec(&spec).await.expect("put spec");
943        let workload = ComputeWorkload {
944            version: 1,
945            name: "wl".into(),
946            active: hash,
947            replicas: 1,
948            placement: Default::default(),
949        };
950        store
951            .set_compute_workload(ProjectRef::DEFAULT, &workload)
952            .await
953            .expect("set workload");
954        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
955        backends.insert(
956            "container".into(),
957            Arc::new(FakeVolumeBackend::with(backend_vols)) as Arc<dyn ComputeBackend>,
958        );
959        NodeComputeVolumes::new(backends, store)
960    }
961
962    #[tokio::test]
963    async fn list_flags_referenced_volume_in_use_and_orphan_free() {
964        // "data" is referenced by the workload spec; "old" is an orphan.
965        let vols = setup(Some("data"), &[("data", 100), ("old", 50)]).await;
966        let listed = vols.list().await.expect("list");
967        assert_eq!(listed.len(), 2);
968        let data = listed.iter().find(|v| v.info.name == "data").unwrap();
969        let old = listed.iter().find(|v| v.info.name == "old").unwrap();
970        assert!(data.in_use, "spec-referenced volume is in use");
971        assert_eq!(data.info.size_bytes, 100);
972        assert!(!old.in_use, "unreferenced volume is orphaned");
973        assert_eq!(old.info.size_bytes, 50);
974    }
975
976    #[tokio::test]
977    async fn remove_refuses_in_use_without_force_and_allows_with_force() {
978        let vols = setup(Some("data"), &[("data", 100)]).await;
979        // Without force: refused (in use by the registered workload).
980        assert!(matches!(
981            vols.remove("data", false).await,
982            Err(VolumeError::InUse(n)) if n == "data"
983        ));
984        // The volume is still there (refusal didn't remove it).
985        assert!(vols
986            .list()
987            .await
988            .unwrap()
989            .iter()
990            .any(|v| v.info.name == "data"));
991        // With force: removed.
992        assert!(vols.remove("data", true).await.expect("forced remove"));
993        assert!(vols.list().await.unwrap().is_empty());
994    }
995
996    #[tokio::test]
997    async fn remove_orphan_succeeds_and_absent_reports_false() {
998        // No workload references "old"; it removes without force.
999        let vols = setup(None, &[("old", 50)]).await;
1000        assert!(vols.remove("old", false).await.expect("remove orphan"));
1001        // Removing an absent volume reports "did not exist".
1002        assert!(!vols.remove("gone", false).await.expect("remove absent"));
1003    }
1004
1005    // -----------------------------------------------------------------------
1006    // Startup IP adoption (container-IP collision fix): the node reads every
1007    // persisted replica and hands each backend the `(workload, replica, ip)` it
1008    // owns, so a fresh-on-boot pool reserves live addresses before allocating.
1009    // -----------------------------------------------------------------------
1010
1011    /// A backend that records the `reserve_in_use` tuples it was handed (a spy for
1012    /// the startup adoption wiring).
1013    struct AdoptSpyBackend {
1014        adopted: Mutex<Vec<(String, String, u32, std::net::Ipv4Addr)>>,
1015    }
1016    #[async_trait]
1017    impl ComputeBackend for AdoptSpyBackend {
1018        fn id(&self) -> &'static str {
1019            "container"
1020        }
1021        fn capabilities(&self) -> Capabilities {
1022            Capabilities {
1023                isolation: IsolationClass::Namespace,
1024                scale_to_zero: false,
1025                persistent_volumes: true,
1026                max_vcpus: None,
1027                max_mem_mib: None,
1028            }
1029        }
1030        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
1031            Err(BackendError::Unsupported)
1032        }
1033        async fn reserve_in_use(&self, replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {
1034            self.adopted.lock().unwrap().extend_from_slice(replicas);
1035        }
1036        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
1037            Err(BackendError::Unsupported)
1038        }
1039        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
1040            Ok(())
1041        }
1042        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
1043            Ok(Health::Unknown)
1044        }
1045    }
1046
1047    #[tokio::test]
1048    async fn adopt_running_replica_ips_feeds_each_backends_in_use_addresses() {
1049        use boatramp_core::compute::{Endpoint, ObservedInstance, ReplicaPhase, Scheme};
1050        use std::net::Ipv4Addr;
1051
1052        let store = DeployStore::new(
1053            Arc::new(NullStorage),
1054            Arc::new(boatramp_core::kv::MemoryKv::new()),
1055        );
1056        // Container replicas on distinct IPs (one in `default`, one in a non-default
1057        // project — the adoption must carry the OWNING project into the tuple), a
1058        // parked container replica, plus one on a different backend (must not be
1059        // handed to the container backend's adoption).
1060        let mk = |proj: &str, wl: &str, rep: u32, backend: &str, ip: &str, phase: ReplicaPhase| {
1061            (
1062                proj.to_string(),
1063                ObservedInstance {
1064                    handle: InstanceHandle {
1065                        project: proj.into(),
1066                        workload: wl.into(),
1067                        replica: rep,
1068                        backend_ref: format!("{ip}:5432"),
1069                    },
1070                    node: 1,
1071                    backend: backend.into(),
1072                    endpoint: Endpoint {
1073                        scheme: Scheme::Http,
1074                        host: ip.into(),
1075                        port: 5432,
1076                    },
1077                    region: None,
1078                    healthy: true,
1079                    started_at: None,
1080                    phase,
1081                    snapshot: None,
1082                },
1083            )
1084        };
1085        for (proj, st) in [
1086            mk(
1087                "default",
1088                "pg-a",
1089                0,
1090                "container",
1091                "10.0.0.2",
1092                ReplicaPhase::Running,
1093            ),
1094            // A non-default project's SAME-shaped workload — its project must survive
1095            // into the adoption tuple (not collapse to `default`).
1096            mk(
1097                "acme",
1098                "web",
1099                0,
1100                "container",
1101                "10.0.0.3",
1102                ReplicaPhase::Zero,
1103            ), // parked — still holds its IP
1104            mk(
1105                "default",
1106                "vm",
1107                0,
1108                "vmm-embedded",
1109                "10.0.0.9",
1110                ReplicaPhase::Running,
1111            ),
1112        ] {
1113            store
1114                .set_replica_state(ProjectRef::new(&proj), &st)
1115                .await
1116                .expect("persist replica state");
1117        }
1118
1119        let container = Arc::new(AdoptSpyBackend {
1120            adopted: Mutex::new(Vec::new()),
1121        });
1122        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1123        backends.insert(
1124            "container".into(),
1125            container.clone() as Arc<dyn ComputeBackend>,
1126        );
1127
1128        adopt_running_replica_ips(&store, &backends).await;
1129
1130        let got = container.adopted.lock().unwrap().clone();
1131        // Only the two container replicas' addresses were handed to the container
1132        // backend, each carrying its OWNING project — the VMM replica's IP went to
1133        // no container adoption.
1134        assert!(got.contains(&(
1135            "default".into(),
1136            "pg-a".into(),
1137            0,
1138            Ipv4Addr::new(10, 0, 0, 2)
1139        )));
1140        assert!(got.contains(&("acme".into(), "web".into(), 0, Ipv4Addr::new(10, 0, 0, 3))));
1141        assert!(
1142            !got.iter()
1143                .any(|(_, _, _, ip)| *ip == Ipv4Addr::new(10, 0, 0, 9)),
1144            "another backend's replica IP must not be adopted by the container backend"
1145        );
1146        assert_eq!(got.len(), 2);
1147    }
1148}