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                .with_ephemeral_timeout(std::time::Duration::from_secs(
200                    cfg.docker_ephemeral_timeout_secs,
201                ));
202            if docker.reachable().await {
203                backends.insert("docker".to_string(), std::sync::Arc::new(docker));
204            } else {
205                tracing::debug!("no reachable docker daemon; skipping docker backend");
206            }
207        }
208        Err(e) => tracing::debug!(%e, "docker backend unavailable"),
209    }
210
211    // Ensure the shared compute bridge exists before the backends that enslave a
212    // veth/tap to it. boatramp creates it itself over netlink (needs `CAP_NET_ADMIN`)
213    // rather than requiring the operator to pre-create it — so a stock image on a fresh
214    // host is turnkey. If it can't be created, the container + embedded-VMM backends are
215    // skipped rather than advertised and then failing at launch on the missing bridge.
216    // The SINGLE shared IP authority for the compute bridge/subnet (A5): every backend
217    // that places a guest on `cfg.bridge` (the native container backend's veths + the
218    // embedded-VMM backend's taps) draws from and releases to this ONE pool, so two
219    // backends on the same L2 can never be handed the same address. Built once here and
220    // injected (a clone) into each. `None` if the subnet is malformed (both backends are
221    // then skipped, same as before).
222    #[cfg(target_os = "linux")]
223    let shared_ip_authority: Option<boatramp_core::ipam::IpAuthority> =
224        match boatramp_core::ipam::IpAuthority::new(&cfg.subnet) {
225            Ok(a) => Some(a),
226            Err(e) => {
227                tracing::warn!(%e, subnet = %cfg.subnet, "bad compute subnet; container + embedded-VMM backends disabled");
228                None
229            }
230        };
231    #[cfg(target_os = "linux")]
232    let bridge_ready = match &shared_ip_authority {
233        Some(authority) => {
234            match boatramp_container::ensure_bridge(
235                &cfg.bridge,
236                authority.gateway(),
237                authority.prefix_len(),
238            )
239            .await
240            {
241                Ok(()) => true,
242                Err(e) => {
243                    tracing::warn!(%e, bridge = %cfg.bridge, "could not create the compute bridge (need CAP_NET_ADMIN); container + embedded-VMM backends disabled");
244                    false
245                }
246            }
247        }
248        None => false,
249    };
250
251    // Native container backend (Linux only).
252    #[cfg(target_os = "linux")]
253    if bridge_ready {
254        match worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())) {
255            Ok(self_exe) => match boatramp_container::ContainerBackend::new(
256                storage.clone(),
257                data_dir.to_path_buf(),
258                cfg.bridge.clone(),
259                &cfg.subnet,
260                self_exe,
261            ) {
262                Ok(c) => {
263                    // Single-tenant posture (`!strict`) may honor `cap_add`; multi-tenant
264                    // keeps every capability dropped.
265                    let c = c.with_cap_add_allowed(!strict);
266                    // Point each container's resolv.conf at the internal DNS on the
267                    // bridge gateway when the resolver is enabled (default on). The
268                    // node starts the resolver task (see `spawn_internal_dns`); the
269                    // two must agree on the domain, so both read `compute.dns_domain`.
270                    let c = c.with_internal_dns(cfg.internal_dns.then(|| cfg.dns_domain.clone()));
271                    // Share the ONE bridge/subnet IP authority (A5) so a co-located
272                    // embedded-VMM guest and a container can never get the same address.
273                    let c = match &shared_ip_authority {
274                        Some(a) => c.with_ip_authority(a.clone()),
275                        None => c,
276                    };
277                    // Air-gap override for project export/import's `run_ephemeral`: map
278                    // specific client images to a PRELOADED rootfs (no registry pull).
279                    // Empty (default) keeps the on-demand OCI pull+build path.
280                    let c = c.with_client_image_rootfs(cfg.client_image_rootfs.clone());
281                    // Multi-tenant posture: refuse an unauthenticated on-demand pull of a mutable
282                    // client-image tag for `run_ephemeral` — require a preloaded (air-gap) image or
283                    // an `@sha256:`-pinned ref, so a registry/MITM cannot substitute an image that
284                    // would run with the managed-DB credential + egress (M1).
285                    let c = c.with_require_pinned_client_image(strict);
286                    // Reclaim one-shot cgroups orphaned by a hard crash mid-export/import (L2).
287                    c.sweep_orphaned_ephemeral_cgroups();
288                    backends.insert("container".to_string(), std::sync::Arc::new(c));
289                }
290                Err(e) => tracing::warn!(%e, "container backend unavailable"),
291            },
292            Err(e) => tracing::warn!(%e, "current_exe for container backend"),
293        }
294    }
295    // Embedded VMM backend (Linux + x86_64 + `/dev/kvm`): in-process microVMs, no
296    // external `firecracker` binary — the strongest isolation when KVM is available.
297    // Like the container backend it enslaves each tap to `cfg.bridge` (ensured above,
298    // hence the `bridge_ready` gate). The embedded VMM is KVM-x86-specific, so this is
299    // x86_64-only; boatramp
300    // still serves on linux/aarch64 (with the container backend, no embedded VMM).
301    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
302    if bridge_ready && std::path::Path::new("/dev/kvm").exists() {
303        match (
304            worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
305            boatramp_core::ipam::IpPool::new(&cfg.subnet),
306        ) {
307            (Ok(self_exe), Ok(pool)) => {
308                let gateway = pool.gateway().to_string();
309                // Verify-before-boot gate for every kernel this backend stages.
310                let verifier: Arc<dyn boatramp_firecracker::KernelVerifier> =
311                    Arc::new(PostureKernelVerifier {
312                        strict,
313                        signing_keys: cfg.kernel_signing_pubkeys.clone(),
314                        allowed_hashes: cfg.kernel_allowed_hashes.clone(),
315                        daemon: daemon.clone(),
316                    });
317                match boatramp_firecracker::EmbeddedVmmBackend::new(
318                    storage.clone(),
319                    self_exe, // re-exec'd as `__vmm-run` per VM (jailed subprocess)
320                    data_dir.to_path_buf(),
321                    cfg.bridge.clone(),
322                    gateway,
323                    &cfg.subnet,
324                    verifier,
325                ) {
326                    Ok(vmm) => {
327                        // Share the ONE bridge/subnet IP authority with the co-located
328                        // container backend (A5): both draw from and release to one pool,
329                        // so a tap and a veth on the same L2 never collide on an address.
330                        let vmm = match &shared_ip_authority {
331                            Some(a) => vmm.with_ip_authority(a.clone()),
332                            None => vmm,
333                        };
334                        backends.insert("vmm-embedded".to_string(), std::sync::Arc::new(vmm));
335                    }
336                    Err(e) => tracing::warn!(%e, "embedded VMM backend unavailable"),
337                }
338            }
339            (Err(e), _) => tracing::warn!(%e, "current_exe for VMM backend"),
340            (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for VMM backend"),
341        }
342    } else {
343        tracing::debug!("no /dev/kvm; skipping embedded VMM backend");
344    }
345
346    // macOS-native VMM backend (Apple silicon + macOS): each replica is a Linux
347    // microVM under Virtualization.framework, run by a re-exec'd `__vz-run`
348    // worker. Strong isolation (VmKvm), matching the KVM backend's user surface.
349    // Capability-detected + log-skipped on Intel / older macOS, exactly like the
350    // `/dev/kvm` check gates the Linux VMM.
351    #[cfg(target_os = "macos")]
352    if macos_supports_vz() {
353        match (
354            worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
355            boatramp_core::ipam::IpPool::new(&cfg.subnet),
356        ) {
357            (Ok(self_exe), Ok(_pool)) => {
358                let verifier: Arc<dyn boatramp_vz::KernelVerifier> =
359                    Arc::new(VzPostureKernelVerifier {
360                        strict,
361                        signing_keys: cfg.kernel_signing_pubkeys.clone(),
362                        allowed_hashes: cfg.kernel_allowed_hashes.clone(),
363                        daemon: daemon.clone(),
364                    });
365                match boatramp_vz::VzBackend::new(
366                    storage.clone(),
367                    self_exe, // re-exec'd as `__vz-run` per VM
368                    data_dir.to_path_buf(),
369                    &cfg.subnet, // the vmnet range (e.g. 192.168.64.0/24); `.1` = gateway
370                    verifier,
371                ) {
372                    // `writable_root` honored only under the single-tenant posture.
373                    Ok(vz) => {
374                        // The vz backend keeps its own private IP authority (A5): on macOS
375                        // it is the sole compute backend on the vmnet segment (no native
376                        // container / KVM backend to share with), so there is nothing to
377                        // collide with. It still accepts a shared authority via
378                        // `with_ip_authority` for a uniform surface.
379                        let vz = vz.with_writable_root_allowed(!strict);
380                        backends.insert("vmm-vz".to_string(), std::sync::Arc::new(vz));
381                    }
382                    Err(e) => tracing::warn!(%e, "macOS VMM backend unavailable"),
383                }
384            }
385            (Err(e), _) => tracing::warn!(%e, "current_exe for macOS VMM backend"),
386            (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for macOS VMM backend"),
387        }
388    } else {
389        tracing::debug!("not Apple silicon + macOS 15+; skipping macOS VMM backend");
390    }
391
392    let _ = (&storage, data_dir); // used only on Linux/macOS (container / VMM backends)
393    // The kernel-trust verifier is wired for the embedded VMM (x86_64 Linux) and
394    // the macOS VMM; silence `strict`/`daemon` on the platforms that wire neither
395    // (linux/aarch64, and any non-Linux non-macOS host).
396    #[cfg(not(any(all(target_os = "linux", target_arch = "x86_64"), target_os = "macos")))]
397    let _ = (strict, &daemon);
398
399    let free_vcpus = if cfg.vcpus > 0 {
400        cfg.vcpus
401    } else {
402        std::thread::available_parallelism()
403            .map(|n| n.get() as u32)
404            .unwrap_or(1)
405    };
406    let free_mem_mib = if cfg.mem_mib > 0 { cfg.mem_mib } else { 1024 };
407    let advertised: Vec<BackendKind> = backends
408        .iter()
409        .map(|(id, b)| {
410            let caps = b.capabilities();
411            BackendKind {
412                id: id.clone(),
413                isolation: caps.isolation,
414                persistent_volumes: caps.persistent_volumes,
415                scale_to_zero: caps.scale_to_zero,
416            }
417        })
418        .collect();
419    tracing::info!(backends = ?advertised, free_vcpus, free_mem_mib, "compute node inventory");
420    let node = Node {
421        id: node_id,
422        region: cfg.region.clone(),
423        labels: std::collections::BTreeMap::new(),
424        free_vcpus,
425        free_mem_mib,
426        backends: advertised,
427    };
428    (backends, node)
429}
430
431/// Adopt the IPs of already-running replicas into each compute backend's
432/// fresh-on-boot IP pool, so the backend reserves live addresses before the
433/// reconcile loop allocates any new one. Reads every persisted replica across all
434/// projects, parses each endpoint's IPv4 address (from the routable endpoint host,
435/// falling back to the `<ip>:<port>` `backend_ref`), groups them by backend, and
436/// hands each backend the `(workload, replica, ip)` tuples for its own replicas.
437///
438/// Only backends with a per-node IP pool act on it (the native `container` backend);
439/// the rest default to a no-op, and any endpoint outside a backend's own subnet is
440/// skipped by the pool. This is the startup half of the container-IP collision fix:
441/// without it, a fresh pool would re-hand a live `10.0.0.x` to a different workload,
442/// and a relaunch would move a replica's endpoint. A read failure is logged and
443/// adoption is skipped (the reconcile still runs — it just can't guarantee stability
444/// this boot), so a transient KV hiccup never blocks serving.
445pub async fn adopt_running_replica_ips(
446    deploy: &boatramp_core::deploy::DeployStore,
447    backends: &boatramp_core::compute::BackendRegistry,
448) {
449    use std::collections::BTreeMap;
450    use std::net::Ipv4Addr;
451
452    let states = match deploy.list_all_replica_states().await {
453        Ok(s) => s,
454        Err(e) => {
455            tracing::warn!(%e, "could not read replica states for IP adoption; \
456                             the reconcile loop starts without adopting in-use IPs");
457            return;
458        }
459    };
460    // (project, workload, replica, ip) grouped by the backend that owns the replica.
461    // The project is the first dimension of the IPAM key, so two projects' same-named
462    // workloads never share a slot; `list_all_replica_states` already backfilled it
463    // onto the handle from the KV key. Both a Running and a parked (Zero) replica hold
464    // their endpoint address — a Zero replica's IP is reserved for its wake — so adopt
465    // them alike.
466    let mut by_backend: BTreeMap<String, Vec<(String, String, u32, Ipv4Addr)>> = BTreeMap::new();
467    for st in &states {
468        let ip = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
469            st.handle
470                .backend_ref
471                .split(':')
472                .next()
473                .and_then(|s| s.parse::<Ipv4Addr>().ok())
474        });
475        if let Some(ip) = ip {
476            by_backend.entry(st.backend.clone()).or_default().push((
477                st.handle.project.clone(),
478                st.handle.workload.clone(),
479                st.handle.replica,
480                ip,
481            ));
482        }
483    }
484    for (backend_id, replicas) in by_backend {
485        if let Some(backend) = backends.get(&backend_id) {
486            backend.reserve_in_use(&replicas).await;
487            tracing::info!(
488                backend = %backend_id,
489                count = replicas.len(),
490                "adopted in-use compute IPs into the backend pool"
491            );
492        }
493    }
494}
495
496/// Start the per-project **internal DNS** resolver task when it is enabled and the
497/// container backend + bridge are active. Binds UDP `gateway:53` (the bridge
498/// gateway the container backend assigns), forwarding non-internal queries to
499/// `compute.dns_upstream`. Returns the detached task handle, or `None` when the
500/// resolver is off, the container backend isn't present (nothing to serve names
501/// for), or the subnet/upstream is malformed. Linux-only: it binds a socket and is
502/// meaningful only where the container/bridge code runs; a non-Linux node returns
503/// `None` (the seam is compiled out).
504#[cfg(target_os = "linux")]
505pub fn spawn_internal_dns(
506    cfg: Option<&crate::config::ComputeConfig>,
507    backends: &boatramp_core::compute::BackendRegistry,
508    deploy: &boatramp_core::deploy::DeployStore,
509) -> Option<tokio::task::JoinHandle<()>> {
510    let cfg = cfg?;
511    // Off by config, or no container backend on this node (the resolver only serves
512    // names for co-located containers).
513    if !cfg.internal_dns || !backends.contains_key("container") {
514        return None;
515    }
516    let gateway = match boatramp_core::ipam::IpPool::new(&cfg.subnet) {
517        Ok(pool) => pool.gateway(),
518        Err(e) => {
519            tracing::warn!(%e, subnet = %cfg.subnet, "internal DNS: bad compute subnet; resolver not started");
520            return None;
521        }
522    };
523    let upstream: std::net::SocketAddr = match cfg.dns_upstream.parse() {
524        Ok(a) => a,
525        Err(e) => {
526            tracing::warn!(%e, upstream = %cfg.dns_upstream, "internal DNS: bad dns_upstream (want host:port); resolver not started");
527            return None;
528        }
529    };
530    let source: std::sync::Arc<dyn boatramp_container::dns_server::InternalDnsSource> =
531        std::sync::Arc::new(DeployDnsSource::new(deploy.clone()));
532    let domain = cfg.dns_domain.clone();
533    Some(tokio::spawn(async move {
534        if let Err(e) =
535            boatramp_container::dns_server::serve(gateway, upstream, domain, source).await
536        {
537            tracing::warn!(%e, "internal DNS resolver exited (bind/setup error); \
538                                guests keep their static resolv.conf peers");
539        }
540    }))
541}
542
543/// Non-Linux stub: no internal DNS resolver (the container/bridge seam is Linux-only).
544#[cfg(not(target_os = "linux"))]
545pub fn spawn_internal_dns(
546    _cfg: Option<&crate::config::ComputeConfig>,
547    _backends: &boatramp_core::compute::BackendRegistry,
548    _deploy: &boatramp_core::deploy::DeployStore,
549) -> Option<tokio::task::JoinHandle<()>> {
550    None
551}
552
553/// The internal-DNS control-plane source (Linux): snapshots the co-located fleet
554/// from the [`DeployStore`](boatramp_core::deploy::DeployStore) replica state into
555/// the two maps the resolver needs — IP → `(project, workload)` (the source-IP
556/// reverse map, from **every** replica so an unknown source is recognised as such)
557/// and `(project, workload)` → healthy replica IPs (the name → IP forward map,
558/// mirroring [`DeployEndpointResolver`](crate::managed_sql::DeployEndpointResolver)'s
559/// healthy-running filter). A workload resolves ONLY to its own project's healthy
560/// replicas; the isolation scoping itself lives in the pure resolver, which keys
561/// its answer by the source IP's project.
562#[cfg(target_os = "linux")]
563pub struct DeployDnsSource {
564    deploy: boatramp_core::deploy::DeployStore,
565}
566
567#[cfg(target_os = "linux")]
568impl DeployDnsSource {
569    /// Build over the control-plane store.
570    pub fn new(deploy: boatramp_core::deploy::DeployStore) -> Self {
571        Self { deploy }
572    }
573}
574
575#[cfg(target_os = "linux")]
576#[async_trait::async_trait]
577impl boatramp_container::dns_server::InternalDnsSource for DeployDnsSource {
578    async fn snapshot(&self) -> boatramp_container::dns_server::DnsFleet {
579        use boatramp_container::dns::ResolvedAddrs;
580        use boatramp_container::dns_server::DnsFleet;
581        use boatramp_core::compute::ReplicaPhase;
582        use std::net::Ipv4Addr;
583
584        let mut fleet = DnsFleet::default();
585        let states = match self.deploy.list_all_replica_states().await {
586            Ok(s) => s,
587            Err(e) => {
588                // A transient KV hiccup ⇒ an empty snapshot: every query is then
589                // forward-only (no internal answer, no cross-tenant leak) — fail safe.
590                tracing::warn!(%e, "internal DNS: could not read replica states; \
591                                    answering forward-only this query");
592                return fleet;
593            }
594        };
595        for st in &states {
596            // Parse the replica's bridge IP from its endpoint host (fall back to the
597            // `<ip>:<port>` backend_ref, like the IP-adoption path).
598            let v4 = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
599                st.handle
600                    .backend_ref
601                    .split(':')
602                    .next()
603                    .and_then(|s| s.parse::<Ipv4Addr>().ok())
604            });
605            let Some(ip) = v4 else { continue };
606            let key = (st.handle.project.clone(), st.handle.workload.clone());
607            // Reverse map: EVERY replica (running or parked) owns its IP, so a source
608            // that is a real container is always recognised (never treated as unknown
609            // and answered internal names it shouldn't be — the isolation depends on
610            // this being complete).
611            fleet.owners.insert(ip, key.clone());
612            // Forward map: only a healthy, running replica is a valid answer target
613            // (matches DeployEndpointResolver — a parked/unhealthy replica is not a
614            // live endpoint). Ordered by replica index (primary-first) via the KV
615            // list order that `list_all_replica_states` preserves per workload. The
616            // compute bridge is IPv4 today, so every endpoint is an `A` record; a
617            // future v6 bridge would populate `ResolvedAddrs::v6` here.
618            // Answerability gate (v0.45.0): only a `Provisioned` replica is a valid
619            // answer target — a workload whose post-launch actions have not completed (a
620            // managed DB still provisioning, or one in `ProvisionFailed`, or a generic
621            // workload whose `post_launch` Exec has not finished) is NOT answered, so the
622            // fabric never routes to a not-yet-ready upstream. This is the strict default
623            // across ALL workloads; a generic healthy workload reaches `Provisioned`
624            // immediately, so it is unchanged. (Per-DB relaxation of answerability to
625            // `service_ready` needs the node-global DNS source to become config-aware —
626            // a follow-up; routing + migration are already per-DB-configurable.)
627            if st.phase == ReplicaPhase::Running && st.healthy && st.readiness.is_provisioned() {
628                fleet
629                    .addrs
630                    .entry(key)
631                    .or_insert_with(ResolvedAddrs::default)
632                    .v4
633                    .push(ip);
634            }
635        }
636        fleet
637    }
638}
639
640/// The node's [`ComputeExec`](boatramp_core::compute::ComputeExec): resolve a
641/// workload's running replica from the control-plane state, pick its backend, and
642/// run the command inside it. Backs `POST /api/compute/{name}/exec`; the API gates
643/// it behind the `allow_compute_exec` posture. Only the shared-kernel backends
644/// (native `container`, remote `docker`) actually implement
645/// [`ComputeBackend::exec`](boatramp_core::compute::ComputeBackend::exec); the rest
646/// surface as [`ExecError::Unsupported`](boatramp_core::compute::ExecError).
647pub struct NodeComputeExec {
648    backends: boatramp_core::compute::BackendRegistry,
649    deploy: boatramp_core::deploy::DeployStore,
650}
651
652impl NodeComputeExec {
653    /// Build over this node's compute backends + the control-plane store. The
654    /// registry is a cheap `BTreeMap` of `Arc` backends (clone it before the
655    /// reconcile loop consumes the original).
656    pub fn new(
657        backends: boatramp_core::compute::BackendRegistry,
658        deploy: boatramp_core::deploy::DeployStore,
659    ) -> Self {
660        Self { backends, deploy }
661    }
662}
663
664#[async_trait::async_trait]
665impl boatramp_core::compute::ComputeExec for NodeComputeExec {
666    async fn exec(
667        &self,
668        project: &str,
669        workload: &str,
670        argv: &[String],
671        stdin: Option<&[u8]>,
672    ) -> Result<boatramp_core::compute::ExecOutput, boatramp_core::compute::ExecError> {
673        use boatramp_core::compute::{BackendError, ExecError, ReplicaPhase};
674        use boatramp_core::project::ProjectRef;
675        let states = self
676            .deploy
677            .list_replica_states(ProjectRef::new(project), workload)
678            .await
679            .map_err(|e| ExecError::Other(e.to_string()))?;
680        // A running replica — prefer a healthy one, else any running (a just-launched
681        // DB may not be health-marked yet but can still accept an exec).
682        let target = states
683            .iter()
684            .find(|s| s.phase == ReplicaPhase::Running && s.healthy)
685            .or_else(|| states.iter().find(|s| s.phase == ReplicaPhase::Running))
686            .ok_or_else(|| ExecError::NoReplica(workload.to_string()))?;
687        let backend = self
688            .backends
689            .get(&target.backend)
690            .ok_or_else(|| ExecError::Unsupported(target.backend.clone()))?;
691        match backend.exec(&target.handle, argv, stdin).await {
692            Ok(out) => Ok(out),
693            Err(BackendError::Unsupported) => Err(ExecError::Unsupported(target.backend.clone())),
694            Err(e) => Err(ExecError::Other(e.to_string())),
695        }
696    }
697}
698
699/// The node's [`ComputeControl`](boatramp_core::compute::ComputeControl): restart a
700/// replica (stop it + drop its observed state so the reconcile loop relaunches it).
701/// Backs `POST /api/compute/maintenance/restart` (admin-scoped). Resolves the target
702/// replica's backend from its persisted state and calls
703/// [`ComputeBackend::stop`](boatramp_core::compute::ComputeBackend::stop); the server
704/// nudges the reconcile loop afterwards so relaunch is prompt.
705pub struct NodeComputeControl {
706    backends: boatramp_core::compute::BackendRegistry,
707    deploy: boatramp_core::deploy::DeployStore,
708}
709
710impl NodeComputeControl {
711    /// Build over this node's compute backends + the control-plane store. Clone the
712    /// registry before the reconcile loop consumes the original.
713    pub fn new(
714        backends: boatramp_core::compute::BackendRegistry,
715        deploy: boatramp_core::deploy::DeployStore,
716    ) -> Self {
717        Self { backends, deploy }
718    }
719}
720
721#[async_trait::async_trait]
722impl boatramp_core::compute::ComputeControl for NodeComputeControl {
723    async fn restart(
724        &self,
725        project: &str,
726        workload: &str,
727        replica: u32,
728    ) -> Result<bool, boatramp_core::compute::ControlError> {
729        use boatramp_core::compute::{BackendError, ControlError};
730        use boatramp_core::project::ProjectRef;
731        let pref = ProjectRef::new(project);
732        let states = self
733            .deploy
734            .list_replica_states(pref, workload)
735            .await
736            .map_err(|e| ControlError::Other(e.to_string()))?;
737        let Some(target) = states.iter().find(|s| s.handle.replica == replica) else {
738            return Ok(false);
739        };
740        let backend = self
741            .backends
742            .get(&target.backend)
743            .ok_or_else(|| ControlError::Unsupported(target.backend.clone()))?;
744        // Stop the replica, then drop its observed state so the reconcile loop sees
745        // desired > observed and launches a fresh one (re-running IPAM). `stop` is
746        // idempotent, so a half-gone replica still converges.
747        match backend.stop(&target.handle).await {
748            Ok(()) => {}
749            Err(BackendError::Unsupported) => {
750                return Err(ControlError::Unsupported(target.backend.clone()));
751            }
752            Err(e) => return Err(ControlError::Other(e.to_string())),
753        }
754        self.deploy
755            .delete_replica_state(pref, workload, replica)
756            .await
757            .map_err(|e| ControlError::Other(e.to_string()))?;
758        Ok(true)
759    }
760}
761
762/// The node's [`ComputeVolumes`](boatramp_core::compute::ComputeVolumes): list +
763/// reclaim persistent volumes. Backs `GET /api/compute/volumes` +
764/// `DELETE /api/compute/volumes/{name}` (admin-scoped). Lists every
765/// volume-capable backend's on-node volumes, flags which are still referenced by a
766/// registered workload's active spec (in use vs orphaned), and refuses to remove
767/// an in-use volume unless forced — so `compute rm <workload>` (which unregisters
768/// it, then the reconcile loop stops the replica) is the safe precondition for
769/// reclaiming its volume.
770pub struct NodeComputeVolumes {
771    backends: boatramp_core::compute::BackendRegistry,
772    deploy: boatramp_core::deploy::DeployStore,
773}
774
775impl NodeComputeVolumes {
776    /// Build over this node's compute backends + the control-plane store.
777    pub fn new(
778        backends: boatramp_core::compute::BackendRegistry,
779        deploy: boatramp_core::deploy::DeployStore,
780    ) -> Self {
781        Self { backends, deploy }
782    }
783
784    /// The set of volume names still referenced by **any** registered workload's
785    /// active spec, across every project (the `_all` fan-out). A name in this set
786    /// is "in use": a running or relaunching replica mounts it, so removing its
787    /// backing would corrupt live data. Resolves each workload's content-addressed
788    /// spec to read its `volumes[].name`; a workload whose spec can't be resolved
789    /// is skipped (it can't be actively mounting a volume the backend still backs).
790    async fn referenced_volume_names(
791        &self,
792    ) -> Result<std::collections::BTreeSet<String>, boatramp_core::compute::VolumeError> {
793        use boatramp_core::compute::VolumeError;
794        let mut names = std::collections::BTreeSet::new();
795        let workloads = self
796            .deploy
797            .list_compute_workloads_all()
798            .await
799            .map_err(|e| VolumeError::Other(e.to_string()))?;
800        for (_project, workload) in workloads {
801            let spec = self
802                .deploy
803                .get_compute_spec(&workload.active)
804                .await
805                .map_err(|e| VolumeError::Other(e.to_string()))?;
806            if let Some(spec) = spec {
807                for vol in spec.volumes {
808                    names.insert(vol.name);
809                }
810            }
811        }
812        Ok(names)
813    }
814}
815
816#[async_trait::async_trait]
817impl boatramp_core::compute::ComputeVolumes for NodeComputeVolumes {
818    async fn list(
819        &self,
820    ) -> Result<Vec<boatramp_core::compute::VolumeStatus>, boatramp_core::compute::VolumeError>
821    {
822        use boatramp_core::compute::{VolumeError, VolumeStatus};
823        let referenced = self.referenced_volume_names().await?;
824        // Union the volumes every backend reports (dedup by name — a name is unique
825        // per node's volumes dir). A backend that doesn't back volumes returns the
826        // empty default, so this naturally reduces to the volume-capable backend(s).
827        let mut by_name: std::collections::BTreeMap<String, u64> =
828            std::collections::BTreeMap::new();
829        for backend in self.backends.values() {
830            let vols = backend
831                .list_volumes()
832                .await
833                .map_err(|e| VolumeError::Other(e.to_string()))?;
834            for v in vols {
835                // Keep the largest reported size if two backends somehow name-collide.
836                let slot = by_name.entry(v.name).or_insert(0);
837                *slot = (*slot).max(v.size_bytes);
838            }
839        }
840        Ok(by_name
841            .into_iter()
842            .map(|(name, size_bytes)| VolumeStatus {
843                in_use: referenced.contains(&name),
844                info: boatramp_core::compute::VolumeInfo { name, size_bytes },
845            })
846            .collect())
847    }
848
849    async fn remove(
850        &self,
851        name: &str,
852        force: bool,
853    ) -> Result<bool, boatramp_core::compute::VolumeError> {
854        use boatramp_core::compute::{BackendError, VolumeError};
855        // Safety guard: refuse to pull a volume out from under a registered
856        // workload unless the operator forces it. `compute rm <workload>` first is
857        // the safe flow; `--force` is the disposable-data override.
858        if !force && self.referenced_volume_names().await?.contains(name) {
859            return Err(VolumeError::InUse(name.to_string()));
860        }
861        // Remove from whichever backend owns it. `true` from any backend ⇒ existed.
862        // Every backend reports `Unsupported` ⇒ no volume-capable backend here.
863        let mut existed = false;
864        let mut any_supported = false;
865        for backend in self.backends.values() {
866            match backend.remove_volume(name).await {
867                Ok(removed) => {
868                    any_supported = true;
869                    existed |= removed;
870                }
871                Err(BackendError::Unsupported) => {}
872                Err(e) => return Err(VolumeError::Other(e.to_string())),
873            }
874        }
875        if !any_supported {
876            return Err(VolumeError::Unsupported);
877        }
878        Ok(existed)
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use async_trait::async_trait;
886    use boatramp_core::compute::{
887        Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, ComputeVolumes,
888        ComputeWorkload, Health, Instance, InstanceHandle, IsolationClass, IsolationRequirement,
889        LaunchRequest, RestartPolicy, RootSource, VolumeError, VolumeInfo, VolumeRef,
890    };
891    use boatramp_core::deploy::DeployStore;
892    use boatramp_core::project::ProjectRef;
893    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
894    use std::collections::BTreeMap;
895    use std::sync::{Arc, Mutex};
896
897    /// A `Storage` the `DeployStore` never actually reads on the volume paths (the
898    /// spec/workload records live in the KV) — every method is a stub.
899    struct NullStorage;
900    #[async_trait]
901    impl Storage for NullStorage {
902        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
903            Err(StorageError::NotFound(String::new()))
904        }
905        async fn get_range(
906            &self,
907            _: &str,
908            _: u64,
909            _: Option<u64>,
910        ) -> Result<GetObject, StorageError> {
911            Err(StorageError::unsupported("range"))
912        }
913        async fn put(
914            &self,
915            _: &str,
916            _: ByteStream,
917            _: PutMeta,
918        ) -> Result<ObjectMeta, StorageError> {
919            Err(StorageError::unsupported("put"))
920        }
921        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
922            Err(StorageError::NotFound(String::new()))
923        }
924        async fn delete(&self, _: &str) -> Result<(), StorageError> {
925            Ok(())
926        }
927        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
928            Ok(Vec::new())
929        }
930    }
931
932    /// A fake volume-capable backend over an in-memory set of `(name, size)`
933    /// volumes — enough to drive `NodeComputeVolumes` without a real container node.
934    struct FakeVolumeBackend {
935        vols: Mutex<BTreeMap<String, u64>>,
936    }
937    impl FakeVolumeBackend {
938        fn with(names: &[(&str, u64)]) -> Self {
939            Self {
940                vols: Mutex::new(names.iter().map(|(n, s)| (n.to_string(), *s)).collect()),
941            }
942        }
943    }
944    #[async_trait]
945    impl ComputeBackend for FakeVolumeBackend {
946        fn id(&self) -> &'static str {
947            "container"
948        }
949        fn capabilities(&self) -> Capabilities {
950            Capabilities {
951                isolation: IsolationClass::Namespace,
952                scale_to_zero: false,
953                persistent_volumes: true,
954                max_vcpus: None,
955                max_mem_mib: None,
956            }
957        }
958        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
959            Err(BackendError::Unsupported)
960        }
961        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
962            Err(BackendError::Unsupported)
963        }
964        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
965            Ok(())
966        }
967        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
968            Ok(Health::Unknown)
969        }
970        async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
971            Ok(self
972                .vols
973                .lock()
974                .unwrap()
975                .iter()
976                .map(|(name, size)| VolumeInfo {
977                    name: name.clone(),
978                    size_bytes: *size,
979                })
980                .collect())
981        }
982        async fn remove_volume(&self, name: &str) -> Result<bool, BackendError> {
983            Ok(self.vols.lock().unwrap().remove(name).is_some())
984        }
985    }
986
987    fn spec_with_volume(vol: Option<&str>) -> ComputeSpec {
988        ComputeSpec {
989            version: 1,
990            root: RootSource::Image("img".into()),
991            kernel: String::new(),
992            kernel_cmdline: None,
993            vcpus: 1,
994            mem_mib: 64,
995            entrypoint: vec![],
996            env: BTreeMap::new(),
997            port: 8080,
998            restart: RestartPolicy::Always,
999            startup_grace_secs: 30,
1000            scale_to_zero: false,
1001            volumes: vol
1002                .map(|n| {
1003                    vec![VolumeRef {
1004                        mount: "/data".into(),
1005                        name: n.into(),
1006                        size_mib: 128,
1007                    }]
1008                })
1009                .unwrap_or_default(),
1010            writable_root: false,
1011            cap_add: vec![],
1012            user: None,
1013            isolation: IsolationRequirement::Trusted,
1014            prefer_backend: None,
1015            bindings: vec![],
1016            post_launch: vec![],
1017        }
1018    }
1019
1020    /// Build a store with one workload named `wl` whose spec references volume
1021    /// `referenced` (or none), plus a `NodeComputeVolumes` over a fake backend that
1022    /// backs `backend_vols`.
1023    async fn setup(referenced: Option<&str>, backend_vols: &[(&str, u64)]) -> NodeComputeVolumes {
1024        let store = DeployStore::new(
1025            Arc::new(NullStorage),
1026            Arc::new(boatramp_core::kv::MemoryKv::new()),
1027        );
1028        let spec = spec_with_volume(referenced);
1029        let hash = store.put_compute_spec(&spec).await.expect("put spec");
1030        let workload = ComputeWorkload {
1031            version: 1,
1032            name: "wl".into(),
1033            active: hash,
1034            replicas: 1,
1035            placement: Default::default(),
1036        };
1037        store
1038            .set_compute_workload(ProjectRef::DEFAULT, &workload)
1039            .await
1040            .expect("set workload");
1041        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1042        backends.insert(
1043            "container".into(),
1044            Arc::new(FakeVolumeBackend::with(backend_vols)) as Arc<dyn ComputeBackend>,
1045        );
1046        NodeComputeVolumes::new(backends, store)
1047    }
1048
1049    #[tokio::test]
1050    async fn list_flags_referenced_volume_in_use_and_orphan_free() {
1051        // "data" is referenced by the workload spec; "old" is an orphan.
1052        let vols = setup(Some("data"), &[("data", 100), ("old", 50)]).await;
1053        let listed = vols.list().await.expect("list");
1054        assert_eq!(listed.len(), 2);
1055        let data = listed.iter().find(|v| v.info.name == "data").unwrap();
1056        let old = listed.iter().find(|v| v.info.name == "old").unwrap();
1057        assert!(data.in_use, "spec-referenced volume is in use");
1058        assert_eq!(data.info.size_bytes, 100);
1059        assert!(!old.in_use, "unreferenced volume is orphaned");
1060        assert_eq!(old.info.size_bytes, 50);
1061    }
1062
1063    #[tokio::test]
1064    async fn remove_refuses_in_use_without_force_and_allows_with_force() {
1065        let vols = setup(Some("data"), &[("data", 100)]).await;
1066        // Without force: refused (in use by the registered workload).
1067        assert!(matches!(
1068            vols.remove("data", false).await,
1069            Err(VolumeError::InUse(n)) if n == "data"
1070        ));
1071        // The volume is still there (refusal didn't remove it).
1072        assert!(
1073            vols.list()
1074                .await
1075                .unwrap()
1076                .iter()
1077                .any(|v| v.info.name == "data")
1078        );
1079        // With force: removed.
1080        assert!(vols.remove("data", true).await.expect("forced remove"));
1081        assert!(vols.list().await.unwrap().is_empty());
1082    }
1083
1084    #[tokio::test]
1085    async fn remove_orphan_succeeds_and_absent_reports_false() {
1086        // No workload references "old"; it removes without force.
1087        let vols = setup(None, &[("old", 50)]).await;
1088        assert!(vols.remove("old", false).await.expect("remove orphan"));
1089        // Removing an absent volume reports "did not exist".
1090        assert!(!vols.remove("gone", false).await.expect("remove absent"));
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // Startup IP adoption (container-IP collision fix): the node reads every
1095    // persisted replica and hands each backend the `(workload, replica, ip)` it
1096    // owns, so a fresh-on-boot pool reserves live addresses before allocating.
1097    // -----------------------------------------------------------------------
1098
1099    /// A backend that records the `reserve_in_use` tuples it was handed (a spy for
1100    /// the startup adoption wiring).
1101    struct AdoptSpyBackend {
1102        adopted: Mutex<Vec<(String, String, u32, std::net::Ipv4Addr)>>,
1103    }
1104    #[async_trait]
1105    impl ComputeBackend for AdoptSpyBackend {
1106        fn id(&self) -> &'static str {
1107            "container"
1108        }
1109        fn capabilities(&self) -> Capabilities {
1110            Capabilities {
1111                isolation: IsolationClass::Namespace,
1112                scale_to_zero: false,
1113                persistent_volumes: true,
1114                max_vcpus: None,
1115                max_mem_mib: None,
1116            }
1117        }
1118        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
1119            Err(BackendError::Unsupported)
1120        }
1121        async fn reserve_in_use(&self, replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {
1122            self.adopted.lock().unwrap().extend_from_slice(replicas);
1123        }
1124        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
1125            Err(BackendError::Unsupported)
1126        }
1127        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
1128            Ok(())
1129        }
1130        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
1131            Ok(Health::Unknown)
1132        }
1133    }
1134
1135    #[tokio::test]
1136    async fn adopt_running_replica_ips_feeds_each_backends_in_use_addresses() {
1137        use boatramp_core::compute::{Endpoint, ObservedInstance, ReplicaPhase, Scheme};
1138        use std::net::Ipv4Addr;
1139
1140        let store = DeployStore::new(
1141            Arc::new(NullStorage),
1142            Arc::new(boatramp_core::kv::MemoryKv::new()),
1143        );
1144        // Container replicas on distinct IPs (one in `default`, one in a non-default
1145        // project — the adoption must carry the OWNING project into the tuple), a
1146        // parked container replica, plus one on a different backend (must not be
1147        // handed to the container backend's adoption).
1148        let mk = |proj: &str, wl: &str, rep: u32, backend: &str, ip: &str, phase: ReplicaPhase| {
1149            (
1150                proj.to_string(),
1151                ObservedInstance {
1152                    handle: InstanceHandle {
1153                        project: proj.into(),
1154                        workload: wl.into(),
1155                        replica: rep,
1156                        backend_ref: format!("{ip}:5432"),
1157                    },
1158                    node: 1,
1159                    backend: backend.into(),
1160                    endpoint: Endpoint {
1161                        scheme: Scheme::Http,
1162                        host: ip.into(),
1163                        port: 5432,
1164                    },
1165                    region: None,
1166                    healthy: true,
1167                    started_at: None,
1168                    phase,
1169                    readiness: boatramp_core::compute::Readiness::Provisioned,
1170                    snapshot: None,
1171                },
1172            )
1173        };
1174        for (proj, st) in [
1175            mk(
1176                "default",
1177                "pg-a",
1178                0,
1179                "container",
1180                "10.0.0.2",
1181                ReplicaPhase::Running,
1182            ),
1183            // A non-default project's SAME-shaped workload — its project must survive
1184            // into the adoption tuple (not collapse to `default`).
1185            mk(
1186                "acme",
1187                "web",
1188                0,
1189                "container",
1190                "10.0.0.3",
1191                ReplicaPhase::Zero,
1192            ), // parked — still holds its IP
1193            mk(
1194                "default",
1195                "vm",
1196                0,
1197                "vmm-embedded",
1198                "10.0.0.9",
1199                ReplicaPhase::Running,
1200            ),
1201        ] {
1202            store
1203                .set_replica_state(ProjectRef::new(&proj), &st)
1204                .await
1205                .expect("persist replica state");
1206        }
1207
1208        let container = Arc::new(AdoptSpyBackend {
1209            adopted: Mutex::new(Vec::new()),
1210        });
1211        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1212        backends.insert(
1213            "container".into(),
1214            container.clone() as Arc<dyn ComputeBackend>,
1215        );
1216
1217        adopt_running_replica_ips(&store, &backends).await;
1218
1219        let got = container.adopted.lock().unwrap().clone();
1220        // Only the two container replicas' addresses were handed to the container
1221        // backend, each carrying its OWNING project — the VMM replica's IP went to
1222        // no container adoption.
1223        assert!(got.contains(&(
1224            "default".into(),
1225            "pg-a".into(),
1226            0,
1227            Ipv4Addr::new(10, 0, 0, 2)
1228        )));
1229        assert!(got.contains(&("acme".into(), "web".into(), 0, Ipv4Addr::new(10, 0, 0, 3))));
1230        assert!(
1231            !got.iter()
1232                .any(|(_, _, _, ip)| *ip == Ipv4Addr::new(10, 0, 0, 9)),
1233            "another backend's replica IP must not be adopted by the container backend"
1234        );
1235        assert_eq!(got.len(), 2);
1236    }
1237}