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 .with_ephemeral_timeout(std::time::Duration::from_secs(
200 cfg.docker_ephemeral_timeout_secs,
201 ));
202 if docker.reachable().await {
203 backends.insert("docker".to_string(), std::sync::Arc::new(docker));
204 } else {
205 tracing::debug!("no reachable docker daemon; skipping docker backend");
206 }
207 }
208 Err(e) => tracing::debug!(%e, "docker backend unavailable"),
209 }
210
211 #[cfg(target_os = "linux")]
223 let shared_ip_authority: Option<boatramp_core::ipam::IpAuthority> =
224 match boatramp_core::ipam::IpAuthority::new(&cfg.subnet) {
225 Ok(a) => Some(a),
226 Err(e) => {
227 tracing::warn!(%e, subnet = %cfg.subnet, "bad compute subnet; container + embedded-VMM backends disabled");
228 None
229 }
230 };
231 #[cfg(target_os = "linux")]
232 let bridge_ready = match &shared_ip_authority {
233 Some(authority) => {
234 match boatramp_container::ensure_bridge(
235 &cfg.bridge,
236 authority.gateway(),
237 authority.prefix_len(),
238 )
239 .await
240 {
241 Ok(()) => true,
242 Err(e) => {
243 tracing::warn!(%e, bridge = %cfg.bridge, "could not create the compute bridge (need CAP_NET_ADMIN); container + embedded-VMM backends disabled");
244 false
245 }
246 }
247 }
248 None => false,
249 };
250
251 #[cfg(target_os = "linux")]
253 if bridge_ready {
254 match worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())) {
255 Ok(self_exe) => match boatramp_container::ContainerBackend::new(
256 storage.clone(),
257 data_dir.to_path_buf(),
258 cfg.bridge.clone(),
259 &cfg.subnet,
260 self_exe,
261 ) {
262 Ok(c) => {
263 let c = c.with_cap_add_allowed(!strict);
266 let c = c.with_internal_dns(cfg.internal_dns.then(|| cfg.dns_domain.clone()));
271 let c = match &shared_ip_authority {
274 Some(a) => c.with_ip_authority(a.clone()),
275 None => c,
276 };
277 let c = c.with_client_image_rootfs(cfg.client_image_rootfs.clone());
281 let c = c.with_require_pinned_client_image(strict);
286 c.sweep_orphaned_ephemeral_cgroups();
288 backends.insert("container".to_string(), std::sync::Arc::new(c));
289 }
290 Err(e) => tracing::warn!(%e, "container backend unavailable"),
291 },
292 Err(e) => tracing::warn!(%e, "current_exe for container backend"),
293 }
294 }
295 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
302 if bridge_ready && std::path::Path::new("/dev/kvm").exists() {
303 match (
304 worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
305 boatramp_core::ipam::IpPool::new(&cfg.subnet),
306 ) {
307 (Ok(self_exe), Ok(pool)) => {
308 let gateway = pool.gateway().to_string();
309 let verifier: Arc<dyn boatramp_firecracker::KernelVerifier> =
311 Arc::new(PostureKernelVerifier {
312 strict,
313 signing_keys: cfg.kernel_signing_pubkeys.clone(),
314 allowed_hashes: cfg.kernel_allowed_hashes.clone(),
315 daemon: daemon.clone(),
316 });
317 match boatramp_firecracker::EmbeddedVmmBackend::new(
318 storage.clone(),
319 self_exe, data_dir.to_path_buf(),
321 cfg.bridge.clone(),
322 gateway,
323 &cfg.subnet,
324 verifier,
325 ) {
326 Ok(vmm) => {
327 let vmm = match &shared_ip_authority {
331 Some(a) => vmm.with_ip_authority(a.clone()),
332 None => vmm,
333 };
334 backends.insert("vmm-embedded".to_string(), std::sync::Arc::new(vmm));
335 }
336 Err(e) => tracing::warn!(%e, "embedded VMM backend unavailable"),
337 }
338 }
339 (Err(e), _) => tracing::warn!(%e, "current_exe for VMM backend"),
340 (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for VMM backend"),
341 }
342 } else {
343 tracing::debug!("no /dev/kvm; skipping embedded VMM backend");
344 }
345
346 #[cfg(target_os = "macos")]
352 if macos_supports_vz() {
353 match (
354 worker_exe.map_or_else(std::env::current_exe, |p| Ok(p.to_path_buf())),
355 boatramp_core::ipam::IpPool::new(&cfg.subnet),
356 ) {
357 (Ok(self_exe), Ok(_pool)) => {
358 let verifier: Arc<dyn boatramp_vz::KernelVerifier> =
359 Arc::new(VzPostureKernelVerifier {
360 strict,
361 signing_keys: cfg.kernel_signing_pubkeys.clone(),
362 allowed_hashes: cfg.kernel_allowed_hashes.clone(),
363 daemon: daemon.clone(),
364 });
365 match boatramp_vz::VzBackend::new(
366 storage.clone(),
367 self_exe, data_dir.to_path_buf(),
369 &cfg.subnet, verifier,
371 ) {
372 Ok(vz) => {
374 let vz = vz.with_writable_root_allowed(!strict);
380 backends.insert("vmm-vz".to_string(), std::sync::Arc::new(vz));
381 }
382 Err(e) => tracing::warn!(%e, "macOS VMM backend unavailable"),
383 }
384 }
385 (Err(e), _) => tracing::warn!(%e, "current_exe for macOS VMM backend"),
386 (_, Err(e)) => tracing::warn!(%e, "bad compute subnet for macOS VMM backend"),
387 }
388 } else {
389 tracing::debug!("not Apple silicon + macOS 15+; skipping macOS VMM backend");
390 }
391
392 let _ = (&storage, data_dir); #[cfg(not(any(all(target_os = "linux", target_arch = "x86_64"), target_os = "macos")))]
397 let _ = (strict, &daemon);
398
399 let free_vcpus = if cfg.vcpus > 0 {
400 cfg.vcpus
401 } else {
402 std::thread::available_parallelism()
403 .map(|n| n.get() as u32)
404 .unwrap_or(1)
405 };
406 let free_mem_mib = if cfg.mem_mib > 0 { cfg.mem_mib } else { 1024 };
407 let advertised: Vec<BackendKind> = backends
408 .iter()
409 .map(|(id, b)| {
410 let caps = b.capabilities();
411 BackendKind {
412 id: id.clone(),
413 isolation: caps.isolation,
414 persistent_volumes: caps.persistent_volumes,
415 scale_to_zero: caps.scale_to_zero,
416 }
417 })
418 .collect();
419 tracing::info!(backends = ?advertised, free_vcpus, free_mem_mib, "compute node inventory");
420 let node = Node {
421 id: node_id,
422 region: cfg.region.clone(),
423 labels: std::collections::BTreeMap::new(),
424 free_vcpus,
425 free_mem_mib,
426 backends: advertised,
427 };
428 (backends, node)
429}
430
431pub async fn adopt_running_replica_ips(
446 deploy: &boatramp_core::deploy::DeployStore,
447 backends: &boatramp_core::compute::BackendRegistry,
448) {
449 use std::collections::BTreeMap;
450 use std::net::Ipv4Addr;
451
452 let states = match deploy.list_all_replica_states().await {
453 Ok(s) => s,
454 Err(e) => {
455 tracing::warn!(%e, "could not read replica states for IP adoption; \
456 the reconcile loop starts without adopting in-use IPs");
457 return;
458 }
459 };
460 let mut by_backend: BTreeMap<String, Vec<(String, String, u32, Ipv4Addr)>> = BTreeMap::new();
467 for st in &states {
468 let ip = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
469 st.handle
470 .backend_ref
471 .split(':')
472 .next()
473 .and_then(|s| s.parse::<Ipv4Addr>().ok())
474 });
475 if let Some(ip) = ip {
476 by_backend.entry(st.backend.clone()).or_default().push((
477 st.handle.project.clone(),
478 st.handle.workload.clone(),
479 st.handle.replica,
480 ip,
481 ));
482 }
483 }
484 for (backend_id, replicas) in by_backend {
485 if let Some(backend) = backends.get(&backend_id) {
486 backend.reserve_in_use(&replicas).await;
487 tracing::info!(
488 backend = %backend_id,
489 count = replicas.len(),
490 "adopted in-use compute IPs into the backend pool"
491 );
492 }
493 }
494}
495
496#[cfg(target_os = "linux")]
505pub fn spawn_internal_dns(
506 cfg: Option<&crate::config::ComputeConfig>,
507 backends: &boatramp_core::compute::BackendRegistry,
508 deploy: &boatramp_core::deploy::DeployStore,
509) -> Option<tokio::task::JoinHandle<()>> {
510 let cfg = cfg?;
511 if !cfg.internal_dns || !backends.contains_key("container") {
514 return None;
515 }
516 let gateway = match boatramp_core::ipam::IpPool::new(&cfg.subnet) {
517 Ok(pool) => pool.gateway(),
518 Err(e) => {
519 tracing::warn!(%e, subnet = %cfg.subnet, "internal DNS: bad compute subnet; resolver not started");
520 return None;
521 }
522 };
523 let upstream: std::net::SocketAddr = match cfg.dns_upstream.parse() {
524 Ok(a) => a,
525 Err(e) => {
526 tracing::warn!(%e, upstream = %cfg.dns_upstream, "internal DNS: bad dns_upstream (want host:port); resolver not started");
527 return None;
528 }
529 };
530 let source: std::sync::Arc<dyn boatramp_container::dns_server::InternalDnsSource> =
531 std::sync::Arc::new(DeployDnsSource::new(deploy.clone()));
532 let domain = cfg.dns_domain.clone();
533 Some(tokio::spawn(async move {
534 if let Err(e) =
535 boatramp_container::dns_server::serve(gateway, upstream, domain, source).await
536 {
537 tracing::warn!(%e, "internal DNS resolver exited (bind/setup error); \
538 guests keep their static resolv.conf peers");
539 }
540 }))
541}
542
543#[cfg(not(target_os = "linux"))]
545pub fn spawn_internal_dns(
546 _cfg: Option<&crate::config::ComputeConfig>,
547 _backends: &boatramp_core::compute::BackendRegistry,
548 _deploy: &boatramp_core::deploy::DeployStore,
549) -> Option<tokio::task::JoinHandle<()>> {
550 None
551}
552
553#[cfg(target_os = "linux")]
563pub struct DeployDnsSource {
564 deploy: boatramp_core::deploy::DeployStore,
565}
566
567#[cfg(target_os = "linux")]
568impl DeployDnsSource {
569 pub fn new(deploy: boatramp_core::deploy::DeployStore) -> Self {
571 Self { deploy }
572 }
573}
574
575#[cfg(target_os = "linux")]
576#[async_trait::async_trait]
577impl boatramp_container::dns_server::InternalDnsSource for DeployDnsSource {
578 async fn snapshot(&self) -> boatramp_container::dns_server::DnsFleet {
579 use boatramp_container::dns::ResolvedAddrs;
580 use boatramp_container::dns_server::DnsFleet;
581 use boatramp_core::compute::ReplicaPhase;
582 use std::net::Ipv4Addr;
583
584 let mut fleet = DnsFleet::default();
585 let states = match self.deploy.list_all_replica_states().await {
586 Ok(s) => s,
587 Err(e) => {
588 tracing::warn!(%e, "internal DNS: could not read replica states; \
591 answering forward-only this query");
592 return fleet;
593 }
594 };
595 for st in &states {
596 let v4 = st.endpoint.host.parse::<Ipv4Addr>().ok().or_else(|| {
599 st.handle
600 .backend_ref
601 .split(':')
602 .next()
603 .and_then(|s| s.parse::<Ipv4Addr>().ok())
604 });
605 let Some(ip) = v4 else { continue };
606 let key = (st.handle.project.clone(), st.handle.workload.clone());
607 fleet.owners.insert(ip, key.clone());
612 if st.phase == ReplicaPhase::Running && st.healthy && st.readiness.is_provisioned() {
628 fleet
629 .addrs
630 .entry(key)
631 .or_insert_with(ResolvedAddrs::default)
632 .v4
633 .push(ip);
634 }
635 }
636 fleet
637 }
638}
639
640pub struct NodeComputeExec {
648 backends: boatramp_core::compute::BackendRegistry,
649 deploy: boatramp_core::deploy::DeployStore,
650}
651
652impl NodeComputeExec {
653 pub fn new(
657 backends: boatramp_core::compute::BackendRegistry,
658 deploy: boatramp_core::deploy::DeployStore,
659 ) -> Self {
660 Self { backends, deploy }
661 }
662}
663
664#[async_trait::async_trait]
665impl boatramp_core::compute::ComputeExec for NodeComputeExec {
666 async fn exec(
667 &self,
668 project: &str,
669 workload: &str,
670 argv: &[String],
671 stdin: Option<&[u8]>,
672 ) -> Result<boatramp_core::compute::ExecOutput, boatramp_core::compute::ExecError> {
673 use boatramp_core::compute::{BackendError, ExecError, ReplicaPhase};
674 use boatramp_core::project::ProjectRef;
675 let states = self
676 .deploy
677 .list_replica_states(ProjectRef::new(project), workload)
678 .await
679 .map_err(|e| ExecError::Other(e.to_string()))?;
680 let target = states
683 .iter()
684 .find(|s| s.phase == ReplicaPhase::Running && s.healthy)
685 .or_else(|| states.iter().find(|s| s.phase == ReplicaPhase::Running))
686 .ok_or_else(|| ExecError::NoReplica(workload.to_string()))?;
687 let backend = self
688 .backends
689 .get(&target.backend)
690 .ok_or_else(|| ExecError::Unsupported(target.backend.clone()))?;
691 match backend.exec(&target.handle, argv, stdin).await {
692 Ok(out) => Ok(out),
693 Err(BackendError::Unsupported) => Err(ExecError::Unsupported(target.backend.clone())),
694 Err(e) => Err(ExecError::Other(e.to_string())),
695 }
696 }
697}
698
699pub struct NodeComputeControl {
706 backends: boatramp_core::compute::BackendRegistry,
707 deploy: boatramp_core::deploy::DeployStore,
708}
709
710impl NodeComputeControl {
711 pub fn new(
714 backends: boatramp_core::compute::BackendRegistry,
715 deploy: boatramp_core::deploy::DeployStore,
716 ) -> Self {
717 Self { backends, deploy }
718 }
719}
720
721#[async_trait::async_trait]
722impl boatramp_core::compute::ComputeControl for NodeComputeControl {
723 async fn restart(
724 &self,
725 project: &str,
726 workload: &str,
727 replica: u32,
728 ) -> Result<bool, boatramp_core::compute::ControlError> {
729 use boatramp_core::compute::{BackendError, ControlError};
730 use boatramp_core::project::ProjectRef;
731 let pref = ProjectRef::new(project);
732 let states = self
733 .deploy
734 .list_replica_states(pref, workload)
735 .await
736 .map_err(|e| ControlError::Other(e.to_string()))?;
737 let Some(target) = states.iter().find(|s| s.handle.replica == replica) else {
738 return Ok(false);
739 };
740 let backend = self
741 .backends
742 .get(&target.backend)
743 .ok_or_else(|| ControlError::Unsupported(target.backend.clone()))?;
744 match backend.stop(&target.handle).await {
748 Ok(()) => {}
749 Err(BackendError::Unsupported) => {
750 return Err(ControlError::Unsupported(target.backend.clone()));
751 }
752 Err(e) => return Err(ControlError::Other(e.to_string())),
753 }
754 self.deploy
755 .delete_replica_state(pref, workload, replica)
756 .await
757 .map_err(|e| ControlError::Other(e.to_string()))?;
758 Ok(true)
759 }
760}
761
762pub struct NodeComputeVolumes {
771 backends: boatramp_core::compute::BackendRegistry,
772 deploy: boatramp_core::deploy::DeployStore,
773}
774
775impl NodeComputeVolumes {
776 pub fn new(
778 backends: boatramp_core::compute::BackendRegistry,
779 deploy: boatramp_core::deploy::DeployStore,
780 ) -> Self {
781 Self { backends, deploy }
782 }
783
784 async fn referenced_volume_names(
791 &self,
792 ) -> Result<std::collections::BTreeSet<String>, boatramp_core::compute::VolumeError> {
793 use boatramp_core::compute::VolumeError;
794 let mut names = std::collections::BTreeSet::new();
795 let workloads = self
796 .deploy
797 .list_compute_workloads_all()
798 .await
799 .map_err(|e| VolumeError::Other(e.to_string()))?;
800 for (_project, workload) in workloads {
801 let spec = self
802 .deploy
803 .get_compute_spec(&workload.active)
804 .await
805 .map_err(|e| VolumeError::Other(e.to_string()))?;
806 if let Some(spec) = spec {
807 for vol in spec.volumes {
808 names.insert(vol.name);
809 }
810 }
811 }
812 Ok(names)
813 }
814}
815
816#[async_trait::async_trait]
817impl boatramp_core::compute::ComputeVolumes for NodeComputeVolumes {
818 async fn list(
819 &self,
820 ) -> Result<Vec<boatramp_core::compute::VolumeStatus>, boatramp_core::compute::VolumeError>
821 {
822 use boatramp_core::compute::{VolumeError, VolumeStatus};
823 let referenced = self.referenced_volume_names().await?;
824 let mut by_name: std::collections::BTreeMap<String, u64> =
828 std::collections::BTreeMap::new();
829 for backend in self.backends.values() {
830 let vols = backend
831 .list_volumes()
832 .await
833 .map_err(|e| VolumeError::Other(e.to_string()))?;
834 for v in vols {
835 let slot = by_name.entry(v.name).or_insert(0);
837 *slot = (*slot).max(v.size_bytes);
838 }
839 }
840 Ok(by_name
841 .into_iter()
842 .map(|(name, size_bytes)| VolumeStatus {
843 in_use: referenced.contains(&name),
844 info: boatramp_core::compute::VolumeInfo { name, size_bytes },
845 })
846 .collect())
847 }
848
849 async fn remove(
850 &self,
851 name: &str,
852 force: bool,
853 ) -> Result<bool, boatramp_core::compute::VolumeError> {
854 use boatramp_core::compute::{BackendError, VolumeError};
855 if !force && self.referenced_volume_names().await?.contains(name) {
859 return Err(VolumeError::InUse(name.to_string()));
860 }
861 let mut existed = false;
864 let mut any_supported = false;
865 for backend in self.backends.values() {
866 match backend.remove_volume(name).await {
867 Ok(removed) => {
868 any_supported = true;
869 existed |= removed;
870 }
871 Err(BackendError::Unsupported) => {}
872 Err(e) => return Err(VolumeError::Other(e.to_string())),
873 }
874 }
875 if !any_supported {
876 return Err(VolumeError::Unsupported);
877 }
878 Ok(existed)
879 }
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885 use async_trait::async_trait;
886 use boatramp_core::compute::{
887 Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, ComputeVolumes,
888 ComputeWorkload, Health, Instance, InstanceHandle, IsolationClass, IsolationRequirement,
889 LaunchRequest, RestartPolicy, RootSource, VolumeError, VolumeInfo, VolumeRef,
890 };
891 use boatramp_core::deploy::DeployStore;
892 use boatramp_core::project::ProjectRef;
893 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
894 use std::collections::BTreeMap;
895 use std::sync::{Arc, Mutex};
896
897 struct NullStorage;
900 #[async_trait]
901 impl Storage for NullStorage {
902 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
903 Err(StorageError::NotFound(String::new()))
904 }
905 async fn get_range(
906 &self,
907 _: &str,
908 _: u64,
909 _: Option<u64>,
910 ) -> Result<GetObject, StorageError> {
911 Err(StorageError::unsupported("range"))
912 }
913 async fn put(
914 &self,
915 _: &str,
916 _: ByteStream,
917 _: PutMeta,
918 ) -> Result<ObjectMeta, StorageError> {
919 Err(StorageError::unsupported("put"))
920 }
921 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
922 Err(StorageError::NotFound(String::new()))
923 }
924 async fn delete(&self, _: &str) -> Result<(), StorageError> {
925 Ok(())
926 }
927 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
928 Ok(Vec::new())
929 }
930 }
931
932 struct FakeVolumeBackend {
935 vols: Mutex<BTreeMap<String, u64>>,
936 }
937 impl FakeVolumeBackend {
938 fn with(names: &[(&str, u64)]) -> Self {
939 Self {
940 vols: Mutex::new(names.iter().map(|(n, s)| (n.to_string(), *s)).collect()),
941 }
942 }
943 }
944 #[async_trait]
945 impl ComputeBackend for FakeVolumeBackend {
946 fn id(&self) -> &'static str {
947 "container"
948 }
949 fn capabilities(&self) -> Capabilities {
950 Capabilities {
951 isolation: IsolationClass::Namespace,
952 scale_to_zero: false,
953 persistent_volumes: true,
954 max_vcpus: None,
955 max_mem_mib: None,
956 }
957 }
958 async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
959 Err(BackendError::Unsupported)
960 }
961 async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
962 Err(BackendError::Unsupported)
963 }
964 async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
965 Ok(())
966 }
967 async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
968 Ok(Health::Unknown)
969 }
970 async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
971 Ok(self
972 .vols
973 .lock()
974 .unwrap()
975 .iter()
976 .map(|(name, size)| VolumeInfo {
977 name: name.clone(),
978 size_bytes: *size,
979 })
980 .collect())
981 }
982 async fn remove_volume(&self, name: &str) -> Result<bool, BackendError> {
983 Ok(self.vols.lock().unwrap().remove(name).is_some())
984 }
985 }
986
987 fn spec_with_volume(vol: Option<&str>) -> ComputeSpec {
988 ComputeSpec {
989 version: 1,
990 root: RootSource::Image("img".into()),
991 kernel: String::new(),
992 kernel_cmdline: None,
993 vcpus: 1,
994 mem_mib: 64,
995 entrypoint: vec![],
996 env: BTreeMap::new(),
997 port: 8080,
998 restart: RestartPolicy::Always,
999 startup_grace_secs: 30,
1000 scale_to_zero: false,
1001 volumes: vol
1002 .map(|n| {
1003 vec![VolumeRef {
1004 mount: "/data".into(),
1005 name: n.into(),
1006 size_mib: 128,
1007 }]
1008 })
1009 .unwrap_or_default(),
1010 writable_root: false,
1011 cap_add: vec![],
1012 user: None,
1013 isolation: IsolationRequirement::Trusted,
1014 prefer_backend: None,
1015 bindings: vec![],
1016 post_launch: vec![],
1017 }
1018 }
1019
1020 async fn setup(referenced: Option<&str>, backend_vols: &[(&str, u64)]) -> NodeComputeVolumes {
1024 let store = DeployStore::new(
1025 Arc::new(NullStorage),
1026 Arc::new(boatramp_core::kv::MemoryKv::new()),
1027 );
1028 let spec = spec_with_volume(referenced);
1029 let hash = store.put_compute_spec(&spec).await.expect("put spec");
1030 let workload = ComputeWorkload {
1031 version: 1,
1032 name: "wl".into(),
1033 active: hash,
1034 replicas: 1,
1035 placement: Default::default(),
1036 };
1037 store
1038 .set_compute_workload(ProjectRef::DEFAULT, &workload)
1039 .await
1040 .expect("set workload");
1041 let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1042 backends.insert(
1043 "container".into(),
1044 Arc::new(FakeVolumeBackend::with(backend_vols)) as Arc<dyn ComputeBackend>,
1045 );
1046 NodeComputeVolumes::new(backends, store)
1047 }
1048
1049 #[tokio::test]
1050 async fn list_flags_referenced_volume_in_use_and_orphan_free() {
1051 let vols = setup(Some("data"), &[("data", 100), ("old", 50)]).await;
1053 let listed = vols.list().await.expect("list");
1054 assert_eq!(listed.len(), 2);
1055 let data = listed.iter().find(|v| v.info.name == "data").unwrap();
1056 let old = listed.iter().find(|v| v.info.name == "old").unwrap();
1057 assert!(data.in_use, "spec-referenced volume is in use");
1058 assert_eq!(data.info.size_bytes, 100);
1059 assert!(!old.in_use, "unreferenced volume is orphaned");
1060 assert_eq!(old.info.size_bytes, 50);
1061 }
1062
1063 #[tokio::test]
1064 async fn remove_refuses_in_use_without_force_and_allows_with_force() {
1065 let vols = setup(Some("data"), &[("data", 100)]).await;
1066 assert!(matches!(
1068 vols.remove("data", false).await,
1069 Err(VolumeError::InUse(n)) if n == "data"
1070 ));
1071 assert!(
1073 vols.list()
1074 .await
1075 .unwrap()
1076 .iter()
1077 .any(|v| v.info.name == "data")
1078 );
1079 assert!(vols.remove("data", true).await.expect("forced remove"));
1081 assert!(vols.list().await.unwrap().is_empty());
1082 }
1083
1084 #[tokio::test]
1085 async fn remove_orphan_succeeds_and_absent_reports_false() {
1086 let vols = setup(None, &[("old", 50)]).await;
1088 assert!(vols.remove("old", false).await.expect("remove orphan"));
1089 assert!(!vols.remove("gone", false).await.expect("remove absent"));
1091 }
1092
1093 struct AdoptSpyBackend {
1102 adopted: Mutex<Vec<(String, String, u32, std::net::Ipv4Addr)>>,
1103 }
1104 #[async_trait]
1105 impl ComputeBackend for AdoptSpyBackend {
1106 fn id(&self) -> &'static str {
1107 "container"
1108 }
1109 fn capabilities(&self) -> Capabilities {
1110 Capabilities {
1111 isolation: IsolationClass::Namespace,
1112 scale_to_zero: false,
1113 persistent_volumes: true,
1114 max_vcpus: None,
1115 max_mem_mib: None,
1116 }
1117 }
1118 async fn materialize(&self, _: &ComputeSpec) -> Result<Artifact, BackendError> {
1119 Err(BackendError::Unsupported)
1120 }
1121 async fn reserve_in_use(&self, replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {
1122 self.adopted.lock().unwrap().extend_from_slice(replicas);
1123 }
1124 async fn launch(&self, _: &LaunchRequest) -> Result<Instance, BackendError> {
1125 Err(BackendError::Unsupported)
1126 }
1127 async fn stop(&self, _: &InstanceHandle) -> Result<(), BackendError> {
1128 Ok(())
1129 }
1130 async fn health(&self, _: &InstanceHandle) -> Result<Health, BackendError> {
1131 Ok(Health::Unknown)
1132 }
1133 }
1134
1135 #[tokio::test]
1136 async fn adopt_running_replica_ips_feeds_each_backends_in_use_addresses() {
1137 use boatramp_core::compute::{Endpoint, ObservedInstance, ReplicaPhase, Scheme};
1138 use std::net::Ipv4Addr;
1139
1140 let store = DeployStore::new(
1141 Arc::new(NullStorage),
1142 Arc::new(boatramp_core::kv::MemoryKv::new()),
1143 );
1144 let mk = |proj: &str, wl: &str, rep: u32, backend: &str, ip: &str, phase: ReplicaPhase| {
1149 (
1150 proj.to_string(),
1151 ObservedInstance {
1152 handle: InstanceHandle {
1153 project: proj.into(),
1154 workload: wl.into(),
1155 replica: rep,
1156 backend_ref: format!("{ip}:5432"),
1157 },
1158 node: 1,
1159 backend: backend.into(),
1160 endpoint: Endpoint {
1161 scheme: Scheme::Http,
1162 host: ip.into(),
1163 port: 5432,
1164 },
1165 region: None,
1166 healthy: true,
1167 started_at: None,
1168 phase,
1169 readiness: boatramp_core::compute::Readiness::Provisioned,
1170 snapshot: None,
1171 },
1172 )
1173 };
1174 for (proj, st) in [
1175 mk(
1176 "default",
1177 "pg-a",
1178 0,
1179 "container",
1180 "10.0.0.2",
1181 ReplicaPhase::Running,
1182 ),
1183 mk(
1186 "acme",
1187 "web",
1188 0,
1189 "container",
1190 "10.0.0.3",
1191 ReplicaPhase::Zero,
1192 ), mk(
1194 "default",
1195 "vm",
1196 0,
1197 "vmm-embedded",
1198 "10.0.0.9",
1199 ReplicaPhase::Running,
1200 ),
1201 ] {
1202 store
1203 .set_replica_state(ProjectRef::new(&proj), &st)
1204 .await
1205 .expect("persist replica state");
1206 }
1207
1208 let container = Arc::new(AdoptSpyBackend {
1209 adopted: Mutex::new(Vec::new()),
1210 });
1211 let mut backends: boatramp_core::compute::BackendRegistry = BTreeMap::new();
1212 backends.insert(
1213 "container".into(),
1214 container.clone() as Arc<dyn ComputeBackend>,
1215 );
1216
1217 adopt_running_replica_ips(&store, &backends).await;
1218
1219 let got = container.adopted.lock().unwrap().clone();
1220 assert!(got.contains(&(
1224 "default".into(),
1225 "pg-a".into(),
1226 0,
1227 Ipv4Addr::new(10, 0, 0, 2)
1228 )));
1229 assert!(got.contains(&("acme".into(), "web".into(), 0, Ipv4Addr::new(10, 0, 0, 3))));
1230 assert!(
1231 !got.iter()
1232 .any(|(_, _, _, ip)| *ip == Ipv4Addr::new(10, 0, 0, 9)),
1233 "another backend's replica IP must not be adopted by the container backend"
1234 );
1235 assert_eq!(got.len(), 2);
1236 }
1237}