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
151#[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 worker_exe: Option<&std::path::Path>,
170 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 #[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 match boatramp_docker::DockerBackend::connect() {
201 Ok(docker) => {
202 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 #[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 #[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 let c = c.with_cap_add_allowed(!strict);
278 let c = c.with_internal_dns(cfg.internal_dns.then(|| cfg.dns_domain.clone()));
283 let c = match &shared_ip_authority {
286 Some(a) => c.with_ip_authority(a.clone()),
287 None => c,
288 };
289 let c = c.with_client_image_rootfs(cfg.client_image_rootfs.clone());
293 let c = c.with_require_pinned_client_image(strict);
298 let c = c.with_legacy_data_claimant(legacy_data_claimant.clone());
303 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 #[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 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, data_dir.to_path_buf(),
338 cfg.bridge.clone(),
339 gateway,
340 &cfg.subnet,
341 verifier,
342 ) {
343 Ok(vmm) => {
344 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 #[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, data_dir.to_path_buf(),
386 &cfg.subnet, verifier,
388 ) {
389 Ok(vz) => {
391 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); #[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
448pub 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 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#[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 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#[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#[cfg(target_os = "linux")]
580pub struct DeployDnsSource {
581 deploy: boatramp_core::deploy::DeployStore,
582}
583
584#[cfg(target_os = "linux")]
585impl DeployDnsSource {
586 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 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 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 fleet.owners.insert(ip, key.clone());
629 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
657pub struct NodeComputeExec {
665 backends: boatramp_core::compute::BackendRegistry,
666 deploy: boatramp_core::deploy::DeployStore,
667}
668
669impl NodeComputeExec {
670 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 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
716pub struct NodeComputeControl {
723 backends: boatramp_core::compute::BackendRegistry,
724 deploy: boatramp_core::deploy::DeployStore,
725}
726
727impl NodeComputeControl {
728 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 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
779pub struct NodeComputeVolumes {
788 backends: boatramp_core::compute::BackendRegistry,
789 deploy: boatramp_core::deploy::DeployStore,
790}
791
792impl NodeComputeVolumes {
793 pub fn new(
795 backends: boatramp_core::compute::BackendRegistry,
796 deploy: boatramp_core::deploy::DeployStore,
797 ) -> Self {
798 Self { backends, deploy }
799 }
800
801 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 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 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 if !force && self.referenced_volume_names().await?.contains(name) {
876 return Err(VolumeError::InUse(name.to_string()));
877 }
878 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 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 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 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 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 assert!(matches!(
1090 vols.remove("data", false).await,
1091 Err(VolumeError::InUse(n)) if n == "data"
1092 ));
1093 assert!(
1095 vols.list()
1096 .await
1097 .unwrap()
1098 .iter()
1099 .any(|v| v.info.name == "data")
1100 );
1101 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 let vols = setup(None, &[("old", 50)]).await;
1110 assert!(vols.remove("old", false).await.expect("remove orphan"));
1111 assert!(!vols.remove("gone", false).await.expect("remove absent"));
1113 }
1114
1115 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 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 mk(
1214 "acme",
1215 "web",
1216 0,
1217 "container",
1218 "10.0.0.3",
1219 ReplicaPhase::Zero,
1220 ), 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 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}