From b448ed64974ef32afdc695b924195141ba722a7b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 14:09:01 -0800 Subject: [PATCH 1/4] Record typed sandbox roots and bounded RAM uppers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aec73065-9547-4d77-a63b-a6307a637d10 --- doc/usage.md | 10 +- nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs | 4 +- .../src/host/cli/sandbox/lifecycle/start.rs | 67 +-- nvx-rs/src/host/cli/sandbox/mod.rs | 33 +- nvx-rs/src/host/cli/sandbox/options.rs | 31 +- nvx-rs/src/host/cli/sandbox/state.rs | 450 +++++++----------- nvx-rs/src/host/cli/sandbox/state/tests.rs | 1 + nvx-rs/src/host/cli/sandbox/tests.rs | 56 +++ nvx-rs/tests/cli.rs | 4 +- nvx-rs/tests/cli/formats.rs | 368 ++++++-------- nvx-rs/tests/cli/lifecycle.rs | 64 ++- nvx-rs/tests/cli/usage.rs | 4 +- 12 files changed, 499 insertions(+), 593 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index c0a78ea5c..1d811cbb5 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -247,9 +247,11 @@ never after a dry run, a restore, an option that `nvx-rs` refuses, or Ctrl-C. ```text nvx-rs/target/release/nvx-rs sandbox [{run,provision,start,exec,stop,deprovision}] - [--layer ROLE,PATH,EROFS_UUID]... + [--layer ROLE,PATH,IMAGE_UUID] + [--layer-format {erofs,gpt}] [--scratch PATH] [--upper {scratch,ram}] + [--ram-upper-percent PERCENT] [--state-dir PATH] [--entrypoint PATH] [--arg VALUE]... @@ -299,9 +301,11 @@ do, and their names cannot be abbreviated either. | Option | Default | Description | | --- | --- | --- | | `{run,provision,start,exec,stop,deprovision}` | `run` | Select a one-shot run or a managed lifecycle operation. | -| `--layer ROLE,PATH,EROFS_UUID` | required for `run` and `provision` | Attach the `distro` EROFS layer. Only `distro` is accepted. The UUID must be lowercase hexadecimal in canonical hyphenated form; braces, prefixes, uppercase, and unhyphenated forms are refused. | +| `--layer ROLE,PATH,IMAGE_UUID` | required for `run` and `provision` | Attach exactly one `distro` image by path. `run` takes EROFS; `provision` takes the format of `--layer-format`. The filesystem UUID or GPT disk GUID must be lowercase hexadecimal in canonical hyphenated form; braces, prefixes, uppercase, and unhyphenated forms are refused. | +| `--layer-format {erofs,gpt}` | `erofs` | Select provision's image format explicitly, without probing the file or inferring its format from its name. Other sandbox commands reject this option. | | `--scratch PATH` | required for `run` and `provision` with a scratch upper | Attach a preformatted ext4 scratch image as the overlay's writable upper. `--upper ram` refuses it. | -| `--upper {scratch,ram}` | `scratch` | Select the overlay's writable upper for `run` or `provision`: `scratch`, the image of `--scratch`, or `ram`, a tmpfs in guest memory with no scratch image, which loses the workload's writes when the VM stops. A RAM upper holds at most half of the guest's RAM; see [Overlay upper](run.md#overlay-upper). | +| `--upper {scratch,ram}` | `scratch` | Select the overlay's writable upper for `run` or `provision`: `scratch`, the image of `--scratch`, or `ram`, a tmpfs in guest memory with no scratch image, which loses the workload's writes when the VM stops. The default RAM cap is half of guest memory; provision can set it with `--ram-upper-percent`. See [Overlay upper](run.md#overlay-upper). | +| `--ram-upper-percent PERCENT` | `50` | Set provision's RAM upper cap to an ASCII decimal integer from 1 through 90 percent of VM memory. Requires `--upper ram`; other sandbox commands reject this option. The typed configuration records the cap, image format and path, and upper kind, which each start reads unchanged. Older configuration formats are refused rather than migrated. | | `--state-dir PATH` | required for managed operations | Select persistent sandbox state. One-shot `run` rejects this option. | | `--entrypoint PATH` | `/bin/sh` | Select an absolute workload entrypoint without whitespace, control characters, double quotes, or any UTF-8 byte that Linux treats as whitespace. | | `--arg VALUE` | none | Append one nonempty entrypoint argument without whitespace, control characters, double quotes, or any UTF-8 byte that Linux treats as whitespace. Repeat to pass multiple arguments. | diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index ef7ebee65..4c3a632ca 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -1,6 +1,6 @@ //! Persistent sandboxes on the CLI's store and caller-owned [`crate::host::vm`] interface. //! -//! Provision keeps today's configuration, image bindings, network and export. Start journals the +//! Provision records the typed image and upper, image bindings, network and export. Start journals the //! prepared capability and outputs before spawn, records the verified process, and commits the //! matching saved VM only after authenticated readiness and mappings. Every lifecycle command //! takes the CLI store's recoverable lock; indeterminate launches retain their evidence. @@ -58,6 +58,8 @@ pub(crate) struct ProvisionConfig<'a> { pub(crate) memory_mib: u64, /// `--cmdline`. pub(crate) cmdline: &'a str, + /// The RAM upper's cap, or the default when omitted. + pub(crate) ram_upper_percent: Option, } /// The control features that the layered sandbox root must provide for the requests of diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs index e584cf0f6..34259d6fd 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/start.rs @@ -10,23 +10,17 @@ use super::{ CONFIG_NAME, Found, REQUIRED_FEATURES, connect, find_vm, mismatched, require_config, running_vm, runtime_for, }; -use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; use crate::host::cli::deadline::Deadline; use crate::host::cli::sandbox::lock::LifecycleLock; use crate::host::cli::sandbox::state::{Config, read_config}; use crate::host::cli::sandbox::{Artifacts, error_with_cause}; use crate::host::cli::session::{ExecRequest, ExecResult, Session}; use crate::host::cli::{Exit, Result, output}; -use crate::host::config::{ - Boot, ConsoleAttachment, LayeredRoot, NetworkConfig, RootConfig, UpperConfig, VmConfig, -}; use crate::host::process::KILL_TIMEOUT; use crate::host::runtime::Runtime; use crate::host::sandbox::distro::DISTRO_POLICY_REVISION; use crate::host::sandbox::scratch::CLAIM_RELEASE_WAIT; -use crate::host::sandbox::{SandboxLaunch, SandboxPlan}; use crate::host::vm::{self, SavedVm, VmDir}; -use crate::net::Ipv4Cidr; use crate::{ErrorCode, ErrorKind}; /// Starts a provisioned sandbox, as `start` does. @@ -68,13 +62,11 @@ fn start_locked( } let config = Config::new(config)?; let launch = config.launch()?; - let binding = config.distro_binding()?; + let binding = config.distro_binding(); let scratch_binding = config.scratch_binding()?; let distro = binding .check(launch.distro(), DISTRO_POLICY_REVISION) .map_err(binding_error)?; - // Reading the network checks its policy again, as provision did, before anything launches. - let launch = launch.with_network(config.network()?); let scratch = match (scratch_binding, launch.scratch()) { (Some(scratch_binding), Some(scratch)) => Some( scratch_binding @@ -83,12 +75,12 @@ fn start_locked( ), _ => None, }; - let plan = launch.bound_plan(&binding)?; + let plan = launch.bound_plan(binding)?; // Keep provision's refusal of caller-owned nvx_* tokens, without launching its old plan. plan.kernel_command_line(config.cmdline())?; artifacts.require()?; let runtime = runtime_for(artifacts, &config, artifacts.launch_identity()?)?; - let config = vm_config(&config, &launch, &plan)?; + let config = config.vm_config(&launch)?; let dir = VmDir::open(state_dir).map_err(error_with_cause)?; lock.clear_outputs()?; let mut prepared = vm::create(&runtime, &config, None, &dir).map_err(error_with_cause)?; @@ -177,59 +169,6 @@ fn start_locked( Err(error) } -fn vm_config(config: &Config, launch: &SandboxLaunch, plan: &SandboxPlan) -> Result { - let (uid, gid) = launch.settings().workload_identity; - let mut command_line = format!("nvx_sandbox=1 nvx_mode=managed nvx_identity={uid}:{gid}"); - if !config.cmdline().is_empty() { - command_line.push(' '); - command_line.push_str(config.cmdline()); - } - let network = launch - .network() - .map(|network| -> Result { - Ok(NetworkConfig { - guest_network: Some( - Ipv4Cidr::parse(network.guest_network()) - .map_err(|error| Exit::error(ErrorCode::Unsupported, error.to_string()))?, - ), - policy: network.policy().clone(), - }) - }) - .transpose()?; - Ok(VmConfig { - processors: 1, - memory_mib: config.memory_mib(), - root: RootConfig::Layered(LayeredRoot { - image: launch.distro().path().to_path_buf(), - format: plan.distro().image(), - gpt_first_partition_bound: None, - upper: match launch.scratch() { - Some(path) => UpperConfig::Scratch { - path: path.to_path_buf(), - journal: Journal::Allowed, - }, - None => UpperConfig::Ram { - max_memory_percent: UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, - }, - }, - overlay: OverlayMode::TrustedLowers, - }), - view: View { - hostname: launch.settings().hostname.as_bytes().to_vec(), - // As the sandbox agent did, nvx-init gives the workload a machine ID of the VM's own. - machine_id: MachineIdMode::Generated, - }, - network, - filesystem: launch.export().cloned(), - console: ConsoleAttachment::Disconnected, - boot: Some(Boot { - command_line, - memory_capacity_mib: None, - capture: None, - }), - }) -} - /// Returns the error of a start whose distro layer or scratch image fails the check of its /// binding: a binding that no longer holds needs a new provision, which verifies the layer and /// binds the image again, while a scratch image that another sandbox claims, and a failure of the diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 33a6b2876..55cc86554 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -30,7 +30,7 @@ use super::shell::format_command; use super::{Exit, Result, output}; use crate::ErrorCode; use crate::host::artifacts::{Guest, KERNEL_NAME, require_artifact}; -use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; +use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding, DistroFormat}; use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; use crate::host::sandbox::options::{ SandboxOperation, SandboxOptions, sandbox_mounts, validate_sandbox_systemd_policy, @@ -137,6 +137,20 @@ pub(crate) fn error_with_cause(error: crate::Error) -> Exit { /// Runs the operation of `options`, as `command_sandbox` did. pub(super) fn run(root: Option<&str>, options: Options) -> Result { let operation = options.operation; + if !matches!(operation, SandboxOperation::Provision) + && (options.layer_format.is_some() || options.ram_upper_percent.is_some()) + { + return Err(Exit::error( + ErrorCode::Unsupported, + "--layer-format and --ram-upper-percent are only supported by provision", + )); + } + if options.ram_upper_percent.is_some() && options.upper != Some(RootUpper::Ram) { + return Err(Exit::error( + ErrorCode::Unsupported, + "--ram-upper-percent requires --upper ram", + )); + } let checks = SandboxOptions { operation, dry_run: options.dry_run, @@ -193,7 +207,21 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { } RootUpper::Ram => SandboxUpper::RAM, }; - let launch = SandboxLaunch::new(options.layers.clone(), upper, settings)?; + let layers = options + .layers + .iter() + .map(|layer| { + Ok(match options.layer_format { + Some(DistroFormat::Gpt) => crate::host::sandbox::SandboxLayer::gpt( + layer.role(), + &path_string(layer.path())?, + layer.uuid(), + ), + _ => layer.clone(), + }) + }) + .collect::>>()?; + let launch = SandboxLaunch::new(layers, upper, settings)?; validate_mounts(&mounts)?; launch.validated()?; // The shares are planned once, from their resolved host paths, and `provision` records @@ -230,6 +258,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { hypervisor: hypervisor(options.hypervisor), memory_mib: options.memory_mib, cmdline: &options.cmdline, + ram_upper_percent: options.ram_upper_percent, }, options.timeout, ) diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs index 48fc36e55..61ba70714 100644 --- a/nvx-rs/src/host/cli/sandbox/options.rs +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -11,6 +11,7 @@ use crate::host::network::{ Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, }; use crate::host::paths; +use crate::host::sandbox::distro::DistroFormat; use crate::host::sandbox::mount::{MountOwner, PolicyKind}; use crate::host::sandbox::options::{PolicyPathOption, SandboxOperation}; use crate::host::sandbox::plan::guest_gateway; @@ -30,12 +31,18 @@ positional arguments: options: -h, --help show this help message and exit - --layer ROLE,PATH,EROFS_UUID + --layer ROLE,PATH,IMAGE_UUID + --layer-format {erofs,gpt} + provision's image format (default: erofs); IMAGE_UUID + is the filesystem UUID or GPT disk GUID --scratch SCRATCH --upper {scratch,ram} where the overlay keeps the sandbox's writes: scratch (default), on the ext4 image of --scratch, or ram, on a tmpfs in guest memory, without a scratch image + --ram-upper-percent PERCENT + provision's RAM upper cap, from 1 to 90 percent of + guest memory (default: 50); requires --upper ram --state-dir STATE_DIR --entrypoint ENTRYPOINT --arg SANDBOX_ARG @@ -117,6 +124,8 @@ options: const OPERATIONS: [&str; 6] = ["run", "provision", "start", "exec", "stop", "deprovision"]; /// The choices of `--upper`, the names of the uppers. const UPPERS: [&str; 2] = [RootUpper::Scratch.as_str(), RootUpper::Ram.as_str()]; +/// The image formats that provision accepts. +const LAYER_FORMATS: [&str; 2] = ["erofs", "gpt"]; /// The choices of `--hypervisor`. pub(in crate::host::cli) const HYPERVISORS: [&str; 4] = ["auto", "whp", "kvm", "mshv"]; /// The choices of `--mount-owner`. @@ -137,10 +146,12 @@ fn seconds(value: &str) -> std::result::Result { /// The options of `nvx-rs sandbox`, which parse with the defaults of the former /// `nvx.py sandbox`. -pub(in crate::host::cli) const OPTIONS: [Spec; 34] = [ +pub(in crate::host::cli) const OPTIONS: [Spec; 36] = [ Spec::one("--layer"), + Spec::one("--layer-format"), Spec::one("--scratch"), Spec::one("--upper"), + Spec::one("--ram-upper-percent"), Spec::one("--state-dir"), Spec::one("--entrypoint"), Spec::one("--arg"), @@ -181,10 +192,14 @@ pub(crate) struct Options { pub(crate) operation: SandboxOperation, /// `--layer`, parsed as `SandboxLayer.parse` parses it. pub(crate) layers: Vec, + /// `--layer-format`, if given; provision defaults to EROFS. + pub(crate) layer_format: Option, /// `--scratch`, as given. pub(crate) scratch: Option, /// `--upper`, if given; a launch keeps its upper on scratch without it. pub(crate) upper: Option, + /// `--ram-upper-percent`, if given; a RAM upper defaults to 50. + pub(crate) ram_upper_percent: Option, /// `--state-dir`, as given. pub(crate) state_dir: Option, /// `--entrypoint`. @@ -233,8 +248,10 @@ impl Default for Options { Self { operation: SandboxOperation::Run, layers: Vec::new(), + layer_format: None, scratch: None, upper: None, + ram_upper_percent: None, state_dir: None, entrypoint: DEFAULT_ENTRYPOINT.to_owned(), args: Vec::new(), @@ -277,6 +294,12 @@ impl Receiver for Options { "--layer" => self .layers .push(SandboxLayer::parse(&text()).map_err(|error| error.message().to_owned())?), + "--layer-format" => { + self.layer_format = Some(match choice(&text(), &LAYER_FORMATS)? { + "gpt" => DistroFormat::Gpt, + _ => DistroFormat::Erofs, + }); + } "--scratch" => self.scratch = Some(text()), "--upper" => { self.upper = Some(match choice(&text(), &UPPERS)? { @@ -284,6 +307,10 @@ impl Receiver for Options { _ => RootUpper::Scratch, }); } + "--ram-upper-percent" => { + self.ram_upper_percent = + Some(integer(&text(), crate::protocol::root::RAM_UPPER_CAPS)?); + } "--state-dir" => self.state_dir = Some(text()), "--entrypoint" => self.entrypoint = text(), "--arg" => self.args.push(text()), diff --git a/nvx-rs/src/host/cli/sandbox/state.rs b/nvx-rs/src/host/cli/sandbox/state.rs index 2dff2c014..f0bcbd98b 100644 --- a/nvx-rs/src/host/cli/sandbox/state.rs +++ b/nvx-rs/src/host/cli/sandbox/state.rs @@ -1,76 +1,48 @@ -//! The CLI's own record in a state directory, `config.json`, which `provision` writes in format 8 -//! and `start` also reads in formats 1 and 7. +//! The CLI's typed `config.json`, in format 9 only: one EROFS or GPT image by path, +//! a scratch upper by path or a RAM upper with its explicit cap, the image bindings, +//! and the workload's view, network and export. //! -//! - [`write_json`] writes JSON text and a line end, with each `\n` turned into the line separator -//! of the host, into a temporary file of mode 0600 that then replaces the file. -//! - [`read_config`] reads a configuration as [`read_object`] reads a JSON file, whose `format` is -//! 1, 7, or 8, and [`Config::new`] holds the fields that `start` reads, as their types require -//! them: a configuration without one, or with a value of another type, is malformed. Guest RAM -//! outside the range of `--memory-mib`, which `nvx.py` and earlier releases of `nvx-rs` -//! recorded, is refused with the advice to provision the sandbox again. -//! - [`serialize_launch`] writes a provisioned launch in format 8, with the export plan of its -//! shares under `export`, `null` without shares, and its upper under `upper`, `scratch` or -//! `ram`, with its members sorted by name, as [`pretty_serde`] writes them, and -//! [`Config::launch`] reads it back. It also reads format 7, which earlier builds wrote, the -//! same without `upper`, whose upper is on scratch, and format 1, which they wrote for a sandbox -//! without shares, with `mount` `null` and no `export`. Formats 2 to 6 recorded the shares as -//! they were requested; `start` no longer reads them, and such a sandbox is provisioned again. -//! - Every configuration also records the [`DistroBinding`] of the sandbox's distro layer, under -//! `distro_binding` in its serde form, which [`Config::distro_binding`] reads back for `start` -//! to check. `nvx.py` never wrote one, and `start` refuses a configuration without one. The -//! binding is of the layer at the path that the configuration records, which a -//! [`RecordedDistro`] holds. -//! - The configuration of an upper on scratch records the [`ScratchBinding`] of the sandbox's -//! scratch image too, under `scratch_binding`, which [`Config::scratch_binding`] reads back for -//! `start` to check and claim, of the image at the path that the configuration records under -//! `scratch`, which a [`RecordedScratch`] holds. `start` refuses such a configuration without -//! one as well. That of an upper in RAM has no scratch image, and records `null` under both. -//! - Every configuration also records the sandbox's [`SandboxNetwork`], under `network` in the -//! serde form of the plan's network, or `null` for no network device, which -//! [`Config::network`] reads back for `start` to render. It replaces the network options that -//! `nvx.py` recorded, which no reader reads: `start` refuses a configuration without it. +//! Provision checks the same [`VmConfig`] that start reconstructs. Older records are +//! refused, not migrated. Bindings still check the recorded caller-owned paths before +//! launch, and the caller keeps those paths stable while the VM uses them. use std::fs::OpenOptions; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use super::lifecycle::ProvisionConfig; use super::{MAX_MEMORY_MIB, path_string}; use crate::ErrorCode; +use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; use crate::host::cli::json::{Object, pretty_serde, read_object}; use crate::host::cli::output::LINE_SEPARATOR; use crate::host::cli::snapshots::Name as SnapshotName; use crate::host::cli::{Exit, Result, io_error, io_message}; +use crate::host::config::{ + Boot, ConsoleAttachment, LayeredRoot, NetworkConfig, RootConfig, UpperConfig, VmConfig, +}; use crate::host::filesystem::ExportPlan; use crate::host::paths; -use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; +use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding, DistroFormat}; use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; use crate::host::sandbox::scratch::ScratchBinding; use crate::host::sandbox::{SandboxLaunch, SandboxNetwork, SandboxSettings, SandboxUpper}; -use crate::protocol::root::RootUpper; +use crate::net::Ipv4Cidr; +use crate::protocol::root::{RAM_UPPER_CAPS, RootImage}; +use crate::uuid::Uuid; pub(crate) use crate::host::cli::{hex, random_bytes, temporary_path}; #[cfg(test)] mod tests; -/// The configuration that earlier builds wrote for a sandbox without shares, whose `mount` is -/// `null`, which `start` still reads. -pub(crate) const CONFIG_FORMAT: u64 = 1; -/// The configuration that earlier builds wrote, which records the export plan of the sandbox's -/// shares under `export`, in its serde form, or `null` for a sandbox without shares, and whose -/// upper is on scratch, which `start` still reads. -pub(crate) const EXPORT_CONFIG_FORMAT: u64 = 7; -/// The configuration that `provision` writes: that of [`EXPORT_CONFIG_FORMAT`], with the upper -/// under `upper`, `scratch` or `ram`. -pub(crate) const UPPER_CONFIG_FORMAT: u64 = 8; +/// The typed image and upper configuration that provision writes and start reads. +pub(crate) const CONFIG_FORMAT: u64 = 9; /// Every configuration format that `start` reads. -pub(crate) const CONFIG_FORMATS: [u64; 3] = - [CONFIG_FORMAT, EXPORT_CONFIG_FORMAT, UPPER_CONFIG_FORMAT]; +pub(crate) const CONFIG_FORMATS: [u64; 1] = [CONFIG_FORMAT]; /// What messages call a configuration. const DESCRIPTION: &str = "sandbox configuration"; @@ -131,10 +103,8 @@ fn resolved(path: &Path) -> Result { /// The distro layer that `provision` records, at the path that it records for it, with the /// binding of the layer at that path, which `start` checks. pub(crate) struct RecordedDistro { - /// The layer's path, resolved, as `provision` records it. - path: String, - /// The layer at that path. - layer: SandboxLayer, + /// The image at its resolved path, in its actual format. + image: Image, /// The binding of the layer at that path. binding: DistroBinding, } @@ -152,7 +122,12 @@ impl RecordedDistro { pub(crate) fn new(launch: &SandboxLaunch, binding: DistroBinding) -> Result { let distro = launch.distro(); let path = resolved(distro.path())?; - let layer = SandboxLayer::new(distro.role(), &path, distro.uuid()); + let image = Image { + path, + format: distro.format(), + identity: distro.uuid().to_owned(), + }; + let layer = image.layer(); let binding = match binding.check(&layer, DISTRO_POLICY_REVISION) { Ok(_) => binding, Err(_) => { @@ -163,11 +138,7 @@ impl RecordedDistro { binding } }; - Ok(Self { - path, - layer, - binding, - }) + Ok(Self { image, binding }) } } @@ -194,62 +165,30 @@ impl RecordedScratch { } } -/// A read-only layer of a configuration, as `provision` records it. -#[derive(Serialize)] -struct RecordedLayer<'a> { - /// The layer's role: `distro`. - role: &'a str, - /// The layer's image, at the path that `provision` verified. - path: &'a str, - /// The identity that the guest expects of the image. - uuid: &'a str, +/// The one image of a provisioned root. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Image { + format: DistroFormat, + path: String, + identity: String, } -/// A read-only layer of a configuration, as `start` reads it. -#[derive(Deserialize)] -struct ConfigLayer { - /// The layer's role, which only `distro` may be. - role: String, - /// The layer's image. - path: String, - /// The identity that the guest expects of the image. - uuid: String, +impl Image { + fn layer(&self) -> SandboxLayer { + match self.format { + DistroFormat::Erofs => SandboxLayer::new(LayerRole::Distro, &self.path, &self.identity), + DistroFormat::Gpt => SandboxLayer::gpt(LayerRole::Distro, &self.path, &self.identity), + } + } } -/// The configuration that `provision` records, in format 8. -#[derive(Serialize)] -struct Record<'a> { - /// [`UPPER_CONFIG_FORMAT`]. - format: u64, - /// The distro layer, the one layer that a launch holds. - layers: [RecordedLayer<'a>; 1], - /// Where the overlay keeps its upper: `scratch` or `ram`. - upper: &'a str, - /// The scratch image, at the path that `provision` bound, or `None` for an upper in RAM. - scratch: Option<&'a str>, - /// The workload's hostname. - hostname: &'a str, - /// The workload's UID. - workload_uid: u32, - /// The workload's GID. - workload_gid: u32, - /// `--hypervisor`, resolved. - hypervisor: &'a str, - /// `--memory-mib`. - memory_mib: u64, - /// The sandbox's network, or `None` for no network device. - network: Option<&'a SandboxNetwork>, - /// `--cmdline`. - cmdline: &'a str, - /// The binding of the distro layer. - distro_binding: &'a DistroBinding, - /// The binding of the scratch image, or `None` for an upper in RAM. - scratch_binding: Option<&'a ScratchBinding>, - /// The export plan of the shares, or `None` without shares. - export: Option<&'a ExportPlan>, - /// The immutable named snapshot that supplies this sandbox's restore view. - #[serde(skip_serializing_if = "Option::is_none")] - source_snapshot: Option<&'a SnapshotName>, +/// The upper's kind and exactly the fields that it needs. +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +enum Upper { + Scratch { path: String }, + Ram { max_memory_percent: u8 }, } /// Returns the JSON text of the configuration that `provision` records for `launch` and the other @@ -273,30 +212,37 @@ pub(crate) fn serialize_launch_with_snapshot( config: &ProvisionConfig<'_>, source_snapshot: Option<&SnapshotName>, ) -> Result { - let layer = &distro.layer; let settings = launch.settings(); let (workload_uid, workload_gid) = settings.workload_identity; - let record = Record { - format: UPPER_CONFIG_FORMAT, - layers: [RecordedLayer { - role: layer.role().as_str(), - path: &distro.path, - uuid: layer.uuid(), - }], - upper: launch.upper().kind().as_str(), - scratch: scratch.map(|scratch| scratch.path.as_str()), - hostname: &settings.hostname, + let record = Config { + format: CONFIG_FORMAT, + image: distro.image.clone(), + upper: match scratch { + Some(scratch) => Upper::Scratch { + path: scratch.path.clone(), + }, + None => Upper::Ram { + max_memory_percent: config + .ram_upper_percent + .unwrap_or(UpperConfig::DEFAULT_MAX_MEMORY_PERCENT), + }, + }, + hostname: settings.hostname.clone(), workload_uid, workload_gid, - hypervisor: config.hypervisor, + hypervisor: config.hypervisor.to_owned(), memory_mib: config.memory_mib, - network: launch.network(), - cmdline: config.cmdline, - distro_binding: &distro.binding, - scratch_binding: scratch.map(|scratch| &scratch.binding), - export: launch.export(), - source_snapshot, + network: launch.network().cloned(), + cmdline: config.cmdline.to_owned(), + distro_binding: distro.binding.clone(), + scratch_binding: scratch.map(|scratch| scratch.binding.clone()), + export: launch.export().cloned(), + source_snapshot: source_snapshot.cloned(), }; + record + .vm_config(&record.launch()?)? + .check() + .map_err(super::error_with_cause)?; pretty_serde(&record, DESCRIPTION) } @@ -306,30 +252,14 @@ pub(crate) fn read_config(path: &Path) -> Result { read_object(path, DESCRIPTION, "format", &CONFIG_FORMATS) } -/// Deserializes a member that may be `null` as present, so that only a missing member reads as -/// `None`. -fn present<'de, D: Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - Value::deserialize(deserializer).map(Some) -} - -/// The fields of a configuration of format 1, 7, or 8 that `start` reads. -/// -/// Every field is required, with its type, but the bindings, the network, the shares, and the -/// upper, whose absence [`Config`]'s methods report: `nvx.py` wrote no bindings and no typed -/// network, only formats 7 and 8 record `export`, and only format 8 `upper`, without which the -/// upper is on scratch. Other fields, such as the network options of `nvx.py`, are ignored. -#[derive(Deserialize)] +/// The sole configuration form, shared by provision's writer and start's reader. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct Config { /// The format, which [`read_config`] checked. format: u64, - /// The read-only layers. - layers: Vec, - /// Where the overlay keeps its upper, `scratch` or `ram`, as format 8 records it. - upper: Option, - /// The scratch image, or `None` for an upper in RAM. - scratch: Option, + image: Image, + upper: Upper, /// The workload's hostname. hostname: String, /// The workload's UID. @@ -342,22 +272,16 @@ pub(crate) struct Config { memory_mib: u64, /// The user's kernel command line. cmdline: String, - /// The export plan of the shares, `null` for none, as formats 7 and 8 record it, if recorded. - #[serde(default, deserialize_with = "present")] - export: Option, - /// The shares as formats 1 to 6 recorded them, `null` for none, if recorded. - #[serde(default, deserialize_with = "present")] - mount: Option, - /// The binding of the distro layer, if recorded. - #[serde(default, deserialize_with = "present")] - distro_binding: Option, - /// The binding of the scratch image, `null` for an upper in RAM, if recorded. - #[serde(default, deserialize_with = "present")] - scratch_binding: Option, - /// The sandbox's network, `null` for none, if recorded. - #[serde(default, deserialize_with = "present")] - network: Option, - /// The source of a restored sandbox, absent for a cold launch. + #[serde(deserialize_with = "Option::deserialize")] + export: Option, + distro_binding: DistroBinding, + #[serde(deserialize_with = "Option::deserialize")] + scratch_binding: Option, + #[serde(deserialize_with = "Option::deserialize")] + network: Option, + /// The immutable named snapshot that supplies a restored sandbox's view, absent for a cold + /// launch. + #[serde(default, skip_serializing_if = "Option::is_none")] source_snapshot: Option, } @@ -384,6 +308,22 @@ impl Config { /// which OpenVMM refuses at every start. `provision` now refuses it, so such a sandbox is /// provisioned again rather than migrated. pub(crate) fn new(object: Object) -> Result { + for (name, description) in [ + ("distro_binding", "distro binding"), + ("scratch_binding", "scratch binding"), + ("network", "network policy"), + ("export", "export plan"), + ] { + if !object.contains_key(name) { + return Err(Exit::error( + ErrorCode::FormatUnsupported, + format!( + "sandbox configuration has no {description}; deprovision the sandbox \ + and provision it again" + ), + )); + } + } if let Some(Value::Number(memory)) = object.get("memory_mib") && !memory .as_u64() @@ -407,96 +347,39 @@ impl Config { &self.hypervisor } - /// Returns the guest RAM in MiB. - pub(crate) fn memory_mib(&self) -> u64 { - self.memory_mib - } - /// Returns the user's kernel command line. pub(crate) fn cmdline(&self) -> &str { &self.cmdline } - /// Reads the launch that the configuration records and checks it against the host. - /// - /// A layer whose role is not `distro`, an export plan that is not of its serde form, or an - /// upper that [`upper`](Self::upper) refuses fails as `sandbox configuration is malformed`. A - /// configuration whose format is not the one that its shares need, format 7 or 8 for one that - /// records `export` and format 1 for one that does not, fails as `sandbox configuration format - /// does not match its mounts`, and so does one with a share under `mount`, which only formats - /// that `start` no longer reads recorded. The launch must pass the checks of - /// [`SandboxLaunch::new`], then the export plan those of [`SandboxLaunch::with_export`], and - /// the launch those of [`SandboxLaunch::validated`], which checks again the host objects that - /// the plan recorded. + /// Reconstructs and validates the recorded image, upper, network and export. pub(crate) fn launch(&self) -> Result { - let layers = self - .layers - .iter() - .map(|layer| { - let role = LayerRole::from_name(&layer.role).ok_or_else(malformed)?; - Ok(SandboxLayer::new(role, &layer.path, layer.uuid.as_str())) - }) - .collect::>>()?; - let plan = match &self.export { - Some(Value::Null) | None => None, - Some(export) => Some( - serde_json::from_value::(export.clone()).map_err(|_| malformed())?, - ), + let upper = match &self.upper { + Upper::Scratch { path } => SandboxUpper::scratch(path), + Upper::Ram { max_memory_percent } if RAM_UPPER_CAPS.contains(max_memory_percent) => { + SandboxUpper::RAM + } + _ => return Err(malformed()), }; let launch = SandboxLaunch::new( - layers, - self.upper()?, + vec![self.image.layer()], + upper, SandboxSettings { hostname: self.hostname.clone(), workload_identity: (self.workload_uid, self.workload_gid), ..SandboxSettings::default() }, )?; - let (expected, mounted) = match &self.export { - Some(_) if self.upper.is_some() => (UPPER_CONFIG_FORMAT, false), - Some(_) => (EXPORT_CONFIG_FORMAT, false), - None => ( - CONFIG_FORMAT, - self.mount.as_ref().is_some_and(|mount| !mount.is_null()), - ), - }; - if mounted || self.format != expected { - return Err(Exit::error( - ErrorCode::FormatUnsupported, - "sandbox configuration format does not match its mounts", - )); - } - let launch = launch.with_export(plan)?; + let launch = launch + .with_export(self.export.clone())? + .with_network(self.network.clone()); launch.validated()?; Ok(launch) } - /// Returns the upper that the configuration records. Format 8 names it under `upper`: for - /// `scratch`, the scratch image under `scratch`, and for `ram`, RAM, with `scratch` `null`. - /// Formats 1 and 7 name none, and keep the upper on the scratch image under `scratch`. Any - /// other combination fails as `sandbox configuration is malformed`. - fn upper(&self) -> Result { - let (scratch, ram) = (RootUpper::Scratch.as_str(), RootUpper::Ram.as_str()); - match (self.format, self.upper.as_deref(), self.scratch.as_deref()) { - (UPPER_CONFIG_FORMAT, Some(upper), Some(path)) if upper == scratch => { - Ok(SandboxUpper::scratch(path)) - } - (UPPER_CONFIG_FORMAT, Some(upper), None) if upper == ram => Ok(SandboxUpper::RAM), - (CONFIG_FORMAT | EXPORT_CONFIG_FORMAT, None, Some(path)) => { - Ok(SandboxUpper::scratch(path)) - } - _ => Err(malformed()), - } - } - - /// Reads the binding of the distro layer that the configuration records, which `start` checks. - /// - /// A configuration without one, as `nvx.py` and earlier releases of `nvx-rs` wrote, fails as - /// `sandbox configuration has no distro binding`: the sandbox is provisioned again rather than - /// migrated. A binding that is not of the binding's serde form fails as `sandbox configuration - /// is malformed`. - pub(crate) fn distro_binding(&self) -> Result { - recorded(self.distro_binding.as_ref(), "distro binding") + /// The binding that start checks against the recorded image. + pub(crate) fn distro_binding(&self) -> &DistroBinding { + &self.distro_binding } /// Reads the binding of the scratch image that the configuration records, which `start` @@ -508,44 +391,75 @@ impl Config { /// [`distro_binding`](Self::distro_binding) fails, and so does a configuration of an upper in /// RAM that records anything but `null`. pub(crate) fn scratch_binding(&self) -> Result> { - if self.upper.as_deref() == Some(RootUpper::Ram.as_str()) { - return match self.scratch_binding { - Some(Value::Null) => Ok(None), - _ => Err(malformed()), - }; + match (&self.upper, &self.scratch_binding) { + (Upper::Scratch { .. }, Some(binding)) => Ok(Some(binding.clone())), + (Upper::Ram { .. }, None) => Ok(None), + _ => Err(malformed()), } - recorded(self.scratch_binding.as_ref(), "scratch binding").map(Some) } - /// Reads the network that the configuration records, in its typed form, which `start` - /// renders, or `None` for no network device. - /// - /// A configuration without one, as `nvx.py` and earlier releases of `nvx-rs` wrote with their - /// network options, fails as `sandbox configuration has no network policy`: the sandbox is - /// provisioned again rather than migrated. A network that is not of the network's serde form, - /// or whose policy [`SandboxNetwork::new`] refuses, fails as `sandbox configuration is - /// malformed`. - pub(crate) fn network(&self) -> Result> { - recorded(self.network.as_ref(), "network policy") + /// The shared projection checked at provision and consumed unchanged at start. + pub(crate) fn vm_config(&self, launch: &SandboxLaunch) -> Result { + let identity = Some(Uuid::parse(&self.image.identity).ok_or_else(malformed)?); + let format = match self.image.format { + DistroFormat::Erofs => RootImage::erofs(identity), + DistroFormat::Gpt => RootImage::gpt(identity), + }; + let upper = match &self.upper { + Upper::Scratch { path } => UpperConfig::Scratch { + path: PathBuf::from(path), + journal: Journal::Allowed, + }, + Upper::Ram { max_memory_percent } => UpperConfig::Ram { + max_memory_percent: *max_memory_percent, + }, + }; + let (uid, gid) = launch.settings().workload_identity; + let mut command_line = format!("nvx_sandbox=1 nvx_mode=managed nvx_identity={uid}:{gid}"); + if !self.cmdline.is_empty() { + command_line.push(' '); + command_line.push_str(&self.cmdline); + } + let network = + launch + .network() + .map(|network| -> Result { + Ok(NetworkConfig { + guest_network: Some(Ipv4Cidr::parse(network.guest_network()).map_err( + |error| Exit::error(ErrorCode::Unsupported, error.to_string()), + )?), + policy: network.policy().clone(), + }) + }) + .transpose()?; + Ok(VmConfig { + processors: 1, + memory_mib: self.memory_mib, + root: RootConfig::Layered(LayeredRoot { + image: PathBuf::from(&self.image.path), + format, + gpt_first_partition_bound: None, + upper, + overlay: OverlayMode::TrustedLowers, + }), + view: View { + hostname: self.hostname.as_bytes().to_vec(), + // As the sandbox agent did, nvx-init gives the workload a machine ID of the VM's + // own. + machine_id: MachineIdMode::Generated, + }, + network, + filesystem: launch.export().cloned(), + console: ConsoleAttachment::Disconnected, + boot: Some(Boot { + command_line, + memory_capacity_mib: None, + capture: None, + }), + }) } } -/// Reads `value`, the serde form of a record of a configuration that messages call -/// `description`, such as `distro binding`, as [`Config::distro_binding`] reads that of the -/// distro layer. -fn recorded(value: Option<&Value>, description: &str) -> Result { - let Some(value) = value else { - return Err(Exit::error( - ErrorCode::FormatUnsupported, - format!( - "sandbox configuration has no {description}; deprovision the sandbox and \ - provision it again" - ), - )); - }; - serde_json::from_value(value.clone()).map_err(|_| malformed()) -} - /// Returns the error of a configuration that is not of the form that this build reads. fn malformed() -> Exit { Exit::error(ErrorCode::Io, "sandbox configuration is malformed") diff --git a/nvx-rs/src/host/cli/sandbox/state/tests.rs b/nvx-rs/src/host/cli/sandbox/state/tests.rs index 6ec46cf9b..17332890c 100644 --- a/nvx-rs/src/host/cli/sandbox/state/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/state/tests.rs @@ -19,6 +19,7 @@ fn restored_sandbox_records_round_trip_only_their_own_snapshot_name() { hypervisor: "whp", memory_mib: 128, cmdline: "", + ram_upper_percent: None, }; for source in [None, Some("My.snapshot-1"), Some("other")] { let name = source.map(|name| SnapshotName::try_from(name.to_owned()).unwrap()); diff --git a/nvx-rs/src/host/cli/sandbox/tests.rs b/nvx-rs/src/host/cli/sandbox/tests.rs index 66188d977..e2c29eb0b 100644 --- a/nvx-rs/src/host/cli/sandbox/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/tests.rs @@ -23,6 +23,62 @@ fn the_upper_is_scratch_or_ram() { for value in ["ramfs", "tmpfs", "RAM", "", "scratch,ram"] { assert!(upper(value).is_err(), "{value:?}"); } + for cap in [1, 50, 90] { + let (options, _) = parse(&[ + "--upper".to_owned(), + "ram".to_owned(), + "--ram-upper-percent".to_owned(), + cap.to_string(), + ]) + .unwrap(); + assert_eq!(options.ram_upper_percent, Some(cap)); + } + for value in ["0", "91", "-1", "+50", "1.5", "x", "\u{ff15}\u{ff10}", ""] { + assert!( + parse(&["--ram-upper-percent".to_owned(), value.to_owned()]).is_err(), + "{value:?}" + ); + } +} + +#[test] +fn provision_options_do_not_change_other_commands() { + for operation in ["run", "start", "exec", "stop", "deprovision"] { + for flag in ["--layer-format", "--ram-upper-percent"] { + let value = if flag == "--layer-format" { + "erofs" + } else { + "50" + }; + let (options, _) = + parse(&[operation.to_owned(), flag.to_owned(), value.to_owned()]).unwrap(); + let error = run(None, options).unwrap_err(); + assert!( + error + .message() + .unwrap() + .contains("only supported by provision") + ); + } + } + let (options, _) = parse(&[ + "provision".to_owned(), + "--ram-upper-percent".to_owned(), + "50".to_owned(), + ]) + .unwrap(); + assert!( + run(None, options) + .unwrap_err() + .message() + .unwrap() + .contains("requires --upper ram") + ); + for (name, format) in [("erofs", DistroFormat::Erofs), ("gpt", DistroFormat::Gpt)] { + let (options, _) = parse(&["--layer-format".to_owned(), name.to_owned()]).unwrap(); + assert_eq!(options.layer_format, Some(format)); + } + assert!(parse(&["--layer-format".to_owned(), "ext4".to_owned()]).is_err()); } proptest! { diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 5cc2f83a9..cd49157c9 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -654,7 +654,7 @@ fn provision_binds_a_linked_distro_layer_where_start_checks_it() { let output = provision_alias("target"); assert!(output.status.success(), "{}", text(&output.stderr)); let config = recorded("target"); - assert_eq!(config["layers"][0]["path"], distro.to_str().unwrap()); + assert_eq!(config["image"]["path"], distro.to_str().unwrap()); assert!(config["distro_binding"]["manifest"].is_object()); cycle(&root, "target"); @@ -675,7 +675,7 @@ fn provision_binds_a_linked_distro_layer_where_start_checks_it() { let output = provision_alias("alias"); assert!(output.status.success(), "{}", text(&output.stderr)); let config = recorded("alias"); - assert_eq!(config["layers"][0]["path"], distro.to_str().unwrap()); + assert_eq!(config["image"]["path"], distro.to_str().unwrap()); assert!(config["distro_binding"]["manifest"].is_null()); cycle(&root, "alias"); diff --git a/nvx-rs/tests/cli/formats.rs b/nvx-rs/tests/cli/formats.rs index 5c924ce4c..4f44826f1 100644 --- a/nvx-rs/tests/cli/formats.rs +++ b/nvx-rs/tests/cli/formats.rs @@ -1,245 +1,165 @@ -//! The configurations of every format that a build of `nvx-rs` or `nvx.py` wrote, from the -//! hand-written `config.json` files of `tests/fixtures/state/`: `start` reads format 7, which -//! earlier builds wrote, with shares or without, and format 1, which they wrote for a sandbox -//! without shares, and refuses the others, and the configurations that `nvx.py` wrote, without -//! bindings, before it launches OpenVMM. The lifecycle tests cover format 8, which this build -//! writes. -//! -//! A fixture names the paths of a root as `{root/RELATIVE}`, the hypervisor that a provision -//! records on this host as `{hypervisor}`, and the bindings of the root's distro layer and scratch -//! image, which hold the identities of the files of each root, as `{distro_binding}` and -//! `{scratch_binding}`. +//! The sole typed provision record and its start reader, and refusal of old records. -use nvx::host::sandbox::distro::DistroBinding; -use nvx::host::sandbox::scratch::ScratchBinding; -use nvx::host::sandbox::{LayerRole, SandboxLayer}; use serde_json::{Value, json}; -use crate::harness::{HYPERVISOR, Root, UUID, entries, in_parallel, json}; +use crate::harness::{LAYER, Root, SCRATCH, UUID, entries, json}; use crate::lifecycle::{PROVISIONED, provision, sandbox}; -use crate::openvmm::{Options, WORKLOAD, assert_opened_images, assert_stable_blocks, managed}; +use crate::openvmm::{Options, assert_opened_distro, assert_opened_images, assert_stable_blocks}; -/// What a start does with a configuration of a format. -enum Start { - /// It starts the sandbox, whose OpenVMM options differ as the function says from those of the - /// minimal sandbox, and whose guest maps the table, if any. A workload of the options runs - /// and writes the output. - Runs { - /// How the options of OpenVMM differ from those of the minimal sandbox. - openvmm: fn(Options) -> Options, - /// The mapping table of the shares that the guest receives, if any. - maps: Option, - /// The options of a workload that `exec` runs, and its output. - exec: (&'static [&'static str], &'static str), - }, - /// It refuses the configuration as one of a format that this build does not read, with an - /// error with the code `format-unsupported` that names the fragments. - Refused(&'static [&'static str]), +#[test] +fn configurations_of_every_format_start_and_exec() { + for format in ["erofs", "gpt"] { + for (upper, cap, expected_cap) in [ + ("scratch", None, None), + ("ram", None, Some(50)), + ("ram", Some("1"), Some(1)), + ("ram", Some("90"), Some(90)), + ] { + let context = format!("{format}/{upper}/{cap:?}"); + let root = Root::new(); + let mut options = vec!["--layer", LAYER, "--layer-format", format, "--upper", upper]; + if upper == "scratch" { + options.extend(["--scratch", SCRATCH]); + } + if let Some(cap) = cap { + options.extend(["--ram-upper-percent", cap]); + } + root.nvx_rs(&sandbox("provision", "{root/state}", &options)) + .assert_silent_success(); + let record = json(&root.below("state/config.json")); + assert_eq!(record["format"], 9, "{context}"); + assert_eq!( + record["image"], + root.expand_json(&json!({ + "format": format, "path": "{root/distro.erofs}", "identity": UUID, + })), + "{context}" + ); + let expected_upper = match expected_cap { + Some(cap) => json!({"kind": "ram", "max_memory_percent": cap}), + None => { + root.expand_json(&json!({"kind": "scratch", "path": "{root/scratch.ext4}"})) + } + }; + assert_eq!(record["upper"], expected_upper, "{context}"); + assert_eq!(record["distro_binding"]["format"], format, "{context}"); + assert!(record.get("layers").is_none(), "{context}"); + assert!(record.get("scratch").is_none(), "{context}"); + assert_eq!(record["scratch_binding"].is_null(), upper == "ram"); + + root.nvx_rs(&sandbox::<&str>("start", "{root/state}", &[])) + .assert_silent_success(); + let mut opened = + Options::parse(&root.openvmm_arguments("build/fake-openvmm-state.json")); + assert_stable_blocks( + &root, + &opened.take("--microvm-sandbox-block"), + upper == "scratch", + ); + if upper == "scratch" { + assert_opened_images(&root, "state"); + } else { + assert_opened_distro(&root, "state"); + } + let line = opened.value("--cmdline"); + let tokens: Vec<_> = line.split_ascii_whitespace().collect(); + assert!( + tokens.contains(&format!("nvx_layer=distro,0xd0003000,{format}:{UUID}").as_str()), + "{context}: {line}" + ); + if let Some(cap) = expected_cap { + assert!(tokens.contains(&format!("nvx_ram_upper_percent={cap}").as_str())); + assert!(tokens.contains(&"nvx_overlay_upper=ramfs")); + assert!(!tokens.iter().any(|token| token.starts_with("nvx_scratch="))); + } else { + assert!(tokens.contains(&"nvx_scratch=0xd0006000,ext4")); + assert!( + !tokens + .iter() + .any(|token| token.starts_with("nvx_ram_upper_percent=")) + ); + } + assert!(opened.values("--microvm-lifecycle").is_empty()); + let outcome = root.nvx_rs(&sandbox( + "exec", + "{root/state}", + &["--entrypoint", "/bin/echo", "--arg", "hello"], + )); + outcome.assert_success(); + assert_eq!(outcome.stdout(), "hello\n", "{context}"); + for operation in ["stop", "deprovision"] { + root.nvx_rs(&sandbox::<&str>(operation, "{root/state}", &[])) + .assert_silent_success(); + } + assert!(!root.below("state").exists(), "{context}"); + } + } } -/// Returns the options with which a start launches OpenVMM for the sandbox of `format-1.json` and -/// `format-7-without-shares.json`, a reader without shares, from those of the minimal sandbox. -/// The `memory_max` and `pids_max` that these records hold set no limit. -fn reader(options: Options) -> Options { - options - .with("--microvm-workload-identity", ["2000:2000"]) - .with("--memory", ["320M"]) - .sandbox_kernel("quiet", &[WORKLOAD[0], "nvx_hostname=reader"]) +#[test] +fn old_configuration_formats_are_refused_without_migration() { + for text in [ + include_str!("../fixtures/state/format-1.json"), + include_str!("../fixtures/state/format-1-network.json"), + include_str!("../fixtures/state/format-1-nvx-py.json"), + include_str!("../fixtures/state/format-2.json"), + include_str!("../fixtures/state/format-3.json"), + include_str!("../fixtures/state/format-4.json"), + include_str!("../fixtures/state/format-5.json"), + include_str!("../fixtures/state/format-6.json"), + include_str!("../fixtures/state/format-7.json"), + include_str!("../fixtures/state/format-7-without-shares.json"), + r#"{"format":8}"#, + ] { + let root = Root::new(); + root.nvx_rs(&provision("{root/state}", &[])) + .assert_silent_success(); + root.write("state/config.json", text); + let before = root.snapshot(); + root.nvx_rs(&sandbox::<&str>("start", "{root/state}", &[])) + .assert_error("format-unsupported", &["unsupported format"]); + assert_eq!(root.snapshot(), before); + assert_eq!(entries(&root.below("state")), PROVISIONED); + root.nvx_rs(&sandbox::<&str>("deprovision", "{root/state}", &[])) + .assert_silent_success(); + } } -/// The configurations of every format, as fixtures, and what a start does with each. -fn formats() -> [(&'static str, &'static str, Start); 10] { - let unsupported = &[ - "sandbox configuration has an unsupported format", - "{root/state/config.json}", - ][..]; - [ - ( - "format 1", - include_str!("../fixtures/state/format-1.json"), - Start::Runs { - openvmm: reader, - maps: None, - exec: (&["--entrypoint", "/bin/echo", "--arg", "one"], "one\n"), - }, - ), - ( - "format 1 with a network", - include_str!("../fixtures/state/format-1-network.json"), - Start::Runs { - openvmm: |options| { - options - .with("--net", ["10.0.0.2/24"]) - .with("--network-profile", ["portable"]) - .with("--network-egress", ["deny"]) - .with("--network-ingress", ["allow"]) - .with("--host-loopback", ["allow"]) - .with("--network-egress-allow", ["192.0.2.0/24:tcp:8080-8443"]) - .with("--network-egress-deny", ["198.51.100.0/24"]) - .with("--host-loopback-forward", ["tcp:18080:8080"]) - }, - maps: None, - exec: ( - &["--entrypoint", "/bin/echo", "--arg", "network"], - "network\n", - ), - }, - ), - ( - "format 7", - include_str!("../fixtures/state/format-7.json"), - Start::Runs { - openvmm: |options| { - options - .with("--mount-aggregate", ["/run/nvx/hostfs/root"]) - .with( - "--mount-child", - ["0,{root/share},rw", "1,{root/single.txt},ro,file"], - ) - .with("--mount-deny", ["{root/share/hidden}"]) - .with("--mount-allow", ["{root/share/hidden/shown}"]) - .with("--mount-write", ["{root/share/writable}"]) - .sandbox_kernel("", &[WORKLOAD[0], WORKLOAD[1], "nvx_maps=2"]) - }, - maps: Some(json!([ - {"source": "0", "target": "/data", "read_only": false, "directory": true}, - { - "source": "1", - "target": "/etc/single.txt", - "read_only": true, - "directory": false, - }, - ])), - exec: (&["--entrypoint", "/bin/pwd", "--cwd", "/data"], "/data\n"), - }, - ), - ( - "format 7 without shares", - include_str!("../fixtures/state/format-7-without-shares.json"), - Start::Runs { - openvmm: reader, - maps: None, - exec: (&["--entrypoint", "/bin/echo", "--arg", "seven"], "seven\n"), - }, - ), - // Formats 2 to 6 recorded shares as they were requested, which no start reads any more: - // such a sandbox is provisioned again. - ( - "format 2", - include_str!("../fixtures/state/format-2.json"), - Start::Refused(unsupported), - ), - ( - "format 3", - include_str!("../fixtures/state/format-3.json"), - Start::Refused(unsupported), - ), - ( - "format 4", - include_str!("../fixtures/state/format-4.json"), - Start::Refused(unsupported), - ), +#[test] +fn invalid_recorded_roots_are_refused_before_spawn() { + for (field, value) in [ ( - "format 5", - include_str!("../fixtures/state/format-5.json"), - Start::Refused(unsupported), + "image", + json!({"format": "ext4", "path": "image", "identity": UUID}), ), + ("image", json!({"format": "gpt", "identity": UUID})), + ("upper", json!({"kind": "ram", "max_memory_percent": 0})), + ("upper", json!({"kind": "ram", "max_memory_percent": 91})), + ("upper", json!({"kind": "ram", "max_memory_percent": "50"})), + ("upper", json!({"kind": "ram", "max_memory_percent": 1.5})), + ("upper", json!({"kind": "ram"})), ( - "format 6", - include_str!("../fixtures/state/format-6.json"), - Start::Refused(unsupported), + "upper", + json!({"kind": "ram", "max_memory_percent": 50, "path": "scratch"}), ), - // `nvx.py` recorded no binding of the images, and its network options rather than the - // typed network. ( - "format 1 of nvx.py", - include_str!("../fixtures/state/format-1-nvx-py.json"), - Start::Refused(&[ - "sandbox configuration has no distro binding", - "provision it again", - ]), + "upper", + json!({"kind": "scratch", "path": "scratch", "max_memory_percent": 50}), ), - ] -} - -/// Returns the configuration of the fixture `text` for `root`, with its placeholders replaced. -fn render(root: &Root, text: &str) -> Value { - let layer = SandboxLayer::new( - LayerRole::Distro, - root.below("distro.erofs").to_str().unwrap(), - UUID, - ); - let distro = serde_json::to_value(DistroBinding::verify(&layer).unwrap()).unwrap(); - let scratch = - serde_json::to_value(ScratchBinding::bind(&root.below("scratch.ext4")).unwrap()).unwrap(); - let mut config = root.expand_json(&serde_json::from_str(text).unwrap()); - for member in config.as_object_mut().unwrap().values_mut() { - match member.as_str() { - Some("{distro_binding}") => *member = distro.clone(), - Some("{scratch_binding}") => *member = scratch.clone(), - Some("{hypervisor}") => *member = json!(HYPERVISOR), - _ => {} - } - } - config -} - -#[test] -fn configurations_of_every_format_start_and_exec() { - in_parallel(&formats(), |(name, text, start)| { - eprintln!("case: {name}"); + ] { let root = Root::new(); - let state = root.below("state"); - // The state directory is the one that a provision makes, private to its owner. root.nvx_rs(&provision("{root/state}", &[])) .assert_silent_success(); - let config = serde_json::to_string_pretty(&render(&root, text)).unwrap(); - root.write("state/config.json", format!("{config}\n")); - match start { - Start::Runs { - openvmm, - maps, - exec, - } => { - root.nvx_rs(&sandbox::<&str>("start", "{root/state}", &[])) - .assert_silent_success(); - let endpoint = json(&state.join("runtime.json"))["endpoint"] - .as_str() - .unwrap() - .to_owned(); - let mut options = - Options::parse(&root.openvmm_arguments("build/fake-openvmm-state.json")); - assert_stable_blocks(&root, &options.take("--microvm-sandbox-block"), true); - assert_opened_images(&root, "state"); - let mut expected = openvmm(managed("state", &endpoint)).persistent_expected(); - expected.take("--microvm-sandbox-block"); - expected.assert_matches(&root, &options, name); - let mapped = root.below("build/fake-openvmm-state-maps.json"); - match maps { - Some(table) => assert_eq!(json(&mapped), *table, "{name}"), - None => assert!(!mapped.exists(), "{name}"), - } - let outcome = root.nvx_rs(&sandbox("exec", "{root/state}", exec.0)); - outcome.assert_success(); - assert_eq!(outcome.stdout(), exec.1, "{name}"); - root.nvx_rs(&sandbox::<&str>("stop", "{root/state}", &[])) - .assert_silent_success(); - } - Start::Refused(fragments) => { - let before = root.snapshot(); - let outcome = root.nvx_rs(&sandbox::<&str>("start", "{root/state}", &[])); - let fragments: Vec = fragments - .iter() - .map(|fragment| root.expand(fragment)) - .collect(); - let fragments: Vec<&str> = fragments.iter().map(String::as_str).collect(); - outcome.assert_error("format-unsupported", &fragments); - assert_eq!(root.snapshot(), before, "{name}"); - assert_eq!(entries(&state), PROVISIONED, "{name}"); - } - } + let mut record = json(&root.below("state/config.json")); + record[field] = value.clone(); + record["scratch_binding"] = Value::Null; + root.write("state/config.json", record.to_string()); + let before = root.snapshot(); + root.nvx_rs(&sandbox::<&str>("start", "{root/state}", &[])) + .assert_error("io", &["sandbox configuration is malformed"]); + assert_eq!(root.snapshot(), before, "{field}: {value}"); + assert!(!root.below("build/fake-openvmm-state.json").exists()); root.nvx_rs(&sandbox::<&str>("deprovision", "{root/state}", &[])) .assert_silent_success(); - assert!(!state.exists(), "{name}"); - }); + } } diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index d3797de27..ac0e45ac3 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -65,20 +65,19 @@ pub(crate) fn provision(state: &str, options: &[&str]) -> Vec { sandbox("provision", state, &all) } -/// Returns the configuration that provision records for the minimal sandbox, in format 8, with +/// Returns the configuration that provision records for the minimal sandbox, in format 9, with /// placeholders, and without the bindings of its images, which [`assert_bindings`] checks. pub(crate) fn minimal_config() -> Value { json!({ "cmdline": "", "export": null, - "format": 8, + "format": 9, "hostname": "nvx-sandbox", "hypervisor": HYPERVISOR, - "layers": [{"path": "{root/distro.erofs}", "role": "distro", "uuid": UUID}], + "image": {"path": "{root/distro.erofs}", "format": "erofs", "identity": UUID}, "memory_mib": 256, "network": null, - "scratch": "{root/scratch.ext4}", - "upper": "scratch", + "upper": {"kind": "scratch", "path": "{root/scratch.ext4}"}, "workload_gid": 65534, "workload_uid": 65534, }) @@ -629,9 +628,7 @@ fn lifecycles_of_every_configuration() { in_parallel(&cases, check_lifecycle); } -/// A sandbox whose upper is in RAM needs no scratch image: provision records `ram` and neither a -/// scratch image nor its binding, and each start attaches the distro layer alone, which OpenVMM -/// opens, and asks the guest for the RAM upper. +/// A RAM upper records its default cap and no scratch image, and each start uses that cap. #[test] fn lifecycle_of_a_sandbox_whose_upper_is_in_ram() { let root = Root::new(); @@ -662,8 +659,7 @@ fn lifecycle_of_a_sandbox_whose_upper_is_in_ram() { ); let mut expected = minimal_config(); for (name, value) in [ - ("upper", json!("ram")), - ("scratch", Value::Null), + ("upper", json!({"kind": "ram", "max_memory_percent": 50})), ("scratch_binding", Value::Null), ] { expected[name] = value; @@ -1209,8 +1205,7 @@ fn start_without_openvmm(root: &Root) { fn malformed_configuration(root: &Root) { root.nvx_rs(&provision("{root/state}", &[])) .assert_silent_success(); - // Format 8 records the shares under `export`, `null` for none, and the upper under `upper`; - // format 7 records no `upper`, and format 1 records no `export` either. + // The sole typed record requires the image, the upper's own fields, and the export plan. let config = json(&root.below("state/config.json")); let mut without_export = config.clone(); without_export.as_object_mut().unwrap().remove("export"); @@ -1223,30 +1218,31 @@ fn malformed_configuration(root: &Root) { let mut format_7_with_upper = config.clone(); format_7_with_upper["format"] = json!(7); let mut other_upper = config.clone(); - other_upper["upper"] = json!("tmpfs"); + other_upper["upper"] = json!({"kind": "tmpfs"}); let mut scratch_without_image = config.clone(); - scratch_without_image["scratch"] = Value::Null; + scratch_without_image["upper"]["path"] = Value::Null; // An upper in RAM has no scratch image, nor its binding. let mut ram_with_scratch = config.clone(); - ram_with_scratch["upper"] = json!("ram"); + ram_with_scratch["upper"] = + json!({"kind": "ram", "max_memory_percent": 50, "path": "{root/scratch.ext4}"}); let mut ram_with_binding = ram_with_scratch.clone(); - ram_with_binding["scratch"] = Value::Null; + ram_with_binding["upper"] = json!({"kind": "ram", "max_memory_percent": 50}); let mut format_1_with_export = without_upper.clone(); format_1_with_export["format"] = json!(1); let unsupported = &["unsupported format", "{root/state/config.json}"][..]; - let mismatched = &["format does not match its mounts"][..]; + let missing_export = &["sandbox configuration has no export plan"][..]; let malformed = &["sandbox configuration is malformed"][..]; for (text, code, fragments) in [ ( - r#"{"format": 9}"#.to_owned(), + r#"{"format": 10}"#.to_owned(), "format-unsupported", unsupported, ), ("[]".to_owned(), "format-unsupported", unsupported), - // Only the integers 8, 7, and 1 are formats, and the configuration is strict JSON, in + // Only the integer 9 is a format, and the configuration is strict JSON, in // which no object repeats a field. ( - r#"{"format": 8.0}"#.to_owned(), + r#"{"format": 9.0}"#.to_owned(), "format-unsupported", unsupported, ), @@ -1259,7 +1255,7 @@ fn malformed_configuration(root: &Root) { ][..], ), ( - r#"{"format": 8, "format": 8}"#.to_owned(), + r#"{"format": 9, "format": 9}"#.to_owned(), "io", &[ "failed to read sandbox configuration", @@ -1267,21 +1263,37 @@ fn malformed_configuration(root: &Root) { "duplicate field \"format\"", ][..], ), - (r#"{"format": 1}"#.to_owned(), "io", malformed), - (r#"{"format": 8}"#.to_owned(), "io", malformed), + ( + r#"{"format": 1}"#.to_owned(), + "format-unsupported", + unsupported, + ), + ( + r#"{"format": 8}"#.to_owned(), + "format-unsupported", + unsupported, + ), (memory_text.to_string(), "io", malformed), (uid_beyond_32_bits.to_string(), "io", malformed), (without_upper.to_string(), "io", malformed), - (format_7_with_upper.to_string(), "io", malformed), + ( + format_7_with_upper.to_string(), + "format-unsupported", + unsupported, + ), (other_upper.to_string(), "io", malformed), (scratch_without_image.to_string(), "io", malformed), (ram_with_scratch.to_string(), "io", malformed), (ram_with_binding.to_string(), "io", malformed), - (without_export.to_string(), "format-unsupported", mismatched), + ( + without_export.to_string(), + "format-unsupported", + missing_export, + ), ( format_1_with_export.to_string(), "format-unsupported", - mismatched, + unsupported, ), ] { root.write("state/config.json", &text); diff --git a/nvx-rs/tests/cli/usage.rs b/nvx-rs/tests/cli/usage.rs index 39de86e32..ee16070b4 100644 --- a/nvx-rs/tests/cli/usage.rs +++ b/nvx-rs/tests/cli/usage.rs @@ -678,8 +678,10 @@ const HELP: [Help; 6] = [ arguments: &["sandbox", "-h"], usage: "usage: nvx-rs sandbox [{run,provision,start,exec,stop,deprovision}] [OPTIONS]", options: &[ - "--layer ROLE,PATH,EROFS_UUID", + "--layer ROLE,PATH,IMAGE_UUID", + "--layer-format {erofs,gpt}", "--upper {scratch,ram}", + "--ram-upper-percent PERCENT", "--state-dir STATE_DIR", "--dry-run", ], From 680470d281629c01524e992e356ce60e8154c1d3 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 14:15:21 -0800 Subject: [PATCH 2/4] Keep the additional-layer refusal against the typed record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aec73065-9547-4d77-a63b-a6307a637d10 --- nvx-rs/tests/cli.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index cd49157c9..1e6b64773 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -380,22 +380,18 @@ fn exec_and_stop_tell_a_directory_without_a_sandbox_from_a_stopped_sandbox() { #[test] fn a_sandbox_whose_configuration_names_another_layer_role_does_not_start() { - // A sandbox takes only the distro layer, so a configuration whose layers name another role is - // malformed: the start refuses it before OpenVMM starts, and deprovision, which reads no - // layers, still removes the sandbox. + // The typed record takes one image, not additional named layers. Injecting another role is + // refused before spawn, while deprovision still removes the sandbox. let root = Root::new(); let output = run(&root, &provision(&root, "other-role", "")); assert!(output.status.success(), "{}", text(&output.stderr)); let config = root.path.join("other-role").join("config.json"); let mut document: Value = serde_json::from_str(&fs::read_to_string(&config).unwrap()).unwrap(); - document["layers"] - .as_array_mut() - .unwrap() - .push(serde_json::json!({ - "path": root.path.join("runtime.erofs").to_str().unwrap(), - "role": "runtime", - "uuid": "22222222-2222-2222-2222-222222222222", - })); + document["layers"] = serde_json::json!([{ + "path": root.path.join("runtime.erofs").to_str().unwrap(), + "role": "runtime", + "uuid": "22222222-2222-2222-2222-222222222222", + }]); fs::write(&config, serde_json::to_string_pretty(&document).unwrap()).unwrap(); let output = run(&root, &lifecycle("start", &root, "other-role")); assert_eq!(output.status.code(), Some(1)); From 7b8d529e2aaa16215ce2e95ca5929d0223b6017b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:09:35 -0800 Subject: [PATCH 3/4] Keep provision acceptance consumers on the typed configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aec73065-9547-4d77-a63b-a6307a637d10 --- .github/workflows/run-nvx-microvm-tests.yml | 10 +++++----- nvx-rs/README.md | 10 ++++++---- scripts/nvx_tools/sandbox_ram_upper_tests.py | 10 ++++------ scripts/test_microvm_tests.py | 16 ++++++++++++++-- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-nvx-microvm-tests.yml b/.github/workflows/run-nvx-microvm-tests.yml index 23ca8fa5f..efdb67468 100644 --- a/.github/workflows/run-nvx-microvm-tests.yml +++ b/.github/workflows/run-nvx-microvm-tests.yml @@ -543,7 +543,7 @@ jobs: with open(sys.argv[1], encoding="utf-8") as stream: config = json.load(stream) - if config["format"] != 8 or config["export"]["owner"] != "caller": + if config["format"] != 9 or config["export"]["owner"] != "caller": sys.exit(f"managed sandbox did not persist caller ownership: {config}") EOF errors=$(mktemp) @@ -686,7 +686,7 @@ jobs: ("/opt/hostedtoolcache", "", "ro", ["nvx-denied"], "directory"), ("/results/output.txt", "", "rw", [], "file"), ] - if config["format"] != 8 or "mounts" in config or shares != expected: + if config["format"] != 9 or "mounts" in config or shares != expected: sys.exit(f"managed sandbox did not persist every share: {config}") EOF nvx-rs/target/release/nvx-rs sandbox start --state-dir "${state}" @@ -815,7 +815,7 @@ jobs: ["nvx-writable"], ), ] - if config["format"] != 8 or "mounts" in config or shares != expected: + if config["format"] != 9 or "mounts" in config or shares != expected: sys.exit(f"managed sandbox did not persist the access policy: {config}") EOF nvx-rs/target/release/nvx-rs sandbox start --state-dir "${state}" @@ -1117,7 +1117,7 @@ jobs: $denied = ($child.denied | ForEach-Object { $_.Substring($child.root.Length + 1).Replace("\", "/") }) -join ";" "$($bind.target),$($bind.source),$mode,$denied,$kind" }) -join " " - if ($config.format -ne 8 -or $shares -ne "/workspace,,rw,nvx-denied,directory /config/settings.json,,ro,,file /opt/hostedtoolcache,,ro,nvx-denied,directory /results/output.txt,,rw,,file") { + if ($config.format -ne 9 -or $shares -ne "/workspace,,rw,nvx-denied,directory /config/settings.json,,ro,,file /opt/hostedtoolcache,,ro,nvx-denied,directory /results/output.txt,,rw,,file") { throw "managed sandbox did not persist every share: $($config | ConvertTo-Json -Compress -Depth 5)" } try { @@ -1215,7 +1215,7 @@ jobs: function Get-Relative($paths) { ($paths | ForEach-Object { $_.Substring($child.root.Length + 1).Replace("\", "/") }) -join "," } - if ($config.format -ne 8 -or @($config.export.children).Count -ne 1 -or @($config.export.binds).Count -ne 1 -or $bind.target -ne "/workspace" -or $bind.readOnly -or (Get-Relative $child.denied) -ne "nvx-denied" -or (Get-Relative $child.allowed) -ne "nvx-denied/nvx-allowed" -or (Get-Relative $child.write) -ne "nvx-writable") { + if ($config.format -ne 9 -or @($config.export.children).Count -ne 1 -or @($config.export.binds).Count -ne 1 -or $bind.target -ne "/workspace" -or $bind.readOnly -or (Get-Relative $child.denied) -ne "nvx-denied" -or (Get-Relative $child.allowed) -ne "nvx-denied/nvx-allowed" -or (Get-Relative $child.write) -ne "nvx-writable") { throw "managed sandbox did not persist the access policy: $($config | ConvertTo-Json -Compress -Depth 5)" } try { diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3e25da9ff..bcdccb7ee 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -2859,13 +2859,15 @@ The state directory is the directory of the sandbox's sandbox shares, and `config.json` is the CLI's own record beside them. It also records the [distro binding](#distro-bindings) and the [scratch binding](#checked-handoff) of the sandbox, and its network as the -typed `SandboxNetwork`, in the serde form of the plan's network, in place of -the network options. `nvx-rs` refuses to start a sandbox that `nvx.py` or an -earlier `nvx-rs` provisioned, which has no bindings or no typed network: +typed `SandboxNetwork`, in the serde form of the plan's network. The typed +configuration names one EROFS or GPT image by path and identity, and a scratch +upper by path or a RAM upper with its cap from 1 through 90 percent, 50 by +default. `nvx-rs` refuses earlier configuration formats rather than migrating +them: | File | Contents | | --- | --- | -| `config.json` | The provisioned sandbox, in format 8, with the export plan of its shares under `export`, `null` for none, its upper under `upper`, `scratch` or `ram`, the binding of its distro layer under `distro_binding`, its scratch image under `scratch` and that image's binding under `scratch_binding`, both `null` for an upper in RAM, and its network under `network`, `null` for none, written by `serde_json`'s pretty printer with the members of every object sorted by name, with mode 0600, through a temporary file. `start` reads it as strict JSON, as egress policy files are read, into the typed fields of format 8, and also reads the format 7 that earlier builds wrote, which records no `upper` and keeps the upper on scratch, and the format 1 that they wrote for a sandbox without shares, with `mount` `null` and no `export` | +| `config.json` | The provisioned sandbox, in format 9 only: `image` holds its format, path and identity; `upper` is tagged as scratch with its path or RAM with `max_memory_percent`. `distro_binding` binds the image, and `scratch_binding` binds scratch or is `null` for RAM. The export and network are typed, `null` when absent. An atomic pretty-JSON write uses mode 0600, and start reads the same strict typed fields without a legacy reader. | | `sandbox.lock` | The store's lock, which serializes the lifecycle transitions | | `launch.json` | The store's journal of a start in progress, or of one that was interrupted | | `runtime.json` | The store's runtime record of the running VM, in its format 4: the OpenVMM process, by its ID, its start time, and the host's boot, its control endpoint, `STATE/control.sock` or `//./pipe/openvmm-microvm-HEX` on Windows, and the [runtime identity](#runtime-identity) of its start | diff --git a/scripts/nvx_tools/sandbox_ram_upper_tests.py b/scripts/nvx_tools/sandbox_ram_upper_tests.py index 0352ba015..5ecf989e4 100644 --- a/scripts/nvx_tools/sandbox_ram_upper_tests.py +++ b/scripts/nvx_tools/sandbox_ram_upper_tests.py @@ -192,15 +192,13 @@ def check_managed(self) -> None: self.require_entries(self.state, PROVISIONED_ENTRIES, "after provision") config = json.loads((self.state / CONFIG).read_text(encoding="utf-8")) recorded = { - name: config.get(name) - for name in ("format", "upper", "scratch", "scratch_binding") + name: config[name] for name in ("format", "upper", "scratch_binding") } if recorded != { - "format": 8, - "upper": "ram", - "scratch": None, + "format": 9, + "upper": {"kind": "ram", "max_memory_percent": 50}, "scratch_binding": None, - }: + } or any(name in config for name in ("layers", "scratch")): raise RuntimeError(f"provision recorded the RAM upper as {recorded!r}") self.start(description="first start") result = self.exec(PROBE, "--arg=init", "--arg=entry") diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index 1688fddde..28fc0bea3 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -2725,9 +2725,19 @@ def _provision( assert state is not None path = state / "config.json" config = json.loads(path.read_text(encoding="utf-8")) - config.update(format=8, upper="ram", scratch=None, scratch_binding=None) + _role, image, identity = values["--layer"].split(",") + config.update( + format=9, + image={"format": "erofs", "path": image, "identity": identity}, + upper={"kind": "ram", "max_memory_percent": 50}, + scratch_binding=None, + ) if "records-scratch" in self.faults: - config.update(upper="scratch", scratch="scratch.ext4") + config["upper"] = {"kind": "scratch", "path": "scratch.ext4"} + if "records-old-format" in self.faults: + config["format"] = 8 + if "records-other-cap" in self.faults: + config["upper"]["max_memory_percent"] = 51 path.write_text(json.dumps(config), encoding="utf-8") return result @@ -2900,6 +2910,8 @@ def test_acceptance_detects_ram_upper_regressions(self): "partial-teardown": "the one-shot run's teardown is", "incomplete-teardown": "the one-shot run's teardown is", "records-scratch": "provision recorded the RAM upper as", + "records-old-format": "provision recorded the RAM upper as", + "records-other-cap": "provision recorded the RAM upper as", "has-scratch-device": "has the virtio block devices ['vda', 'vdb']", "keeps-state": "the restarted sandbox's empty upper returned status 1", } From 74844c49cdc7536838a5e18633a1fcf448393958 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:35:35 -0800 Subject: [PATCH 4/4] Document typed sandbox configuration in shared-path guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aec73065-9547-4d77-a63b-a6307a637d10 --- doc/run.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/run.md b/doc/run.md index 955af4369..4f5c3c4ad 100644 --- a/doc/run.md +++ b/doc/run.md @@ -1162,13 +1162,13 @@ inside a writable part of a share. On Windows, it refuses a path that only a verbatim `\\?\` path names, such as that of a directory whose name ends in a period or a space, which the path without the prefix would not name. The shares' children follow their resolved host paths. A managed sandbox stores -the plan in its configuration, in format 8, and each `start` checks it again, +the plan in its typed configuration, in format 9. Each `start` checks it again, as `run` does once it has verified the distro layer: it refuses a host path that no longer holds a directory or regular file as recorded, a recorded path whose file identity changed, and shares whose host paths came to overlap, such -as through a hard link or a bind mount. NVX no longer reads configurations of -formats 2 to 6, which recorded the shares as requested; deprovision such a -sandbox and provision it again. +as through a hard link or a bind mount. NVX no longer reads earlier +configuration formats, including those that recorded the shares as requested; +deprovision such a sandbox and provision it again. #### Access policy