diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 08201c9d..aee8ce06 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -54,7 +54,7 @@ pub async fn add_subcommand( names: Vec, image: String, memory: Option, - cpus: Option, + cpus: Option, volumes: Vec, ports: Vec, envs: Vec, @@ -219,7 +219,7 @@ pub async fn script_run_subcommand( #[allow(clippy::too_many_arguments)] pub async fn exe_subcommand( name: String, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, @@ -472,7 +472,7 @@ pub async fn self_subcommand(action: SelfAction) -> MicrosandboxCliResult<()> { pub async fn install_subcommand( name: String, alias: Option, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, diff --git a/microsandbox-cli/bin/msbrun.rs b/microsandbox-cli/bin/msbrun.rs index 4f99b693..bf70166e 100644 --- a/microsandbox-cli/bin/msbrun.rs +++ b/microsandbox-cli/bin/msbrun.rs @@ -17,7 +17,7 @@ //! --log-level=3 \ //! --native-rootfs=/path/to/rootfs \ //! --overlayfs-rootfs=/path/to/rootfs \ -//! --num-vcpus=2 \ +//! --num-vcpus=0.5 \ //! --memory-mib=1024 \ //! --workdir-path=/app \ //! --exec-path=/usr/bin/python3 \ @@ -41,7 +41,7 @@ //! --log-level=3 \ //! --native-rootfs=/path/to/rootfs \ //! --overlayfs-rootfs=/path/to/rootfs \ -//! --num-vcpus=2 \ +//! --num-vcpus=0.5 \ //! --memory-mib=1024 \ //! --workdir-path=/app \ //! --exec-path=/usr/bin/python3 \ @@ -256,6 +256,7 @@ async fn main() -> Result<()> { log_dir.clone(), rootfs.clone(), forward_output, + num_vcpus, ) .await?; diff --git a/microsandbox-cli/bin/msbserver.rs b/microsandbox-cli/bin/msbserver.rs index 62afd843..170b72fc 100644 --- a/microsandbox-cli/bin/msbserver.rs +++ b/microsandbox-cli/bin/msbserver.rs @@ -41,11 +41,20 @@ pub async fn main() -> MicrosandboxCliResult<()> { args.dev_mode, )?); - // Get namespace directory from config + // Get namespace directory and port range from config let namespace_dir = config.get_namespace_dir().clone(); + let port_range = ( + config.get_port_range_min().as_ref().copied(), + config.get_port_range_max().as_ref().copied(), + ); - // Initialize the port manager - let port_manager = PortManager::new(namespace_dir).await.map_err(|e| { + // Initialize the port manager with the configured port range + let port_manager = if let (Some(min), Some(max)) = port_range { + PortManager::new_with_range(namespace_dir, Some((min, max))).await + } else { + PortManager::new(namespace_dir).await + } + .map_err(|e| { eprintln!("Error initializing port manager: {}", e); e })?; diff --git a/microsandbox-cli/lib/args/msb.rs b/microsandbox-cli/lib/args/msb.rs index 33d27f52..cf1179b3 100644 --- a/microsandbox-cli/lib/args/msb.rs +++ b/microsandbox-cli/lib/args/msb.rs @@ -78,7 +78,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Volume mappings, format: : #[arg(short, long = "volume", name = "VOLUME")] @@ -290,7 +290,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Memory in MB #[arg(long)] @@ -342,7 +342,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Memory in MB #[arg(long)] diff --git a/microsandbox-cli/lib/args/msbrun.rs b/microsandbox-cli/lib/args/msbrun.rs index cb75e747..c22f14cb 100644 --- a/microsandbox-cli/lib/args/msbrun.rs +++ b/microsandbox-cli/lib/args/msbrun.rs @@ -35,9 +35,9 @@ pub enum McrunSubcommand { #[arg(long)] overlayfs_layer: Vec, - /// Number of virtual CPUs + /// Number of virtual CPUs (supports fractional values) #[arg(long)] - num_vcpus: Option, + num_vcpus: Option, /// Memory size in MiB #[arg(long)] @@ -119,9 +119,9 @@ pub enum McrunSubcommand { #[arg(long)] overlayfs_layer: Vec, - /// Number of virtual CPUs + /// Number of virtual CPUs (supports fractional values) #[arg(long)] - num_vcpus: Option, + num_vcpus: Option, /// Memory size in MiB #[arg(long)] diff --git a/microsandbox-core/lib/config/microsandbox/builder.rs b/microsandbox-core/lib/config/microsandbox/builder.rs index 7080b6f4..fa24eedf 100644 --- a/microsandbox-core/lib/config/microsandbox/builder.rs +++ b/microsandbox-core/lib/config/microsandbox/builder.rs @@ -58,7 +58,7 @@ pub struct SandboxBuilder { meta: Option, image: I, memory: Option, - cpus: Option, + cpus: Option, volumes: Vec, ports: Vec, envs: Vec, @@ -163,7 +163,7 @@ impl SandboxBuilder { } /// Sets the maximum number of CPUs allowed for the sandbox - pub fn cpus(mut self, cpus: u8) -> SandboxBuilder { + pub fn cpus(mut self, cpus: f32) -> SandboxBuilder { self.cpus = Some(cpus); self } diff --git a/microsandbox-core/lib/config/microsandbox/config.rs b/microsandbox-core/lib/config/microsandbox/config.rs index e7224edd..1250fc72 100644 --- a/microsandbox-core/lib/config/microsandbox/config.rs +++ b/microsandbox-core/lib/config/microsandbox/config.rs @@ -130,10 +130,11 @@ pub struct Build { #[builder(default, setter(strip_option))] pub(crate) memory: Option, - /// The number of vCPUs to use. + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0 #[serde(skip_serializing_if = "Option::is_none", default)] #[builder(default, setter(strip_option))] - pub(crate) cpus: Option, + pub(crate) cpus: Option, /// The volumes to mount. #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -242,9 +243,10 @@ pub struct Sandbox { #[serde(skip_serializing_if = "Option::is_none", default)] pub(crate) memory: Option, - /// The number of vCPUs to use. + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0 #[serde(skip_serializing_if = "Option::is_none", default)] - pub(crate) cpus: Option, + pub(crate) cpus: Option, /// The volumes to mount. #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -633,7 +635,7 @@ mod tests { let sandbox = sandboxes.get("test_sandbox").unwrap(); assert_eq!(sandbox.version.as_ref().unwrap().to_string(), "1.0.0"); assert_eq!(sandbox.memory.unwrap(), 1024); - assert_eq!(sandbox.cpus.unwrap(), 2); + assert_eq!(sandbox.cpus.unwrap(), 2.0); assert_eq!(sandbox.volumes[0].to_string(), "./src:/app/src"); assert_eq!(sandbox.ports[0].to_string(), "8080:80"); assert_eq!(sandbox.envs[0].to_string(), "DEBUG=true"); @@ -718,7 +720,7 @@ mod tests { let builds = &config.builds; let base_build = builds.get("base_build").unwrap(); assert_eq!(base_build.memory.unwrap(), 2048); - assert_eq!(base_build.cpus.unwrap(), 2); + assert_eq!(base_build.cpus.unwrap(), 2.0); assert_eq!( base_build.workdir.as_ref().unwrap(), &Utf8UnixPathBuf::from("/build") @@ -742,7 +744,7 @@ mod tests { let api = sandboxes.get("api").unwrap(); assert_eq!(api.version.as_ref().unwrap().to_string(), "1.0.0"); assert_eq!(api.memory.unwrap(), 1024); - assert_eq!(api.cpus.unwrap(), 1); + assert_eq!(api.cpus.unwrap(), 1.0); assert_eq!(api.depends_on, vec!["database", "cache"]); assert_eq!(api.scope, NetworkScope::Public); } @@ -795,4 +797,50 @@ mod tests { "#; assert!(serde_yaml::from_str::(yaml).is_err()); } + + #[test] + fn microsandbox_config_accepts_fractional_cpus_in_sandbox_and_build() { + let yaml = r#" + builds: + base: + image: "alpine:latest" + cpus: 0.5 + sandboxes: + svc: + image: "alpine:latest" + shell: "/bin/sh" + cpus: 0.25 + "#; + + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + let base = config.get_build("base").unwrap(); + assert_eq!(base.cpus, Some(0.5)); + let svc = config.get_sandbox("svc").unwrap(); + assert_eq!(svc.cpus, Some(0.25)); + } + + #[test] + fn microsandbox_config_missing_cpus_is_none() { + let yaml = r#" + sandboxes: + a: + image: "alpine:latest" + shell: "/bin/sh" + "#; + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + assert!(config.get_sandbox("a").unwrap().cpus.is_none()); + } + + #[test] + fn microsandbox_config_integer_cpus_still_supported() { + let yaml = r#" + sandboxes: + b: + image: "alpine:latest" + shell: "/bin/sh" + cpus: 2 + "#; + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.get_sandbox("b").unwrap().cpus, Some(2.0)); + } } diff --git a/microsandbox-core/lib/management/config.rs b/microsandbox-core/lib/management/config.rs index 21846397..879f0617 100644 --- a/microsandbox-core/lib/management/config.rs +++ b/microsandbox-core/lib/management/config.rs @@ -35,7 +35,7 @@ pub struct SandboxConfig { pub memory: Option, /// The number of CPUs to use. - pub cpus: Option, + pub cpus: Option, /// The volumes to mount. pub volumes: Vec, @@ -168,7 +168,7 @@ pub async fn add( } if let Some(cpus_value) = config.cpus { - sandbox_mapping.insert_u32("cpus", cpus_value); + sandbox_mapping.insert_f32("cpus", cpus_value); } // Add shell (default if not provided) diff --git a/microsandbox-core/lib/management/home.rs b/microsandbox-core/lib/management/home.rs index cc6c3c86..4b2e237a 100644 --- a/microsandbox-core/lib/management/home.rs +++ b/microsandbox-core/lib/management/home.rs @@ -147,7 +147,7 @@ pub async fn clean(force: bool) -> MicrosandboxResult<()> { /// &image, /// Some("shell"), // Run shell script /// Some("ubuntu-shell"), // Custom alias -/// Some(2), // 2 CPUs +/// Some(2.0), // 2 CPUs /// Some(1024), // 1GB RAM /// vec![ // Mount host's /tmp to sandbox's /data /// "/tmp:/data".to_string() @@ -172,7 +172,7 @@ pub async fn install( image: &Reference, script: Option<&str>, alias: Option<&str>, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, diff --git a/microsandbox-core/lib/management/sandbox.rs b/microsandbox-core/lib/management/sandbox.rs index 4d782898..e0a8727a 100644 --- a/microsandbox-core/lib/management/sandbox.rs +++ b/microsandbox-core/lib/management/sandbox.rs @@ -421,7 +421,7 @@ pub async fn prepare_run( /// sandbox::run_temp( /// &image, /// Some("start"), // Script name -/// Some(2), // 2 CPUs +/// Some(2.0), // 2 CPUs /// Some(1024), // 1GB RAM /// vec![ // Mount host's /tmp to sandbox's /data /// "/tmp:/data".to_string() @@ -445,7 +445,7 @@ pub async fn prepare_run( pub async fn run_temp( image: &Reference, script: Option<&str>, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, diff --git a/microsandbox-core/lib/runtime/monitor.rs b/microsandbox-core/lib/runtime/monitor.rs index 180adeb3..abdb3ff9 100644 --- a/microsandbox-core/lib/runtime/monitor.rs +++ b/microsandbox-core/lib/runtime/monitor.rs @@ -60,6 +60,13 @@ pub struct MicroVmMonitor { /// Whether to forward output to stdout/stderr forward_output: bool, + + /// The requested number of CPUs, if provided + num_vcpus: Option, + + /// The cgroup name used for throttling (Linux only) + #[cfg(target_os = "linux")] + cgroup_name: Option, } //-------------------------------------------------------------------------------------------------- @@ -78,6 +85,7 @@ impl MicroVmMonitor { log_dir: impl Into, rootfs: Rootfs, forward_output: bool, + num_vcpus: Option, ) -> MicrosandboxResult { Ok(Self { supervisor_pid, @@ -90,6 +98,9 @@ impl MicroVmMonitor { rootfs, original_term: None, forward_output, + num_vcpus, + #[cfg(target_os = "linux")] + cgroup_name: None, }) } @@ -113,6 +124,94 @@ impl MicroVmMonitor { // Place the log file inside that directory with the sandbox name config_dir.join(format!("{}.{}", self.sandbox_name, LOG_SUFFIX)) } + + #[cfg(target_os = "linux")] + fn build_cgroup_name(&self) -> String { + format!( + "{}_{}", + sanitize_cgroup_segment(&self.config_file), + sanitize_cgroup_segment(&self.sandbox_name) + ) + } + + fn maybe_apply_cpu_quota(&mut self, microvm_pid: u32) { + let Some(num_vcpus) = self.num_vcpus else { + return; + }; + + if num_vcpus >= 1.0 { + return; + } + + #[cfg(target_os = "linux")] + { + if !crate::vm::has_cgroup_v2() { + // TODO: consider emitting a structured event for monitoring instead of a log. + tracing::warn!( + "cgroups v2 not available; skipping CPU throttle for {}", + self.sandbox_name + ); + return; + } + + let cgroup_name = self.build_cgroup_name(); + match crate::vm::apply_cpu_quota(microvm_pid, num_vcpus, &cgroup_name) { + Ok(quota) => tracing::info!( + "applied cgroup CPU quota: cpus={:.2}, quota_us={}, period_us={}", + num_vcpus, + quota.quota_us, + quota.period_us + ), + Err(e) => tracing::warn!( + error = %e, + "failed to apply cgroup CPU quota for {}", + self.sandbox_name + ), + } + self.cgroup_name = Some(cgroup_name); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = microvm_pid; + // TODO: consider surfacing this as a user-facing warning in non-Linux environments. + tracing::warn!( + "CPU throttling via cgroups is only available on Linux; skipping for {}", + self.sandbox_name + ); + } + } + + fn maybe_cleanup_cgroup(&mut self) { + #[cfg(target_os = "linux")] + { + let Some(cgroup_name) = self.cgroup_name.take() else { + return; + }; + + if !crate::vm::has_cgroup_v2() { + return; + } + + if let Err(e) = crate::vm::cleanup_cgroup(&cgroup_name) { + tracing::warn!(error = %e, "failed to cleanup cgroup {}", cgroup_name); + } + } + } +} + +#[cfg(target_os = "linux")] +fn sanitize_cgroup_segment(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() } //-------------------------------------------------------------------------------------------------- @@ -163,6 +262,8 @@ impl ProcessMonitor for MicroVmMonitor { .await .map_err(MicrosandboxUtilsError::custom)?; + self.maybe_apply_cpu_quota(microvm_pid); + match child_io { ChildIo::Piped { stdin, @@ -318,6 +419,8 @@ impl ProcessMonitor for MicroVmMonitor { // Restore terminal settings if they were modified self.restore_terminal_settings(); + self.maybe_cleanup_cgroup(); + // Update sandbox status to stopped db::update_sandbox_status( &self.sandbox_db, @@ -338,5 +441,6 @@ impl ProcessMonitor for MicroVmMonitor { impl Drop for MicroVmMonitor { fn drop(&mut self) { self.restore_terminal_settings(); + self.maybe_cleanup_cgroup(); } } diff --git a/microsandbox-core/lib/vm/builder.rs b/microsandbox-core/lib/vm/builder.rs index f9e8a677..45e1b33d 100644 --- a/microsandbox-core/lib/vm/builder.rs +++ b/microsandbox-core/lib/vm/builder.rs @@ -38,7 +38,7 @@ use super::{ pub struct MicroVmConfigBuilder { log_level: LogLevel, rootfs: R, - num_vcpus: u8, + num_vcpus: f32, memory_mib: u32, mapped_dirs: Vec, port_map: Vec, @@ -89,7 +89,7 @@ pub struct MicroVmConfigBuilder { /// let vm = MicroVmBuilder::default() /// .log_level(LogLevel::Debug) /// .rootfs(Rootfs::Native(PathBuf::from("/tmp"))) -/// .num_vcpus(2) +/// .num_vcpus(2.0) /// .memory_mib(1024) /// .mapped_dirs(["/home:/guest/mount".parse()?]) /// .port_map(["8080:80".parse()?]) @@ -192,6 +192,7 @@ impl MicroVmConfigBuilder { /// Sets the number of virtual CPUs (vCPUs) for the MicroVm. /// /// This determines how many CPU cores are available to the guest system. + /// Supports fractional values (e.g., 0.5, 0.25) for CPU throttling. /// /// ## Examples /// @@ -199,14 +200,17 @@ impl MicroVmConfigBuilder { /// use microsandbox_core::vm::MicroVmConfigBuilder; /// /// let config = MicroVmConfigBuilder::default() - /// .num_vcpus(2); // Allocate 2 virtual CPU cores + /// .num_vcpus(2.0); // Allocate 2 virtual CPU cores + /// let config = MicroVmConfigBuilder::default() + /// .num_vcpus(0.5); // Allocate 0.5 CPU (50% of one core) /// ``` /// /// ## Notes - /// - The default is 1 vCPU if not specified - /// - The number of vCPUs should not exceed the host's physical CPU cores + /// - The default is 1.0 vCPU if not specified + /// - Valid range: 0.1 to 128.0 + /// - Fractional values (< 1.0) will be throttled using cgroups /// - More vCPUs aren't always better - consider the workload's needs - pub fn num_vcpus(mut self, num_vcpus: u8) -> Self { + pub fn num_vcpus(mut self, num_vcpus: f32) -> Self { self.num_vcpus = num_vcpus; self } @@ -275,11 +279,11 @@ impl MicroVmConfigBuilder { /// ## Examples /// /// ```rust - /// use microsandbox_core::vm::MicroVmConfigBuilder; + /// use microsandbox_core::vm::MicroVmBuilder; /// use microsandbox_core::config::PortPair; /// /// # fn main() -> anyhow::Result<()> { - /// let config = MicroVmConfigBuilder::default() + /// let vm = MicroVmBuilder::default() /// .port_map([ /// // Map host port 8080 to guest port 80 /// "8080:80".parse()?, @@ -654,7 +658,8 @@ impl MicroVmBuilder { /// Sets the number of virtual CPUs (vCPUs) for the MicroVm. /// - /// This determines how many CPU cores are available to the guest system. + /// This determines how many CPU cores are available to the guest system and + /// supports fractional CPU values where supported. /// /// ## Examples /// @@ -667,7 +672,7 @@ impl MicroVmBuilder { /// let vm = MicroVmBuilder::default() /// .rootfs(Rootfs::Native(temp_dir.path().to_path_buf())) /// .memory_mib(1024) - /// .num_vcpus(2) // Allocate 2 virtual CPU cores + /// .num_vcpus(2.0) // Allocate 2 virtual CPU cores /// .exec_path("/bin/echo") /// .build()?; /// # Ok(()) @@ -677,8 +682,11 @@ impl MicroVmBuilder { /// ## Notes /// - The default is 1 vCPU if not specified /// - More vCPUs aren't always better - consider the workload's needs - pub fn num_vcpus(mut self, num_vcpus: u8) -> Self { - self.inner = self.inner.num_vcpus(num_vcpus); + pub fn num_vcpus(mut self, num_vcpus: T) -> Self + where + T: Into, + { + self.inner = self.inner.num_vcpus(num_vcpus.into()); self } @@ -1134,7 +1142,7 @@ mod tests { let builder = MicroVmBuilder::default() .log_level(LogLevel::Debug) .rootfs(rootfs.clone()) - .num_vcpus(2) + .num_vcpus(2u8) .memory_mib(1024) .mapped_dirs(["/guest/mount:/host/mount".parse()?]) .port_map(["8080:80".parse()?]) @@ -1147,7 +1155,7 @@ mod tests { assert_eq!(builder.inner.log_level, LogLevel::Debug); assert_eq!(builder.inner.rootfs, rootfs); - assert_eq!(builder.inner.num_vcpus, 2); + assert_eq!(builder.inner.num_vcpus, 2.0); assert_eq!(builder.inner.memory_mib, 1024); assert_eq!( builder.inner.mapped_dirs, @@ -1199,3 +1207,34 @@ mod tests { Ok(()) } } + +#[cfg(test)] +mod fractional_cpu_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn microvm_config_builder_preserves_fractional_num_vcpus() { + let tmp = TempDir::new().unwrap(); + let cfg = MicroVmConfigBuilder::default() + .rootfs(Rootfs::Native(tmp.path().to_path_buf())) + .exec_path("/bin/echo") + .num_vcpus(0.5) + .memory_mib(256) + .build(); + + assert_eq!(cfg.num_vcpus, 0.5); + assert_eq!(cfg.memory_mib, 256); + } + + #[test] + fn microvm_config_builder_allows_smallest_practical_fraction() { + let tmp = TempDir::new().unwrap(); + let cfg = MicroVmConfigBuilder::default() + .rootfs(Rootfs::Native(tmp.path().to_path_buf())) + .exec_path("/bin/echo") + .num_vcpus(0.25) + .build(); + assert_eq!(cfg.num_vcpus, 0.25); + } +} diff --git a/microsandbox-core/lib/vm/cgroup.rs b/microsandbox-core/lib/vm/cgroup.rs new file mode 100644 index 00000000..63f4786d --- /dev/null +++ b/microsandbox-core/lib/vm/cgroup.rs @@ -0,0 +1,126 @@ +//! cgroups v2 helpers for runtime resource controls. + +use std::path::Path; + +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +use crate::MicrosandboxResult; + +/// Default cgroup v2 CPU period in microseconds. +pub const DEFAULT_CPU_PERIOD_US: u64 = 100_000; + +/// Computed CPU quota values for cgroups v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CpuQuota { + /// The quota in microseconds. + pub quota_us: u64, + /// The period in microseconds. + pub period_us: u64, +} + +/// Computes the cgroup v2 CPU quota for a fractional CPU request. +pub fn compute_cpu_quota(cpus: f32, period_us: u64) -> CpuQuota { + let quota = (period_us as f32 * cpus).round().max(1.0) as u64; + CpuQuota { + quota_us: quota, + period_us, + } +} + +/// Returns true if cgroups v2 appears to be available on the host. +pub fn has_cgroup_v2() -> bool { + Path::new("/sys/fs/cgroup/cgroup.controllers").exists() +} + +/// Applies a CPU quota to the process PID using cgroups v2. +/// +/// This creates a dedicated cgroup under `/sys/fs/cgroup/microsandbox/`, +/// writes the CPU limit to `cpu.max`, and moves the PID into `cgroup.procs`. +/// +#[cfg(target_os = "linux")] +pub fn apply_cpu_quota(pid: u32, cpus: f32, cgroup_name: &str) -> MicrosandboxResult { + let quota = compute_cpu_quota(cpus, DEFAULT_CPU_PERIOD_US); + let cgroup_path = cgroup_dir_path(cgroup_name); + + std::fs::create_dir_all(&cgroup_path)?; + std::fs::write(cgroup_path.join("cpu.max"), format!("{} {}", quota.quota_us, quota.period_us))?; + std::fs::write(cgroup_path.join("cgroup.procs"), pid.to_string())?; + + Ok(quota) +} + +/// Applies a CPU quota on non-Linux systems (no-op). +/// +/// TODO: define a clearer contract for non-Linux CPU throttling if it ever becomes available. +#[cfg(not(target_os = "linux"))] +pub fn apply_cpu_quota(_pid: u32, _cpus: f32, _cgroup_name: &str) -> MicrosandboxResult { + Ok(compute_cpu_quota(_cpus, DEFAULT_CPU_PERIOD_US)) +} + +#[cfg(target_os = "linux")] +fn cgroup_dir_path(cgroup_name: &str) -> PathBuf { + Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(cgroup_name) +} + +/// Removes the cgroup directory for a sandbox. +/// +/// TODO: consider tolerating EBUSY by retrying after moving the process out. +#[cfg(target_os = "linux")] +pub fn cleanup_cgroup(cgroup_name: &str) -> MicrosandboxResult<()> { + let cgroup_path = cgroup_dir_path(cgroup_name); + if cgroup_path.exists() { + std::fs::remove_dir_all(cgroup_path)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_quota_rounds_to_nearest() { + let quota = compute_cpu_quota(0.1, DEFAULT_CPU_PERIOD_US); + assert_eq!( + quota, + CpuQuota { + quota_us: 10_000, + period_us: 100_000 + } + ); + + let quota = compute_cpu_quota(0.5, DEFAULT_CPU_PERIOD_US); + assert_eq!( + quota, + CpuQuota { + quota_us: 50_000, + period_us: 100_000 + } + ); + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn apply_cpu_quota_is_noop_on_non_linux() { + let pid = std::process::id(); + let cgroup_name = format!("msb_noop_{}", pid); + let path = Path::new("/sys/fs/cgroup").join("microsandbox").join(&cgroup_name); + if path.exists() { + eprintln!("skipping: cgroup path exists on this host"); + return; + } + + let quota = apply_cpu_quota(pid, 0.5, &cgroup_name).expect("no-op should succeed"); + assert_eq!( + quota, + CpuQuota { + quota_us: 50_000, + period_us: 100_000 + } + ); + assert!(!path.exists()); + } +} diff --git a/microsandbox-core/lib/vm/microvm.rs b/microsandbox-core/lib/vm/microvm.rs index 1debc6a7..cc8be8d0 100644 --- a/microsandbox-core/lib/vm/microvm.rs +++ b/microsandbox-core/lib/vm/microvm.rs @@ -121,8 +121,9 @@ pub struct MicroVmConfig { /// The rootfs for the MicroVm. pub rootfs: Rootfs, - /// The number of vCPUs to use for the MicroVm. - pub num_vcpus: u8, + /// The number of vCPUs to use for the MicroVm (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0. Values less than 1.0 will be throttled using cgroups. + pub num_vcpus: f32, /// The amount of memory in MiB to use for the MicroVm. pub memory_mib: u32, @@ -312,12 +313,29 @@ impl MicroVm { assert!(status >= 0, "failed to set log level: {}", status); } + // Convert f32 CPU count to u8 for libkrun + // For fractional CPUs, we allocate at least 1 vCPU and use cgroups to throttle + let vcpus_for_krun = if config.num_vcpus >= 1.0 { + config.num_vcpus.ceil() as u8 + } else { + 1u8 // Minimum 1 vCPU for libkrun + }; + // Set basic VM configuration unsafe { - let status = ffi::krun_set_vm_config(ctx_id, config.num_vcpus, config.memory_mib); + let status = ffi::krun_set_vm_config(ctx_id, vcpus_for_krun, config.memory_mib); assert!(status >= 0, "failed to set VM config: {}", status); } + // Store the actual CPU request for later cgroup throttling + // This will be applied if the system supports cgroups v2 + if config.num_vcpus < 1.0 { + tracing::info!( + "CPU fractional value {:.2} detected, will apply cgroup throttling", + config.num_vcpus + ); + } + // Set rootfs. match &config.rootfs { Rootfs::Native(path) => { @@ -600,7 +618,7 @@ impl MicroVmConfig { } } - if self.num_vcpus == 0 { + if self.num_vcpus == 0.0 { return Err(MicrosandboxError::InvalidMicroVMConfig( InvalidMicroVMConfigError::NumVCPUsIsZero, )); diff --git a/microsandbox-core/lib/vm/mod.rs b/microsandbox-core/lib/vm/mod.rs index d099cbe4..d6fce073 100644 --- a/microsandbox-core/lib/vm/mod.rs +++ b/microsandbox-core/lib/vm/mod.rs @@ -1,6 +1,7 @@ //! Runtime management and configuration. mod builder; +mod cgroup; mod ffi; mod microvm; mod rlimit; @@ -10,6 +11,7 @@ mod rlimit; //-------------------------------------------------------------------------------------------------- pub use builder::*; +pub use cgroup::*; #[allow(unused)] pub use ffi::*; pub use microvm::*; diff --git a/microsandbox-core/tests/cgroup_integration.rs b/microsandbox-core/tests/cgroup_integration.rs new file mode 100644 index 00000000..0220f56d --- /dev/null +++ b/microsandbox-core/tests/cgroup_integration.rs @@ -0,0 +1,61 @@ +#![cfg(target_os = "linux")] + +use std::path::Path; + +use microsandbox_core::vm::{apply_cpu_quota, cleanup_cgroup, has_cgroup_v2}; +use microsandbox_core::MicrosandboxResult; + +#[test] +fn apply_cpu_quota_writes_cpu_max() -> MicrosandboxResult<()> { + if !has_cgroup_v2() { + eprintln!("skipping: cgroups v2 not available on this host"); + return Ok(()); + } + + let pid = std::process::id(); + let cgroup_name = format!("msb_test_{}", pid); + let result = apply_cpu_quota(pid, 0.5, &cgroup_name); + if let Err(e) = result { + eprintln!("skipping: failed to apply cpu quota ({})", e); + return Ok(()); + } + + let cpu_max_path = Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(&cgroup_name) + .join("cpu.max"); + let cpu_max = std::fs::read_to_string(cpu_max_path)?; + + assert!(cpu_max.starts_with("50000 100000")); + + // TODO: cleanup cgroup after validation if permissions allow removal. + Ok(()) +} + +#[test] +fn cleanup_cgroup_removes_directory() -> MicrosandboxResult<()> { + if !has_cgroup_v2() { + eprintln!("skipping: cgroups v2 not available on this host"); + return Ok(()); + } + + let pid = std::process::id(); + let cgroup_name = format!("msb_cleanup_{}", pid); + let cgroup_path = Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(&cgroup_name); + + let result = apply_cpu_quota(pid, 0.5, &cgroup_name); + if let Err(e) = result { + eprintln!("skipping: failed to apply cpu quota ({})", e); + return Ok(()); + } + + if let Err(e) = cleanup_cgroup(&cgroup_name) { + eprintln!("skipping: failed to cleanup cgroup ({})", e); + return Ok(()); + } + + assert!(!cgroup_path.exists()); + Ok(()) +} diff --git a/microsandbox-server/lib/handler.rs b/microsandbox-server/lib/handler.rs index 125d0a37..d953bd66 100644 --- a/microsandbox-server/lib/handler.rs +++ b/microsandbox-server/lib/handler.rs @@ -37,7 +37,7 @@ use crate::{ payload::{ JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse, JsonRpcResponseOrNotification, RegularMessageResponse, SandboxMetricsGetParams, - SandboxStartParams, SandboxStopParams, + SandboxStartParams, SandboxStopParams, SandboxConfig }, state::AppState, }; @@ -349,7 +349,7 @@ pub async fn forward_rpc_to_portal( /// Implementation for starting a sandbox pub async fn sandbox_start_impl( state: AppState, - params: SandboxStartParams, + mut params: SandboxStartParams, ) -> ServerResult { // Validate sandbox name and namespace validate_sandbox_name(¶ms.sandbox)?; @@ -399,6 +399,10 @@ pub async fn sandbox_start_impl( )); } + if let Some(config) = params.config.as_mut() { + adjust_config_for_platform(config); + } + // Load or create the config let mut config_yaml: serde_yaml::Value; @@ -714,6 +718,69 @@ pub async fn sandbox_start_impl( } } +fn adjust_config_for_platform(config: &mut SandboxConfig) { + if let Some(cpus) = config.cpus + && cpus < 1.0 + && !cfg!(target_os = "linux") + { + tracing::warn!("fractional CPUs are only supported on Linux; using cpus=1.0"); + config.cpus = Some(1.0); + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::adjust_config_for_platform; + use crate::payload::SandboxConfig; + use std::collections::HashMap; + + #[cfg(not(target_os = "linux"))] + #[test] + fn adjusts_fractional_cpus_to_one_on_non_linux() { + let mut config = SandboxConfig { + image: Some("alpine:latest".to_string()), + memory: None, + cpus: Some(0.1), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: HashMap::new(), + exec: None, + }; + + adjust_config_for_platform(&mut config); + assert_eq!(config.cpus, Some(1.0)); + } + + #[cfg(target_os = "linux")] + #[test] + fn keeps_fractional_cpus_on_linux() { + let mut config = SandboxConfig { + image: Some("alpine:latest".to_string()), + memory: None, + cpus: Some(0.1), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: HashMap::new(), + exec: None, + }; + + adjust_config_for_platform(&mut config); + assert_eq!(config.cpus, Some(0.1)); + } +} + /// Polls the sandbox until it's verified to be running async fn poll_sandbox_until_running( sandbox_name: &str, diff --git a/microsandbox-server/lib/payload.rs b/microsandbox-server/lib/payload.rs index fafe7eb1..e77e3b93 100644 --- a/microsandbox-server/lib/payload.rs +++ b/microsandbox-server/lib/payload.rs @@ -151,7 +151,7 @@ pub struct SandboxMetricsGetParams { /// Configuration for a sandbox /// Similar to microsandbox-core's Sandbox but with optional fields for update operations -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SandboxConfig { /// The image to use (optional for updates) pub image: Option, @@ -159,8 +159,8 @@ pub struct SandboxConfig { /// The amount of memory in MiB to use pub memory: Option, - /// The number of vCPUs to use - pub cpus: Option, + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25) + pub cpus: Option, /// The volumes to mount #[serde(default)] @@ -387,3 +387,73 @@ impl axum::response::IntoResponse for JsonRpcResponseOrNotification { } } } + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_sandbox_config_with_fractional_cpus() { + let json = r#"{ + "image": "python:3.11-slim", + "memory": 512, + "cpus": 0.5, + "volumes": [], + "ports": [], + "envs": [], + "depends_on": [] + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.cpus, Some(0.5)); + assert_eq!(cfg.memory, Some(512)); + assert_eq!(cfg.image.as_deref(), Some("python:3.11-slim")); + } + + #[test] + fn deserializes_sandbox_config_without_cpus_sets_none() { + let json = r#"{ + "image": "alpine:latest", + "memory": 256 + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.cpus.is_none()); + } + + #[test] + fn deserializes_sandbox_config_with_null_cpus_sets_none() { + let json = r#"{ + "image": "alpine:latest", + "memory": 256, + "cpus": null + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.cpus.is_none()); + } + + #[test] + fn serializes_sandbox_config_with_fractional_cpus() { + let cfg = SandboxConfig { + image: Some("node:20".to_string()), + memory: Some(1024), + cpus: Some(0.25), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: Default::default(), + exec: None, + }; + + let value = serde_json::to_value(&cfg).unwrap(); + assert_eq!(value.get("cpus").and_then(|v| v.as_f64()), Some(0.25)); + } +} diff --git a/microsandbox-server/lib/port.rs b/microsandbox-server/lib/port.rs index 631dd0b4..ee680937 100644 --- a/microsandbox-server/lib/port.rs +++ b/microsandbox-server/lib/port.rs @@ -64,6 +64,9 @@ pub struct PortManager { /// Path to the port mappings file file_path: PathBuf, + + /// Optional port range (min, max) for sandbox port allocation + port_range: Option<(u16, u16)>, } //-------------------------------------------------------------------------------------------------- @@ -158,12 +161,27 @@ impl BiPortMapping { impl PortManager { /// Create a new port manager pub async fn new(namespace_dir: impl AsRef) -> MicrosandboxServerResult { + Self::new_with_range(namespace_dir, None).await + } + + /// Create a new port manager with an optional port range + pub async fn new_with_range( + namespace_dir: impl AsRef, + port_range: Option<(u16, u16)>, + ) -> MicrosandboxServerResult { let file_path = namespace_dir.as_ref().join(PORTAL_PORTS_FILE); let mappings = Self::load_mappings(&file_path).await?; + if let Some((min, max)) = port_range { + info!("Port manager initialized with port range: {}-{}", min, max); + } else { + debug!("Port manager initialized with dynamic port allocation"); + } + Ok(Self { mappings, file_path, + port_range, }) } @@ -240,8 +258,8 @@ impl PortManager { // Get a lock to ensure only one thread gets a port at a time let _lock = PORT_ASSIGNMENT_LOCK.lock().await; - // Get a truly available port from the OS - let port = self.get_available_port_from_os()?; + // Get an available port (from range or from OS) + let port = self.get_available_port()?; // Save the mapping self.mappings.insert(key.to_string(), port); @@ -272,7 +290,34 @@ impl PortManager { TcpListener::bind(addr).is_ok() } - /// Get an available port from the OS + /// Get an available port from the OS or from the configured port range + fn get_available_port(&self) -> MicrosandboxServerResult { + // If a port range is configured, try to find an available port within it + if let Some((min, max)) = self.port_range { + debug!( + "Attempting to find an available port in range {}-{}", + min, max + ); + + for port in min..=max { + if self.verify_port_availability(port) { + debug!("Found available port {} in configured range", port); + return Ok(port); + } + } + + // If no port is available in the range, log a warning and try dynamic allocation + warn!( + "No available ports found in configured range {}-{}, falling back to OS allocation", + min, max + ); + } + + // Fall back to dynamic port allocation from the OS + self.get_available_port_from_os() + } + + /// Get a truly available port from the OS by binding to port 0 fn get_available_port_from_os(&self) -> MicrosandboxServerResult { // Bind to port 0 to let the OS assign an available port let addr = SocketAddr::new(LOCALHOST_IP, 0); diff --git a/microsandbox-utils/lib/defaults.rs b/microsandbox-utils/lib/defaults.rs index b8ac74f5..bd89e8dd 100644 --- a/microsandbox-utils/lib/defaults.rs +++ b/microsandbox-utils/lib/defaults.rs @@ -24,8 +24,8 @@ use crate::MICROSANDBOX_HOME_DIR; /// The default maximum log file size (10MB) pub const DEFAULT_LOG_MAX_SIZE: u64 = 10 * 1024 * 1024; -/// The default number of vCPUs to use for the MicroVm. -pub const DEFAULT_NUM_VCPUS: u8 = 1; +/// The default number of vCPUs to use for the MicroVm (supports fractional values). +pub const DEFAULT_NUM_VCPUS: f32 = 1.0; /// The default amount of memory in MiB to use for the MicroVm. pub const DEFAULT_MEMORY_MIB: u32 = 1024; @@ -77,3 +77,68 @@ pub const DEFAULT_SERVER_PORT: u16 = 5555; /// The default microsandbox-portal port. pub const DEFAULT_PORTAL_GUEST_PORT: u16 = 4444; + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_num_vcpus_is_fractional_one() { + assert_eq!(DEFAULT_NUM_VCPUS, 1.0f32); + } + + #[test] + fn default_memory_mib_is_1024() { + assert_eq!(DEFAULT_MEMORY_MIB, 1024u32); + } + + #[test] + fn default_microsandbox_home_points_to_user_home_dir() { + let home = dirs::home_dir().unwrap().join(MICROSANDBOX_HOME_DIR); + assert_eq!(*DEFAULT_MICROSANDBOX_HOME, home); + } + + #[test] + fn default_config_has_sandboxes_section() { + assert!(DEFAULT_CONFIG.contains("sandboxes:")); + } + + #[test] + fn default_shell_is_bin_sh() { + assert_eq!(DEFAULT_SHELL, "/bin/sh"); + } + + #[test] + fn default_server_namespace_is_default() { + assert_eq!(DEFAULT_SERVER_NAMESPACE, "default"); + } + + #[test] + fn default_server_host_and_port_match_expected() { + assert_eq!(DEFAULT_SERVER_HOST, "127.0.0.1"); + assert_eq!(DEFAULT_SERVER_PORT, 5555u16); + } + + #[test] + fn default_portal_guest_port_is_4444() { + assert_eq!(DEFAULT_PORTAL_GUEST_PORT, 4444u16); + } + + #[test] + fn default_msbrun_and_msbserver_paths_end_with_expected_binaries() { + let msbrun = DEFAULT_MSBRUN_EXE_PATH + .file_name() + .unwrap() + .to_string_lossy(); + let msbserver = DEFAULT_MSBSERVER_EXE_PATH + .file_name() + .unwrap() + .to_string_lossy(); + assert_eq!(msbrun, "msbrun"); + assert_eq!(msbserver, "msbserver"); + } +} diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..e2c908d4 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -20,6 +20,12 @@ pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; /// Environment variable for the msbserver binary path pub const MSBSERVER_EXE_ENV_VAR: &str = "MSBSERVER_EXE"; +/// Environment variable for the minimum port in the sandbox port range +pub const MICROSANDBOX_PORT_MIN_ENV_VAR: &str = "MICROSANDBOX_PORT_MIN"; + +/// Environment variable for the maximum port in the sandbox port range +pub const MICROSANDBOX_PORT_MAX_ENV_VAR: &str = "MICROSANDBOX_PORT_MAX"; + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- @@ -45,3 +51,20 @@ pub fn get_oci_registry() -> String { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the port range for sandbox port allocation. +/// If both MICROSANDBOX_PORT_MIN and MICROSANDBOX_PORT_MAX are set, +/// returns Some((min, max)). Otherwise, returns None for dynamic allocation. +pub fn get_sandbox_port_range() -> Option<(u16, u16)> { + let min = std::env::var(MICROSANDBOX_PORT_MIN_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + let max = std::env::var(MICROSANDBOX_PORT_MAX_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + + match (min, max) { + (Some(min_val), Some(max_val)) if min_val <= max_val => Some((min_val, max_val)), + _ => None, + } +} diff --git a/sdk/python/microsandbox/base_sandbox.py b/sdk/python/microsandbox/base_sandbox.py index 74da7aae..47f29fa1 100644 --- a/sdk/python/microsandbox/base_sandbox.py +++ b/sdk/python/microsandbox/base_sandbox.py @@ -9,12 +9,14 @@ from contextlib import asynccontextmanager from typing import Optional +import logging import aiohttp from dotenv import load_dotenv from .command import Command from .metrics import Metrics +logger = logging.getLogger(__name__) class BaseSandbox(ABC): """ @@ -75,6 +77,9 @@ async def create( namespace: str = "default", name: Optional[str] = None, api_key: Optional[str] = None, + cpus: Optional[float] = None, + memory: Optional[int] = None, + timeout: Optional[int] = None, ): """ Create and initialize a new sandbox as an async context manager. @@ -84,6 +89,9 @@ async def create( namespace: Namespace for the sandbox name: Optional name for the sandbox. If not provided, a random name will be generated. api_key: API key for Microsandbox server authentication. If not provided, it will be read from MSB_API_KEY environment variable. + cpus: Number of CPUs to allocate to the sandbox + memory: Amount of memory (in MB) to allocate to the sandbox + timeout: Maximum time in seconds to wait for the sandbox to start Returns: An instance of the sandbox ready for use @@ -106,7 +114,14 @@ async def create( # Create HTTP session sandbox._session = aiohttp.ClientSession() # Start the sandbox - await sandbox.start() + start_kwargs = {} + if cpus is not None: + start_kwargs["cpus"] = cpus + if memory is not None: + start_kwargs["memory"] = memory + if timeout is not None: + start_kwargs["timeout"] = timeout + await sandbox.start(**start_kwargs) yield sandbox finally: # Stop the sandbox @@ -129,7 +144,7 @@ async def start( Args: image: Docker image to use for the sandbox (defaults to language-specific image) memory: Memory limit in MB - cpus: CPU limit (will be rounded to nearest integer) + cpus: CPU limit (supports fractional values like 0.5) timeout: Maximum time in seconds to wait for the sandbox to start (default: 180 seconds) Raises: @@ -139,6 +154,16 @@ async def start( if self._is_started: return + if cpus < 1.0: + import platform + + if platform.system() != "Linux": + logger.warning( + "Fractional CPUs are only supported on Linux. " + "Using default cpus=1.0." + ) + cpus = 1.0 + sandbox_image = image or await self.get_default_image() request_data = { "jsonrpc": "2.0", @@ -149,7 +174,7 @@ async def start( "config": { "image": sandbox_image, "memory": memory, - "cpus": int(round(cpus)), + "cpus": cpus, }, }, "id": str(uuid.uuid4()), diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 3a90c61c..f76eef92 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=42", "wheel"] [project] name = "microsandbox" -version = "0.1.8" +version = "0.1.17" description = "Microsandbox Python SDK" readme = "README.md" requires-python = ">=3.8" @@ -26,3 +26,9 @@ dependencies = ["aiohttp>=3.10.0,<3.11.0", "python-dotenv"] [project.urls] Homepage = "https://github.com/microsandbox/microsandbox/" Repository = "https://github.com/microsandbox/microsandbox/" + +[tool.setuptools] +packages = ["microsandbox"] + +[tool.setuptools.package-dir] +"" = "."