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