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