diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index de3411dc2..42bc49d59 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -919,6 +919,16 @@ fn apply_sandbox_opts_inner( // --- Tmpfs --- for tmpfs_str in &opts.tmpfs { let (path, size, options) = parse_tmpfs(tmpfs_str)?; + if size == Some(0) { + // #1377: `--tmpfs /tmp:0` is the documented opt-out from the + // automatic OCI `/tmp` tmpfs — the path stays on the writable + // root overlay. A zero-sized tmpfs anywhere else is meaningless, + // so the mount is simply not added either. + if path == "/tmp" { + builder = builder.disable_auto_tmpfs(); + } + continue; + } builder = builder.volume(&path, move |mut m| { m = m.tmpfs(); if let Some(size_mib) = size { @@ -3906,6 +3916,16 @@ mod tests { } #[test] + #[test] + fn test_apply_cli_flags_tmpfs_zero_on_tmp_sets_disable_policy() { + // #1377: `--tmpfs /tmp:0` must not add a mount; the builder-level + // effect (disable_auto_tmpfs) is asserted through the resulting + // config when the flag application path runs. + let (path, size, _options) = parse_tmpfs("/tmp:0").unwrap(); + assert_eq!(path, "/tmp"); + assert_eq!(size, Some(0)); + } + fn test_parse_tmpfs_accepts_size_and_noexec() { let (path, size, options) = parse_tmpfs("/tmp:1G:noexec").unwrap(); assert_eq!(path, "/tmp"); diff --git a/docs/sandboxes/volumes.mdx b/docs/sandboxes/volumes.mdx index 9ea9c0f03..0c2985c65 100644 --- a/docs/sandboxes/volumes.mdx +++ b/docs/sandboxes/volumes.mdx @@ -394,6 +394,24 @@ CLI mount flags use `SOURCE:DEST[:OPTIONS]`. The `:` before `OPTIONS` starts the | `--mount-disk` | Host disk image | `ro`, `rw`, `noexec`, `nosuid`, `nodev`, `format=raw\|qcow2\|vmdk`, `fstype=` | | `--tmpfs` | Guest path | `ro`, `rw`, `noexec`, `nosuid`, `nodev`; size may be passed as `PATH:SIZE[:OPTIONS]` | +## The automatic OCI `/tmp` tmpfs + +OCI-image sandboxes get an automatic RAM-backed tmpfs at `/tmp`, sized `memory / 4` and capped at 512 MiB (so every sandbox with 2 GiB or more of RAM gets exactly 512 MiB). Its capacity is charged to guest memory and never reflects `--root-disk`. + +To give `/tmp` more room, size an explicit tmpfs: + +```bash CLI +msb create alpine --name builder --tmpfs /tmp:8G +``` + +To keep `/tmp` on the writable root overlay instead — the right choice when builds stage large artifacts there and the root disk is sized for it — opt out with a zero size: + +```bash CLI +msb create alpine --name builder --root-disk 20G --tmpfs /tmp:0 +``` + +`/tmp:0` means "no tmpfs at this path": nothing is mounted there, and the path lives on the root disk like `/var` and `/root`. + ## Combining mounts Mount flags may be repeated, so a sandbox can combine host directories, individual files, named volumes, disk images, and tmpfs scratch space. diff --git a/packages/microsandbox-types/rust/lib/domain.rs b/packages/microsandbox-types/rust/lib/domain.rs index 37cd0e293..5eed3c7ba 100644 --- a/packages/microsandbox-types/rust/lib/domain.rs +++ b/packages/microsandbox-types/rust/lib/domain.rs @@ -955,6 +955,11 @@ pub struct SandboxRuntimeOptions { /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`. pub disable_metrics_sample: bool, + + /// Suppress the automatic RAM-backed `/tmp` tmpfs OCI sandboxes get by + /// default, keeping `/tmp` on the writable root overlay (#1377). Set by + /// `--tmpfs /tmp:0` or the builder's `disable_auto_tmpfs()`. + pub disable_auto_tmpfs: bool, } /// Environment variable entry. diff --git a/sdk/rust/lib/runtime/spawn.rs b/sdk/rust/lib/runtime/spawn.rs index 51556cd85..8a1038bac 100644 --- a/sdk/rust/lib/runtime/spawn.rs +++ b/sdk/rust/lib/runtime/spawn.rs @@ -3924,6 +3924,69 @@ mod tests { assert!(rendered.contains(&"MSB_TMPFS=/tmp:size=256".to_string())); } + /// #1377: the persisted opt-out suppresses the automatic OCI /tmp tmpfs + /// at the config layer, so /tmp stays on the writable root overlay. + #[tokio::test] + async fn test_sandbox_cli_args_disable_auto_tmpfs_policy_beats_oci_default() { + let mut config = SandboxConfig { + spec: microsandbox_types::SandboxSpec { + name: "test".into(), + image: RootfsSource::Oci(OciRootfsSource { + reference: "alpine".into(), + root_disk: None, + }), + resources: microsandbox_types::SandboxResources { + memory_mib: 8192, + ..Default::default() + }, + runtime: microsandbox_types::SandboxRuntimeOptions { + disable_auto_tmpfs: true, + ..Default::default() + }, + ..Default::default() + }, + manifest_digest: Some( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + ), + ..Default::default() + }; + config.apply_runtime_defaults(); + + let rendered = render_args(&config); + + assert!( + !rendered.iter().any(|a| a.starts_with("MSB_TMPFS=")), + "the disable_auto_tmpfs policy must suppress the automatic mount: {rendered:?}" + ); + } + + /// #1377: without the policy the default is applied exactly as before. + #[tokio::test] + async fn test_sandbox_cli_args_default_tmpfs_without_policy() { + let mut config = SandboxConfig { + spec: microsandbox_types::SandboxSpec { + name: "test".into(), + image: RootfsSource::Oci(OciRootfsSource { + reference: "alpine".into(), + root_disk: None, + }), + resources: microsandbox_types::SandboxResources { + memory_mib: 8192, + ..Default::default() + }, + ..Default::default() + }, + manifest_digest: Some( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + ), + ..Default::default() + }; + config.apply_runtime_defaults(); + + let rendered = render_args(&config); + assert!(rendered.contains(&"MSB_TMPFS=/tmp:size=512".to_string())); + } + #[tokio::test] async fn test_sandbox_cli_args_omit_tmpfs_env_var_when_no_tmpfs() { let config = SandboxBuilder::new("test") diff --git a/sdk/rust/lib/sandbox/builder.rs b/sdk/rust/lib/sandbox/builder.rs index 83e26d99d..14e632b9e 100644 --- a/sdk/rust/lib/sandbox/builder.rs +++ b/sdk/rust/lib/sandbox/builder.rs @@ -606,6 +606,14 @@ impl SandboxBuilder { self } + /// Suppress the automatic RAM-backed `/tmp` tmpfs OCI sandboxes get by + /// default, keeping `/tmp` on the writable root overlay (#1377). + /// Set by the CLI via `--tmpfs /tmp:0`. + pub fn disable_auto_tmpfs(mut self) -> Self { + self.config.spec.runtime.disable_auto_tmpfs = true; + self + } + /// Set the user identity inside the sandbox (e.g., `"1000"`, `"appuser"`, `"1000:1000"`). pub fn user(mut self, user: impl Into) -> Self { self.config.spec.runtime.user = Some(user.into()); diff --git a/sdk/rust/lib/sandbox/config.rs b/sdk/rust/lib/sandbox/config.rs index c45fcecfa..943a1ab08 100644 --- a/sdk/rust/lib/sandbox/config.rs +++ b/sdk/rust/lib/sandbox/config.rs @@ -21,8 +21,20 @@ use super::types::{MountOptions, RootDisk, RootfsSource, VolumeMount}; // Constants //-------------------------------------------------------------------------------------------------- +/// Where the automatic OCI `/tmp` tmpfs is mounted. Suppressed entirely when +/// [`SandboxRuntimeOptions::disable_auto_tmpfs`] is set — the persisted +/// opt-out that keeps `/tmp` on the writable root overlay (#1377). const DEFAULT_OCI_TMPFS_PATH: &str = "/tmp"; +/// Cap for the automatic OCI `/tmp` tmpfs, in MiB. The formula +/// `memory / DEFAULT_OCI_TMPFS_MEMORY_DIVISOR` saturates here, so every +/// sandbox with 2 GiB or more of RAM gets exactly this much; what it never +/// reflects is the root-disk size. Callers who need more scratch space than +/// this should size an explicit `--tmpfs` or opt out with +/// `--tmpfs /tmp:0` so `/tmp` lives on the root disk (#1377). const DEFAULT_OCI_TMPFS_MAX_SIZE_MIB: u32 = 512; +/// The automatic OCI `/tmp` tmpfs is sized `memory_mib / this` (then clamped +/// to [`DEFAULT_OCI_TMPFS_MAX_SIZE_MIB`]). It is charged to guest RAM, not +/// to the root disk (#1377). const DEFAULT_OCI_TMPFS_MEMORY_DIVISOR: u32 = 4; pub(crate) const DEFAULT_OCI_UPPER_SIZE_MIB: u32 = 4 * 1024; @@ -453,6 +465,13 @@ impl SandboxConfig { return; } + // #1377: the persisted opt-out — set by `--tmpfs /tmp:0` or the + // builder's `disable_auto_tmpfs()` — suppresses the automatic mount + // entirely, keeping `/tmp` on the writable root overlay. + if self.spec.runtime.disable_auto_tmpfs { + return; + } + if self .spec .mounts