1use std::sync::Arc;
11
12#[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#[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 fn verify(&self, bytes: &[u8], expected_hash: &str) -> std::result::Result<(), String> {
46 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#[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#[cfg(target_os = "macos")]
127fn macos_supports_vz() -> bool {
128 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#[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
151pub 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 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 match boatramp_docker::DockerBackend::connect() {
189 Ok(docker) => {
190 let docker = docker
194 .with_endpoint(cfg.docker_endpoint)
195 .with_volume_mode(cfg.docker_volume_mode)
196 .with_data_dir(data_dir)
197 .with_writable_root_allowed(!strict)
198 .with_cap_add_allowed(!strict);
199 if docker.reachable().await {
200 backends.insert("docker".to_string(), std::sync::Arc::new(docker));
201 } else {
202 tracing::debug!("no reachable docker daemon; skipping docker backend");
203 }
204 }
205 Err(e) => tracing::debug!(%e, "docker backend unavailable"),
206 }
207
208 #[cfg(target_os = "linux")]
220 let shared_ip_authority: Option<boatramp_core::ipam::IpAuthority> =
221 match boatramp_core::ipam::IpAuthority::new(&cfg.subnet) {
222 Ok(a) => Some(a),
223 Err(e) => {
224 tracing::warn!(%e, subnet = %cfg.subnet, "bad compute subnet; container + embedded-VMM backends disabled");
225 None
226 }
227 };
228 #[cfg(target_os = "linux")]
229 let bridge_ready = match &shared_ip_authority {
230 Some(authority) => {
231 match boatramp_container::ensure_bridge(
232 &cfg.bridge,
233 authority.gateway(),
234 authority.prefix_len(),
235 )
236 .await
237 {
238 Ok(()) => true,
239 Err(e) => {
240 tracing::warn!(%e, bridge = %cfg.bridge, "could not create the compute bridge (need CAP_NET_ADMIN); container + embedded-VMM backends disabled");
241 false
242 }
243 }
244 }
245 None => false,
246 };
247
248 #[cfg(target_os = "linux")]
250 if bridge_ready {
251 match worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())) {
252 Ok(self_exe) => match boatramp_container::ContainerBackend::new(
253 storage.clone(),
254 data_dir.to_path_buf(),
255 cfg.bridge.clone(),
256 &cfg.subnet,
257 self_exe,
258 ) {
259 Ok(c) => {
260 let c = c.with_cap_add_allowed(!strict);
263 let c = c.with_internal_dns(cfg.internal_dns.then(|| cfg.dns_domain.clone()));
268 let c = match &shared_ip_authority {
271 Some(a) => c.with_ip_authority(a.clone()),
272 None => c,
273 };
274 backends.insert("container".to_string(), std::sync::Arc::new(c));
275 }
276 Err(e) => tracing::warn!(%e, "container backend unavailable"),
277 },
278 Err(e) => tracing::warn!(%e, "current_exe for container backend"),
279 }
280 }
281 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
288 if bridge_ready && std::path::Path::new("/dev/kvm").exists() {
289 match (
290 worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
291 boatramp_core::ipam::IpPool::new(&cfg.subnet),
292 ) {
293 (Ok(self_exe), Ok(pool)) => {
294 let gateway = pool.gateway().to_string();
295 let verifier: Arc<dyn boatramp_firecracker::KernelVerifier> =
297 Arc::new(PostureKernelVerifier {
298 strict,
299 signing_keys: cfg.kernel_signing_pubkeys.clone(),
300 allowed_hashes: cfg.kernel_allowed_hashes.clone(),
301 daemon: daemon.clone(),
302 });
303 match boatramp_firecracker::EmbeddedVmmBackend::new(
304 storage.clone(),
305 self_exe, data_dir.to_path_buf(),
307 cfg.bridge.clone(),
308 gateway,
309 &cfg.subnet,
310 verifier,
311 ) {
312 Ok(vmm) => {
313 let vmm = match &shared_ip_authority {
317 Some(a) => vmm.with_ip_authority(a.clone()),
318 None => vmm,
319 };
320 backends.insert("vmm-embedded".to_string(), std::sync::Arc::new(vmm));
321 }
322 Err(e) => tracing::warn!(%e, "embedded VMM backend unavailable"),
323 }
324 }
325 (Err(e), _) => tracing::warn!(%e, "current_exe for VMM backend"),
326 (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for VMM backend"),
327 }
328 } else {
329 tracing::debug!("no /dev/kvm; skipping embedded VMM backend");
330 }
331
332 #[cfg(target_os = "macos")]
338 if macos_supports_vz() {
339 match (
340 worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
341 boatramp_core::ipam::IpPool::new(&cfg.subnet),
342 ) {
343 (Ok(self_exe), Ok(_pool)) => {
344 let verifier: Arc<dyn boatramp_vz::KernelVerifier> =
345 Arc::new(VzPostureKernelVerifier {
346 strict,
347 signing_keys: cfg.kernel_signing_pubkeys.clone(),
348 allowed_hashes: cfg.kernel_allowed_hashes.clone(),
349 daemon: daemon.clone(),
350 });
351 match boatramp_vz::VzBackend::new(
352 storage.clone(),
353 self_exe, data_dir.to_path_buf(),
355 &cfg.subnet, verifier,
357 ) {
358 Ok(vz) => {
360 let vz = vz.with_writable_root_allowed(!strict);
366 backends.insert("vmm-vz".to_string(), std::sync::Arc::new(vz));
367 }
368 Err(e) => tracing::warn!(%e, "macOS VMM backend unavailable"),
369 }
370 }
371 (Err(e), _) => tracing::warn!(%e, "current_exe for macOS VMM backend"),
372 (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for macOS VMM backend"),
373 }
374 } else {
375 tracing::debug!("not Apple silicon + macOS 15+; skipping macOS VMM backend");
376 }
377
378 let _ = (&storage, data_dir); #[cfg(not(any(all(target_os = "linux", target_arch = "x86_64"), target_os = "macos")))]
383 let _ = (strict, &daemon);
384
385 let free_vcpus = if cfg.vcpus > 0 {
386 cfg.vcpus
387 } else {
388 std::thread::available_parallelism()
389 .map(|n| n.get() as u32)
390 .unwrap_or(1)
391 };
392 let free_mem_mib = if cfg.mem_mib > 0 { cfg.mem_mib } else { 1024 };
393 let advertised: Vec<BackendKind> = backends
394 .iter()
395 .map(|(id, b)| {
396 let caps = b.capabilities();
397 BackendKind {
398 id: id.clone(),
399 isolation: caps.isolation,
400 persistent_volumes: caps.persistent_volumes,
401 scale_to_zero: caps.scale_to_zero,
402 }
403 })
404 .collect();
405 tracing::info!(backends = ?advertised, free_vcpus, free_mem_mib, "compute node inventory");
406 let node = Node {
407 id: node_id,
408 region: cfg.region.clone(),
409 labels: std::collections::BTreeMap::new(),
410 free_vcpus,
411 free_mem_mib,
412 backends: advertised,
413 };
414 (backends, node)
415}
416
417pub async fn adopt_running_replica_ips(
432 deploy: &boatramp_core::deploy::DeployStore,
433 backends: &boatramp_core::compute::BackendRegistry,
434) {
435 use std::collections::BTreeMap;
436 use std::net::Ipv4Addr;
437
438 let states = match deploy.list_all_replica_states().await {
439 Ok(s) => s,
440 Err(e) => {
441 tracing::warn!(%e, "could not read replica states for IP adoption; \
442 the reconcile loop starts without adopting in-use IPs");
443 return;
444 }
445 };
446 let mut by_backend: BTreeMap<String, Vec<(String, String, u32, Ipv4Addr)>> = BTreeMap::new();
453 for st in &states {
454 let ip = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
455 st.handle
456 .backend_ref
457 .split(':')
458 .next()
459 .and_then(|s| s.parse::<Ipv4Addr>().ok())
460 });
461 if let Some(ip) = ip {
462 by_backend.entry(st.backend.clone()).or_default().push((
463 st.handle.project.clone(),
464 st.handle.workload.clone(),
465 st.handle.replica,
466 ip,
467 ));
468 }
469 }
470 for (backend_id, replicas) in by_backend {
471 if let Some(backend) = backends.get(&backend_id) {
472 backend.reserve_in_use(&replicas).await;
473 tracing::info!(
474 backend = %backend_id,
475 count = replicas.len(),
476 "adopted in-use compute IPs into the backend pool"
477 );
478 }
479 }
480}
481
482#[cfg(target_os = "linux")]
491pub fn spawn_internal_dns(
492 cfg: Option<&crate::config::ComputeConfig>,
493 backends: &boatramp_core::compute::BackendRegistry,
494 deploy: &boatramp_core::deploy::DeployStore,
495) -> Option<tokio::task::JoinHandle<()>> {
496 let cfg = cfg?;
497 if !cfg.internal_dns || !backends.contains_key("container") {
500 return None;
501 }
502 let gateway = match boatramp_core::ipam::IpPool::new(&cfg.subnet) {
503 Ok(pool) => pool.gateway(),
504 Err(e) => {
505 tracing::warn!(%e, subnet = %cfg.subnet, "internal DNS: bad compute subnet; resolver not started");
506 return None;
507 }
508 };
509 let upstream: std::net::SocketAddr = match cfg.dns_upstream.parse() {
510 Ok(a) => a,
511 Err(e) => {
512 tracing::warn!(%e, upstream = %cfg.dns_upstream, "internal DNS: bad dns_upstream (want host:port); resolver not started");
513 return None;
514 }
515 };
516 let source: std::sync::Arc<dyn boatramp_container::dns_server::InternalDnsSource> =
517 std::sync::Arc::new(DeployDnsSource::new(deploy.clone()));
518 let domain = cfg.dns_domain.clone();
519 Some(tokio::spawn(async move {
520 if let Err(e) =
521 boatramp_container::dns_server::serve(gateway, upstream, domain, source).await
522 {
523 tracing::warn!(%e, "internal DNS resolver exited (bind/setup error); \
524 guests keep their static resolv.conf peers");
525 }
526 }))
527}
528
529#[cfg(not(target_os = "linux"))]
531pub fn spawn_internal_dns(
532 _cfg: Option<&crate::config::ComputeConfig>,
533 _backends: &boatramp_core::compute::BackendRegistry,
534 _deploy: &boatramp_core::deploy::DeployStore,
535) -> Option<tokio::task::JoinHandle<()>> {
536 None
537}
538
539#[cfg(target_os = "linux")]
549pub struct DeployDnsSource {
550 deploy: boatramp_core::deploy::DeployStore,
551}
552
553#[cfg(target_os = "linux")]
554impl DeployDnsSource {
555 pub fn new(deploy: boatramp_core::deploy::DeployStore) -> Self {
557 Self { deploy }
558 }
559}
560
561#[cfg(target_os = "linux")]
562#[async_trait::async_trait]
563impl boatramp_container::dns_server::InternalDnsSource for DeployDnsSource {
564 async fn snapshot(&self) -> boatramp_container::dns_server::DnsFleet {
565 use boatramp_container::dns::ResolvedAddrs;
566 use boatramp_container::dns_server::DnsFleet;
567 use boatramp_core::compute::ReplicaPhase;
568 use std::net::Ipv4Addr;
569
570 let mut fleet = DnsFleet::default();
571 let states = match self.deploy.list_all_replica_states().await {
572 Ok(s) => s,
573 Err(e) => {
574 tracing::warn!(%e, "internal DNS: could not read replica states; \
577 answering forward-only this query");
578 return fleet;
579 }
580 };
581 for st in &states {
582 let v4 = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
585 st.handle
586 .backend_ref
587 .split(':')
588 .next()
589 .and_then(|s| s.parse::<Ipv4Addr>().ok())
590 });
591 let Some(ip) = v4 else { continue };
592 let key = (st.handle.project.clone(), st.handle.workload.clone());
593 fleet.owners.insert(ip, key.clone());
598 if st.phase == ReplicaPhase::Running && st.healthy {
605 fleet
606 .addrs
607 .entry(key)
608 .or_insert_with(ResolvedAddrs::default)
609 .v4
610 .push(ip);
611 }
612 }
613 fleet
614 }
615}
616
617pub struct NodeComputeExec {
625 backends: boatramp_core::compute::BackendRegistry,
626 deploy: boatramp_core::deploy::DeployStore,
627}
628
629impl NodeComputeExec {
630 pub fn new(
634 backends: boatramp_core::compute::BackendRegistry,
635 deploy: boatramp_core::deploy::DeployStore,
636 ) -> Self {
637 Self { backends, deploy }
638 }
639}
640
641#[async_trait::async_trait]
642impl boatramp_core::compute::ComputeExec for NodeComputeExec {
643 async fn exec(
644 &self,
645 project: &str,
646 workload: &str,
647 argv: &[String],
648 stdin: Option<&[u8]>,
649 ) -> Result<boatramp_core::compute::ExecOutput, boatramp_core::compute::ExecError> {
650 use boatramp_core::compute::{BackendError, ExecError, ReplicaPhase};
651 use boatramp_core::project::ProjectRef;
652 let states = self
653 .deploy
654 .list_replica_states(ProjectRef::new(project), workload)
655 .await
656 .map_err(|e| ExecError::Other(e.to_string()))?;
657 let target = states
660 .iter()
661 .find(|s| s.phase == ReplicaPhase::Running && s.healthy)
662 .or_else(|| states.iter().find(|s| s.phase == ReplicaPhase::Running))
663 .ok_or_else(|| ExecError::NoReplica(workload.to_string()))?;
664 let backend = self
665 .backends
666 .get(&target.backend)
667 .ok_or_else(|| ExecError::Unsupported(target.backend.clone()))?;
668 match backend.exec(&target.handle, argv, stdin).await {
669 Ok(out) => Ok(out),
670 Err(BackendError::Unsupported) => Err(ExecError::Unsupported(target.backend.clone())),
671 Err(e) => Err(ExecError::Other(e.to_string())),
672 }
673 }
674}
675
676pub struct NodeComputeControl {
683 backends: boatramp_core::compute::BackendRegistry,
684 deploy: boatramp_core::deploy::DeployStore,
685}
686
687impl NodeComputeControl {
688 pub fn new(
691 backends: boatramp_core::compute::BackendRegistry,
692 deploy: boatramp_core::deploy::DeployStore,
693 ) -> Self {
694 Self { backends, deploy }
695 }
696}
697
698#[async_trait::async_trait]
699impl boatramp_core::compute::ComputeControl for NodeComputeControl {
700 async fn restart(
701 &self,
702 project: &str,
703 workload: &str,
704 replica: u32,
705 ) -> Result<bool, boatramp_core::compute::ControlError> {
706 use boatramp_core::compute::{BackendError, ControlError};
707 use boatramp_core::project::ProjectRef;
708 let pref = ProjectRef::new(project);
709 let states = self
710 .deploy
711 .list_replica_states(pref, workload)
712 .await
713 .map_err(|e| ControlError::Other(e.to_string()))?;
714 let Some(target) = states.iter().find(|s| s.handle.replica == replica) else {
715 return Ok(false);
716 };
717 let backend = self
718 .backends
719 .get(&target.backend)
720 .ok_or_else(|| ControlError::Unsupported(target.backend.clone()))?;
721 match backend.stop(&target.handle).await {
725 Ok(()) => {}
726 Err(BackendError::Unsupported) => {
727 return Err(ControlError::Unsupported(target.backend.clone()))
728 }
729 Err(e) => return Err(ControlError::Other(e.to_string())),
730 }
731 self.deploy
732 .delete_replica_state(pref, workload, replica)
733 .await
734 .map_err(|e| ControlError::Other(e.to_string()))?;
735 Ok(true)
736 }
737}
738
739pub struct NodeComputeVolumes {
748 backends: boatramp_core::compute::BackendRegistry,
749 deploy: boatramp_core::deploy::DeployStore,
750}
751
752impl NodeComputeVolumes {
753 pub fn new(
755 backends: boatramp_core::compute::BackendRegistry,
756 deploy: boatramp_core::deploy::DeployStore,
757 ) -> Self {
758 Self { backends, deploy }
759 }
760
761 async fn referenced_volume_names(
768 &self,
769 ) -> Result<std::collections::BTreeSet<String>, boatramp_core::compute::VolumeError> {
770 use boatramp_core::compute::VolumeError;
771 let mut names = std::collections::BTreeSet::new();
772 let workloads = self
773 .deploy
774 .list_compute_workloads_all()
775 .await
776 .map_err(|e| VolumeError::Other(e.to_string()))?;
777 for (_project, workload) in workloads {
778 let spec = self
779 .deploy
780 .get_compute_spec(&workload.active)
781 .await
782 .map_err(|e| VolumeError::Other(e.to_string()))?;
783 if let Some(spec) = spec {
784 for vol in spec.volumes {
785 names.insert(vol.name);
786 }
787 }
788 }
789 Ok(names)
790 }
791}
792
793#[async_trait::async_trait]
794impl boatramp_core::compute::ComputeVolumes for NodeComputeVolumes {
795 async fn list(
796 &self,
797 ) -> Result<Vec<boatramp_core::compute::VolumeStatus>, boatramp_core::compute::VolumeError>
798 {
799 use boatramp_core::compute::{VolumeError, VolumeStatus};
800 let referenced = self.referenced_volume_names().await?;
801 let mut by_name: std::collections::BTreeMap<String, u64> =
805 std::collections::BTreeMap::new();
806 for backend in self.backends.values() {
807 let vols = backend
808 .list_volumes()
809 .await
810 .map_err(|e| VolumeError::Other(e.to_string()))?;
811 for v in vols {
812 let slot = by_name.entry(v.name).or_insert(0);
814 *slot = (*slot).max(v.size_bytes);
815 }
816 }
817 Ok(by_name
818 .into_iter()
819 .map(|(name, size_bytes)| VolumeStatus {
820 in_use: referenced.contains(&name),
821 info: boatramp_core::compute::VolumeInfo { name, size_bytes },
822 })
823 .collect())
824 }
825
826 async fn remove(
827 &self,
828 name: &str,
829 force: bool,
830 ) -> Result<bool, boatramp_core::compute::VolumeError> {
831 use boatramp_core::compute::{BackendError, VolumeError};
832 if !force && self.referenced_volume_names().await?.contains(name) {
836 return Err(VolumeError::InUse(name.to_string()));
837 }
838 let mut existed = false;
841 let mut any_supported = false;
842 for backend in self.backends.values() {
843 match backend.remove_volume(name).await {
844 Ok(removed) => {
845 any_supported = true;
846 existed |= removed;
847 }
848 Err(BackendError::Unsupported) => {}
849 Err(e) => return Err(VolumeError::Other(e.to_string())),
850 }
851 }
852 if !any_supported {
853 return Err(VolumeError::Unsupported);
854 }
855 Ok(existed)
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use async_trait::async_trait;
863 use boatramp_core::compute::{
864 Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, ComputeVolumes,
865 ComputeWorkload, Health, Instance, InstanceHandle, IsolationClass, IsolationRequirement,
866 LaunchRequest, RestartPolicy, RootSource, VolumeError, VolumeInfo, VolumeRef,
867 };
868 use boatramp_core::deploy::DeployStore;
869 use boatramp_core::project::ProjectRef;
870 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
871 use std::collections::BTreeMap;
872 use std::sync::{Arc, Mutex};
873
874 struct NullStorage;
877 #[async_trait]
878 impl Storage for NullStorage {
879 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
880 Err(StorageError::NotFound(String::new()))
881 }
882 async fn get_range(
883 &self,
884 _: &str,
885 _: u64,
886 _: Option<u64>,
887 ) -> Result<GetObject, StorageError> {
888 Err(StorageError::unsupported("range"))
889 }
890 async fn put(
891 &self,
892 _: &str,
893 _: ByteStream,
894 _: PutMeta,
895 ) -> Result<ObjectMeta, StorageError> {
896 Err(StorageError::unsupported("put"))
897 }
898 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
899 Err(StorageError::NotFound(String::new()))
900 }
901 async fn delete(&self, _: &str) -> Result<(), StorageError> {
902 Ok(())
903 }
904 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
905 Ok(Vec::new())
906 }
907 }
908
909 struct FakeVolumeBackend {
912 vols: Mutex<BTreeMap<String, u64>>,
913 }
914 impl FakeVolumeBackend {
915 fn with(names: &[(&str, u64)]) -> Self {
916 Self {
917 vols: Mutex::new(names.iter().map(|(n, s)| (n.to_string(), *s)).collect()),
918 }
919 }
920 }
921 #[async_trait]
922 impl ComputeBackend for FakeVolumeBackend {
923 fn id(&self) -> &'static str {
924 "container"
925 }
926 fn capabilities(&self) -> Capabilities {
927 Capabilities {
928 isolation: IsolationClass::Namespace,
929 scale_to_zero: false,
930 persistent_volumes: true,
931 max_vcpus: None,
932 max_mem_mib: None,
933 }
934 }
935 async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
936 Err(BackendError::Unsupported)
937 }
938 async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
939 Err(BackendError::Unsupported)
940 }
941 async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
942 Ok(())
943 }
944 async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
945 Ok(Health::Unknown)
946 }
947 async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
948 Ok(self
949 .vols
950 .lock()
951 .unwrap()
952 .iter()
953 .map(|(name, size)| VolumeInfo {
954 name: name.clone(),
955 size_bytes: *size,
956 })
957 .collect())
958 }
959 async fn remove_volume(&self, name: &str) -> Result<bool, BackendError> {
960 Ok(self.vols.lock().unwrap().remove(name).is_some())
961 }
962 }
963
964 fn spec_with_volume(vol: Option<&str>) -> ComputeSpec {
965 ComputeSpec {
966 version: 1,
967 root: RootSource::Image("img".into()),
968 kernel: String::new(),
969 kernel_cmdline: None,
970 vcpus: 1,
971 mem_mib: 64,
972 entrypoint: vec![],
973 env: BTreeMap::new(),
974 port: 8080,
975 restart: RestartPolicy::Always,
976 startup_grace_secs: 30,
977 scale_to_zero: false,
978 volumes: vol
979 .map(|n| {
980 vec![VolumeRef {
981 mount: "/data".into(),
982 name: n.into(),
983 size_mib: 128,
984 }]
985 })
986 .unwrap_or_default(),
987 writable_root: false,
988 cap_add: vec![],
989 user: None,
990 isolation: IsolationRequirement::Trusted,
991 prefer_backend: None,
992 bindings: vec![],
993 }
994 }
995
996 async fn setup(referenced: Option<&str>, backend_vols: &[(&str, u64)]) -> NodeComputeVolumes {
1000 let store = DeployStore::new(
1001 Arc::new(NullStorage),
1002 Arc::new(boatramp_core::kv::MemoryKv::new()),
1003 );
1004 let spec = spec_with_volume(referenced);
1005 let hash = store.put_compute_spec(&spec).await.expect("put spec");
1006 let workload = ComputeWorkload {
1007 version: 1,
1008 name: "wl".into(),
1009 active: hash,
1010 replicas: 1,
1011 placement: Default::default(),
1012 };
1013 store
1014 .set_compute_workload(ProjectRef::DEFAULT, &workload)
1015 .await
1016 .expect("set workload");
1017 let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1018 backends.insert(
1019 "container".into(),
1020 Arc::new(FakeVolumeBackend::with(backend_vols)) as Arc<dyn ComputeBackend>,
1021 );
1022 NodeComputeVolumes::new(backends, store)
1023 }
1024
1025 #[tokio::test]
1026 async fn list_flags_referenced_volume_in_use_and_orphan_free() {
1027 let vols = setup(Some("data"), &[("data", 100), ("old", 50)]).await;
1029 let listed = vols.list().await.expect("list");
1030 assert_eq!(listed.len(), 2);
1031 let data = listed.iter().find(|v| v.info.name == "data").unwrap();
1032 let old = listed.iter().find(|v| v.info.name == "old").unwrap();
1033 assert!(data.in_use, "spec-referenced volume is in use");
1034 assert_eq!(data.info.size_bytes, 100);
1035 assert!(!old.in_use, "unreferenced volume is orphaned");
1036 assert_eq!(old.info.size_bytes, 50);
1037 }
1038
1039 #[tokio::test]
1040 async fn remove_refuses_in_use_without_force_and_allows_with_force() {
1041 let vols = setup(Some("data"), &[("data", 100)]).await;
1042 assert!(matches!(
1044 vols.remove("data", false).await,
1045 Err(VolumeError::InUse(n)) if n == "data"
1046 ));
1047 assert!(vols
1049 .list()
1050 .await
1051 .unwrap()
1052 .iter()
1053 .any(|v| v.info.name == "data"));
1054 assert!(vols.remove("data", true).await.expect("forced remove"));
1056 assert!(vols.list().await.unwrap().is_empty());
1057 }
1058
1059 #[tokio::test]
1060 async fn remove_orphan_succeeds_and_absent_reports_false() {
1061 let vols = setup(None, &[("old", 50)]).await;
1063 assert!(vols.remove("old", false).await.expect("remove orphan"));
1064 assert!(!vols.remove("gone", false).await.expect("remove absent"));
1066 }
1067
1068 struct AdoptSpyBackend {
1077 adopted: Mutex<Vec<(String, String, u32, std::net::Ipv4Addr)>>,
1078 }
1079 #[async_trait]
1080 impl ComputeBackend for AdoptSpyBackend {
1081 fn id(&self) -> &'static str {
1082 "container"
1083 }
1084 fn capabilities(&self) -> Capabilities {
1085 Capabilities {
1086 isolation: IsolationClass::Namespace,
1087 scale_to_zero: false,
1088 persistent_volumes: true,
1089 max_vcpus: None,
1090 max_mem_mib: None,
1091 }
1092 }
1093 async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
1094 Err(BackendError::Unsupported)
1095 }
1096 async fn reserve_in_use(&self, replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {
1097 self.adopted.lock().unwrap().extend_from_slice(replicas);
1098 }
1099 async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
1100 Err(BackendError::Unsupported)
1101 }
1102 async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
1103 Ok(())
1104 }
1105 async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
1106 Ok(Health::Unknown)
1107 }
1108 }
1109
1110 #[tokio::test]
1111 async fn adopt_running_replica_ips_feeds_each_backends_in_use_addresses() {
1112 use boatramp_core::compute::{Endpoint, ObservedInstance, ReplicaPhase, Scheme};
1113 use std::net::Ipv4Addr;
1114
1115 let store = DeployStore::new(
1116 Arc::new(NullStorage),
1117 Arc::new(boatramp_core::kv::MemoryKv::new()),
1118 );
1119 let mk = |proj: &str, wl: &str, rep: u32, backend: &str, ip: &str, phase: ReplicaPhase| {
1124 (
1125 proj.to_string(),
1126 ObservedInstance {
1127 handle: InstanceHandle {
1128 project: proj.into(),
1129 workload: wl.into(),
1130 replica: rep,
1131 backend_ref: format!("{ip}:5432"),
1132 },
1133 node: 1,
1134 backend: backend.into(),
1135 endpoint: Endpoint {
1136 scheme: Scheme::Http,
1137 host: ip.into(),
1138 port: 5432,
1139 },
1140 region: None,
1141 healthy: true,
1142 started_at: None,
1143 phase,
1144 snapshot: None,
1145 },
1146 )
1147 };
1148 for (proj, st) in [
1149 mk(
1150 "default",
1151 "pg-a",
1152 0,
1153 "container",
1154 "10.0.0.2",
1155 ReplicaPhase::Running,
1156 ),
1157 mk(
1160 "acme",
1161 "web",
1162 0,
1163 "container",
1164 "10.0.0.3",
1165 ReplicaPhase::Zero,
1166 ), mk(
1168 "default",
1169 "vm",
1170 0,
1171 "vmm-embedded",
1172 "10.0.0.9",
1173 ReplicaPhase::Running,
1174 ),
1175 ] {
1176 store
1177 .set_replica_state(ProjectRef::new(&proj), &st)
1178 .await
1179 .expect("persist replica state");
1180 }
1181
1182 let container = Arc::new(AdoptSpyBackend {
1183 adopted: Mutex::new(Vec::new()),
1184 });
1185 let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1186 backends.insert(
1187 "container".into(),
1188 container.clone() as Arc<dyn ComputeBackend>,
1189 );
1190
1191 adopt_running_replica_ips(&store, &backends).await;
1192
1193 let got = container.adopted.lock().unwrap().clone();
1194 assert!(got.contains(&(
1198 "default".into(),
1199 "pg-a".into(),
1200 0,
1201 Ipv4Addr::new(10, 0, 0, 2)
1202 )));
1203 assert!(got.contains(&("acme".into(), "web".into(), 0, Ipv4Addr::new(10, 0, 0, 3))));
1204 assert!(
1205 !got.iter()
1206 .any(|(_, _, _, ip)| *ip == Ipv4Addr::new(10, 0, 0, 9)),
1207 "another backend's replica IP must not be adopted by the container backend"
1208 );
1209 assert_eq!(got.len(), 2);
1210 }
1211}