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 [`ComputeControl`](boatramp_core::compute::ComputeControl): restart a
677/// replica (stop it + drop its observed state so the reconcile loop relaunches it).
678/// Backs `POST /api/compute/maintenance/restart` (admin-scoped). Resolves the target
679/// replica's backend from its persisted state and calls
680/// [`ComputeBackend::stop`](boatramp_core::compute::ComputeBackend::stop); the server
681/// nudges the reconcile loop afterwards so relaunch is prompt.
682pub struct NodeComputeControl {
683    backends: boatramp_core::compute::BackendRegistry,
684    deploy: boatramp_core::deploy::DeployStore,
685}
686
687impl NodeComputeControl {
688    /// Build over this node's compute backends + the control-plane store. Clone the
689    /// registry before the reconcile loop consumes the original.
690    pub fn new(
691        backends: boatramp_core::compute::BackendRegistry,
692        deploy: boatramp_core::deploy::DeployStore,
693    ) -> Self {
694        Self { backends, deploy }
695    }
696}
697
698#[async_trait::async_trait]
699impl boatramp_core::compute::ComputeControl for NodeComputeControl {
700    async fn restart(
701        &self,
702        project: &str,
703        workload: &str,
704        replica: u32,
705    ) -> Result<bool, boatramp_core::compute::ControlError> {
706        use boatramp_core::compute::{BackendError, ControlError};
707        use boatramp_core::project::ProjectRef;
708        let pref = ProjectRef::new(project);
709        let states = self
710            .deploy
711            .list_replica_states(pref, workload)
712            .await
713            .map_err(|e| ControlError::Other(e.to_string()))?;
714        let Some(target) = states.iter().find(|s| s.handle.replica == replica) else {
715            return Ok(false);
716        };
717        let backend = self
718            .backends
719            .get(&target.backend)
720            .ok_or_else(|| ControlError::Unsupported(target.backend.clone()))?;
721        // Stop the replica, then drop its observed state so the reconcile loop sees
722        // desired > observed and launches a fresh one (re-running IPAM). `stop` is
723        // idempotent, so a half-gone replica still converges.
724        match backend.stop(&target.handle).await {
725            Ok(()) => {}
726            Err(BackendError::Unsupported) => {
727                return Err(ControlError::Unsupported(target.backend.clone()))
728            }
729            Err(e) => return Err(ControlError::Other(e.to_string())),
730        }
731        self.deploy
732            .delete_replica_state(pref, workload, replica)
733            .await
734            .map_err(|e| ControlError::Other(e.to_string()))?;
735        Ok(true)
736    }
737}
738
739/// The node's [`ComputeVolumes`](boatramp_core::compute::ComputeVolumes): list +
740/// reclaim persistent volumes. Backs `GET /api/compute/volumes` +
741/// `DELETE /api/compute/volumes/{name}` (admin-scoped). Lists every
742/// volume-capable backend's on-node volumes, flags which are still referenced by a
743/// registered workload's active spec (in use vs orphaned), and refuses to remove
744/// an in-use volume unless forced — so `compute rm <workload>` (which unregisters
745/// it, then the reconcile loop stops the replica) is the safe precondition for
746/// reclaiming its volume.
747pub struct NodeComputeVolumes {
748    backends: boatramp_core::compute::BackendRegistry,
749    deploy: boatramp_core::deploy::DeployStore,
750}
751
752impl NodeComputeVolumes {
753    /// Build over this node's compute backends + the control-plane store.
754    pub fn new(
755        backends: boatramp_core::compute::BackendRegistry,
756        deploy: boatramp_core::deploy::DeployStore,
757    ) -> Self {
758        Self { backends, deploy }
759    }
760
761    /// The set of volume names still referenced by **any** registered workload's
762    /// active spec, across every project (the `_all` fan-out). A name in this set
763    /// is "in use": a running or relaunching replica mounts it, so removing its
764    /// backing would corrupt live data. Resolves each workload's content-addressed
765    /// spec to read its `volumes[].name`; a workload whose spec can't be resolved
766    /// is skipped (it can't be actively mounting a volume the backend still backs).
767    async fn referenced_volume_names(
768        &self,
769    ) -> Result<std::collections::BTreeSet<String>, boatramp_core::compute::VolumeError> {
770        use boatramp_core::compute::VolumeError;
771        let mut names = std::collections::BTreeSet::new();
772        let workloads = self
773            .deploy
774            .list_compute_workloads_all()
775            .await
776            .map_err(|e| VolumeError::Other(e.to_string()))?;
777        for (_project, workload) in workloads {
778            let spec = self
779                .deploy
780                .get_compute_spec(&workload.active)
781                .await
782                .map_err(|e| VolumeError::Other(e.to_string()))?;
783            if let Some(spec) = spec {
784                for vol in spec.volumes {
785                    names.insert(vol.name);
786                }
787            }
788        }
789        Ok(names)
790    }
791}
792
793#[async_trait::async_trait]
794impl boatramp_core::compute::ComputeVolumes for NodeComputeVolumes {
795    async fn list(
796        &self,
797    ) -> Result<Vec<boatramp_core::compute::VolumeStatus>, boatramp_core::compute::VolumeError>
798    {
799        use boatramp_core::compute::{VolumeError, VolumeStatus};
800        let referenced = self.referenced_volume_names().await?;
801        // Union the volumes every backend reports (dedup by name — a name is unique
802        // per node's volumes dir). A backend that doesn't back volumes returns the
803        // empty default, so this naturally reduces to the volume-capable backend(s).
804        let mut by_name: std::collections::BTreeMap<String, u64> =
805            std::collections::BTreeMap::new();
806        for backend in self.backends.values() {
807            let vols = backend
808                .list_volumes()
809                .await
810                .map_err(|e| VolumeError::Other(e.to_string()))?;
811            for v in vols {
812                // Keep the largest reported size if two backends somehow name-collide.
813                let slot = by_name.entry(v.name).or_insert(0);
814                *slot = (*slot).max(v.size_bytes);
815            }
816        }
817        Ok(by_name
818            .into_iter()
819            .map(|(name, size_bytes)| VolumeStatus {
820                in_use: referenced.contains(&name),
821                info: boatramp_core::compute::VolumeInfo { name, size_bytes },
822            })
823            .collect())
824    }
825
826    async fn remove(
827        &self,
828        name: &str,
829        force: bool,
830    ) -> Result<bool, boatramp_core::compute::VolumeError> {
831        use boatramp_core::compute::{BackendError, VolumeError};
832        // Safety guard: refuse to pull a volume out from under a registered
833        // workload unless the operator forces it. `compute rm <workload>` first is
834        // the safe flow; `--force` is the disposable-data override.
835        if !force && self.referenced_volume_names().await?.contains(name) {
836            return Err(VolumeError::InUse(name.to_string()));
837        }
838        // Remove from whichever backend owns it. `true` from any backend ⇒ existed.
839        // Every backend reports `Unsupported` ⇒ no volume-capable backend here.
840        let mut existed = false;
841        let mut any_supported = false;
842        for backend in self.backends.values() {
843            match backend.remove_volume(name).await {
844                Ok(removed) => {
845                    any_supported = true;
846                    existed |= removed;
847                }
848                Err(BackendError::Unsupported) => {}
849                Err(e) => return Err(VolumeError::Other(e.to_string())),
850            }
851        }
852        if !any_supported {
853            return Err(VolumeError::Unsupported);
854        }
855        Ok(existed)
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use async_trait::async_trait;
863    use boatramp_core::compute::{
864        Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, ComputeVolumes,
865        ComputeWorkload, Health, Instance, InstanceHandle, IsolationClass, IsolationRequirement,
866        LaunchRequest, RestartPolicy, RootSource, VolumeError, VolumeInfo, VolumeRef,
867    };
868    use boatramp_core::deploy::DeployStore;
869    use boatramp_core::project::ProjectRef;
870    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
871    use std::collections::BTreeMap;
872    use std::sync::{Arc, Mutex};
873
874    /// A `Storage` the `DeployStore` never actually reads on the volume paths (the
875    /// spec/workload records live in the KV) — every method is a stub.
876    struct NullStorage;
877    #[async_trait]
878    impl Storage for NullStorage {
879        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
880            Err(StorageError::NotFound(String::new()))
881        }
882        async fn get_range(
883            &self,
884            _: &str,
885            _: u64,
886            _: Option<u64>,
887        ) -> Result<GetObject, StorageError> {
888            Err(StorageError::unsupported("range"))
889        }
890        async fn put(
891            &self,
892            _: &str,
893            _: ByteStream,
894            _: PutMeta,
895        ) -> Result<ObjectMeta, StorageError> {
896            Err(StorageError::unsupported("put"))
897        }
898        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
899            Err(StorageError::NotFound(String::new()))
900        }
901        async fn delete(&self, _: &str) -> Result<(), StorageError> {
902            Ok(())
903        }
904        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
905            Ok(Vec::new())
906        }
907    }
908
909    /// A fake volume-capable backend over an in-memory set of `(name, size)`
910    /// volumes — enough to drive `NodeComputeVolumes` without a real container node.
911    struct FakeVolumeBackend {
912        vols: Mutex<BTreeMap<String, u64>>,
913    }
914    impl FakeVolumeBackend {
915        fn with(names: &[(&str, u64)]) -> Self {
916            Self {
917                vols: Mutex::new(names.iter().map(|(n, s)| (n.to_string(), *s)).collect()),
918            }
919        }
920    }
921    #[async_trait]
922    impl ComputeBackend for FakeVolumeBackend {
923        fn id(&self) -> &'static str {
924            "container"
925        }
926        fn capabilities(&self) -> Capabilities {
927            Capabilities {
928                isolation: IsolationClass::Namespace,
929                scale_to_zero: false,
930                persistent_volumes: true,
931                max_vcpus: None,
932                max_mem_mib: None,
933            }
934        }
935        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
936            Err(BackendError::Unsupported)
937        }
938        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
939            Err(BackendError::Unsupported)
940        }
941        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
942            Ok(())
943        }
944        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
945            Ok(Health::Unknown)
946        }
947        async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
948            Ok(self
949                .vols
950                .lock()
951                .unwrap()
952                .iter()
953                .map(|(name, size)| VolumeInfo {
954                    name: name.clone(),
955                    size_bytes: *size,
956                })
957                .collect())
958        }
959        async fn remove_volume(&self, name: &str) -> Result<bool, BackendError> {
960            Ok(self.vols.lock().unwrap().remove(name).is_some())
961        }
962    }
963
964    fn spec_with_volume(vol: Option<&str>) -> ComputeSpec {
965        ComputeSpec {
966            version: 1,
967            root: RootSource::Image("img".into()),
968            kernel: String::new(),
969            kernel_cmdline: None,
970            vcpus: 1,
971            mem_mib: 64,
972            entrypoint: vec![],
973            env: BTreeMap::new(),
974            port: 8080,
975            restart: RestartPolicy::Always,
976            startup_grace_secs: 30,
977            scale_to_zero: false,
978            volumes: vol
979                .map(|n| {
980                    vec![VolumeRef {
981                        mount: "/data".into(),
982                        name: n.into(),
983                        size_mib: 128,
984                    }]
985                })
986                .unwrap_or_default(),
987            writable_root: false,
988            cap_add: vec![],
989            user: None,
990            isolation: IsolationRequirement::Trusted,
991            prefer_backend: None,
992            bindings: vec![],
993        }
994    }
995
996    /// Build a store with one workload named `wl` whose spec references volume
997    /// `referenced` (or none), plus a `NodeComputeVolumes` over a fake backend that
998    /// backs `backend_vols`.
999    async fn setup(referenced: Option<&str>, backend_vols: &[(&str, u64)]) -> NodeComputeVolumes {
1000        let store = DeployStore::new(
1001            Arc::new(NullStorage),
1002            Arc::new(boatramp_core::kv::MemoryKv::new()),
1003        );
1004        let spec = spec_with_volume(referenced);
1005        let hash = store.put_compute_spec(&spec).await.expect("put spec");
1006        let workload = ComputeWorkload {
1007            version: 1,
1008            name: "wl".into(),
1009            active: hash,
1010            replicas: 1,
1011            placement: Default::default(),
1012        };
1013        store
1014            .set_compute_workload(ProjectRef::DEFAULT, &workload)
1015            .await
1016            .expect("set workload");
1017        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1018        backends.insert(
1019            "container".into(),
1020            Arc::new(FakeVolumeBackend::with(backend_vols)) as Arc<dyn ComputeBackend>,
1021        );
1022        NodeComputeVolumes::new(backends, store)
1023    }
1024
1025    #[tokio::test]
1026    async fn list_flags_referenced_volume_in_use_and_orphan_free() {
1027        // "data" is referenced by the workload spec; "old" is an orphan.
1028        let vols = setup(Some("data"), &[("data", 100), ("old", 50)]).await;
1029        let listed = vols.list().await.expect("list");
1030        assert_eq!(listed.len(), 2);
1031        let data = listed.iter().find(|v| v.info.name == "data").unwrap();
1032        let old = listed.iter().find(|v| v.info.name == "old").unwrap();
1033        assert!(data.in_use, "spec-referenced volume is in use");
1034        assert_eq!(data.info.size_bytes, 100);
1035        assert!(!old.in_use, "unreferenced volume is orphaned");
1036        assert_eq!(old.info.size_bytes, 50);
1037    }
1038
1039    #[tokio::test]
1040    async fn remove_refuses_in_use_without_force_and_allows_with_force() {
1041        let vols = setup(Some("data"), &[("data", 100)]).await;
1042        // Without force: refused (in use by the registered workload).
1043        assert!(matches!(
1044            vols.remove("data", false).await,
1045            Err(VolumeError::InUse(n)) if n == "data"
1046        ));
1047        // The volume is still there (refusal didn't remove it).
1048        assert!(vols
1049            .list()
1050            .await
1051            .unwrap()
1052            .iter()
1053            .any(|v| v.info.name == "data"));
1054        // With force: removed.
1055        assert!(vols.remove("data", true).await.expect("forced remove"));
1056        assert!(vols.list().await.unwrap().is_empty());
1057    }
1058
1059    #[tokio::test]
1060    async fn remove_orphan_succeeds_and_absent_reports_false() {
1061        // No workload references "old"; it removes without force.
1062        let vols = setup(None, &[("old", 50)]).await;
1063        assert!(vols.remove("old", false).await.expect("remove orphan"));
1064        // Removing an absent volume reports "did not exist".
1065        assert!(!vols.remove("gone", false).await.expect("remove absent"));
1066    }
1067
1068    // -----------------------------------------------------------------------
1069    // Startup IP adoption (container-IP collision fix): the node reads every
1070    // persisted replica and hands each backend the `(workload, replica, ip)` it
1071    // owns, so a fresh-on-boot pool reserves live addresses before allocating.
1072    // -----------------------------------------------------------------------
1073
1074    /// A backend that records the `reserve_in_use` tuples it was handed (a spy for
1075    /// the startup adoption wiring).
1076    struct AdoptSpyBackend {
1077        adopted: Mutex<Vec<(String, String, u32, std::net::Ipv4Addr)>>,
1078    }
1079    #[async_trait]
1080    impl ComputeBackend for AdoptSpyBackend {
1081        fn id(&self) -> &'static str {
1082            "container"
1083        }
1084        fn capabilities(&self) -> Capabilities {
1085            Capabilities {
1086                isolation: IsolationClass::Namespace,
1087                scale_to_zero: false,
1088                persistent_volumes: true,
1089                max_vcpus: None,
1090                max_mem_mib: None,
1091            }
1092        }
1093        async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
1094            Err(BackendError::Unsupported)
1095        }
1096        async fn reserve_in_use(&self, replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {
1097            self.adopted.lock().unwrap().extend_from_slice(replicas);
1098        }
1099        async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
1100            Err(BackendError::Unsupported)
1101        }
1102        async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
1103            Ok(())
1104        }
1105        async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
1106            Ok(Health::Unknown)
1107        }
1108    }
1109
1110    #[tokio::test]
1111    async fn adopt_running_replica_ips_feeds_each_backends_in_use_addresses() {
1112        use boatramp_core::compute::{Endpoint, ObservedInstance, ReplicaPhase, Scheme};
1113        use std::net::Ipv4Addr;
1114
1115        let store = DeployStore::new(
1116            Arc::new(NullStorage),
1117            Arc::new(boatramp_core::kv::MemoryKv::new()),
1118        );
1119        // Container replicas on distinct IPs (one in `default`, one in a non-default
1120        // project — the adoption must carry the OWNING project into the tuple), a
1121        // parked container replica, plus one on a different backend (must not be
1122        // handed to the container backend's adoption).
1123        let mk = |proj: &str, wl: &str, rep: u32, backend: &str, ip: &str, phase: ReplicaPhase| {
1124            (
1125                proj.to_string(),
1126                ObservedInstance {
1127                    handle: InstanceHandle {
1128                        project: proj.into(),
1129                        workload: wl.into(),
1130                        replica: rep,
1131                        backend_ref: format!("{ip}:5432"),
1132                    },
1133                    node: 1,
1134                    backend: backend.into(),
1135                    endpoint: Endpoint {
1136                        scheme: Scheme::Http,
1137                        host: ip.into(),
1138                        port: 5432,
1139                    },
1140                    region: None,
1141                    healthy: true,
1142                    started_at: None,
1143                    phase,
1144                    snapshot: None,
1145                },
1146            )
1147        };
1148        for (proj, st) in [
1149            mk(
1150                "default",
1151                "pg-a",
1152                0,
1153                "container",
1154                "10.0.0.2",
1155                ReplicaPhase::Running,
1156            ),
1157            // A non-default project's SAME-shaped workload — its project must survive
1158            // into the adoption tuple (not collapse to `default`).
1159            mk(
1160                "acme",
1161                "web",
1162                0,
1163                "container",
1164                "10.0.0.3",
1165                ReplicaPhase::Zero,
1166            ), // parked — still holds its IP
1167            mk(
1168                "default",
1169                "vm",
1170                0,
1171                "vmm-embedded",
1172                "10.0.0.9",
1173                ReplicaPhase::Running,
1174            ),
1175        ] {
1176            store
1177                .set_replica_state(ProjectRef::new(&proj), &st)
1178                .await
1179                .expect("persist replica state");
1180        }
1181
1182        let container = Arc::new(AdoptSpyBackend {
1183            adopted: Mutex::new(Vec::new()),
1184        });
1185        let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1186        backends.insert(
1187            "container".into(),
1188            container.clone() as Arc<dyn ComputeBackend>,
1189        );
1190
1191        adopt_running_replica_ips(&store, &backends).await;
1192
1193        let got = container.adopted.lock().unwrap().clone();
1194        // Only the two container replicas' addresses were handed to the container
1195        // backend, each carrying its OWNING project — the VMM replica's IP went to
1196        // no container adoption.
1197        assert!(got.contains(&(
1198            "default".into(),
1199            "pg-a".into(),
1200            0,
1201            Ipv4Addr::new(10, 0, 0, 2)
1202        )));
1203        assert!(got.contains(&("acme".into(), "web".into(), 0, Ipv4Addr::new(10, 0, 0, 3))));
1204        assert!(
1205            !got.iter()
1206                .any(|(_, _, _, ip)| *ip == Ipv4Addr::new(10, 0, 0, 9)),
1207            "another backend's replica IP must not be adopted by the container backend"
1208        );
1209        assert_eq!(got.len(), 2);
1210    }
1211}