From a5ebddead4f7768b9263ccfb7fb88b5ef8e23988 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 4 Nov 2026 12:59:45 -0800 Subject: [PATCH 1/2] host: give a VM's machine, root, view and console one checked value Add VmConfig in the new host::config: a VM's processors, its memory in MiB, its root, blockless or layered, the View that its workload has of it, the export plan of the host paths that it exports, and where its boot console's output goes, to a listener or nowhere; the console's device exists either way. A layered root names its image and format, an optional bound of a GPT disk's first partition, its overlay mode, and a scratch upper with its journal policy or a RAM upper whose cap is 1 to 90 percent of the VM's memory, 50 by default. VmConfig::check checks the whole value without reading the host, and returns the first refusal as an Error of kind Unsupported, whose message names what it refuses; an export's refusal keeps the error of ExportPlan::check as its source. It reuses activation's journal, overlay and machine-ID vocabulary, the record's hostname rule, which becomes activation::is_hostname for both, and ExportPlan::check with the guest targets that the root reserves. Add activation::View, the hostname and machine ID that a VM's workload sees, beside WorkloadView, which the record keeps for now. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 869204bf-28f8-408e-bcab-e4353944ef69 Copilot-Session: 2d81c32c-865b-42d0-8f3e-6881809e0157 --- nvx-rs/README.md | 42 +++++- nvx-rs/public-api/linux.txt | 32 +++++ nvx-rs/public-api/windows.txt | 32 +++++ nvx-rs/src/activation/mod.rs | 22 +++- nvx-rs/src/host/config/mod.rs | 165 ++++++++++++++++++++++++ nvx-rs/src/host/config/tests.rs | 218 ++++++++++++++++++++++++++++++++ nvx-rs/src/host/mod.rs | 4 + 7 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 nvx-rs/src/host/config/mod.rs create mode 100644 nvx-rs/src/host/config/tests.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index ca60516b3..d4323c888 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -45,12 +45,13 @@ nvx-rs/ │ ├── error.rs nvx::Error, its ErrorKind, and its stable ErrorCode │ ├── exec/ the execution engine: the streams, credit, and outcome of an execution's two │ │ sides, without I/O, threads, or features, for any framing and host -│ ├── host/ feature `host` (default): the runtime that every launch shares, VM directories, -│ │ artifacts, launch, network and egress policy files, filesystem, NVXS connection, -│ │ session, process, platform, VM instances, sandbox stores, templates' manifests, -│ │ compatibility keys, store, and leases, outcome reports, file seals, and the -│ │ sandbox launch profile of `nvx-rs sandbox`, its distro and scratch bindings, -│ │ and its prepared launches; with feature `cli`, the `nvx-rs` CLI +│ ├── host/ feature `host` (default): the runtime that every launch shares, VM +│ │ directories and configurations, artifacts, launch, network and egress policy +│ │ files, filesystem, NVXS connection, session, process, platform, VM instances, +│ │ sandbox stores, templates' manifests, compatibility keys, store, and leases, +│ │ outcome reports, file seals, and the sandbox launch profile of `nvx-rs sandbox`, +│ │ its distro and scratch bindings, and its prepared launches; with feature +│ │ `cli`, the `nvx-rs` CLI │ ├── testing/ feature `testing`: the fake OpenVMM │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, the libraries through which a guest init or agent opens the @@ -122,6 +123,7 @@ changes only when the module's purpose does. | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | | [`nvx::host::vm`](#nvxhostvm) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, export, and boot console as one value, checked without reading the host | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -290,6 +292,10 @@ and a proxy endpoint without credentials; the `WorkloadView`, its hostname, and the `ExpectedResources`. The other inputs of the root's assembly are invariants of the template. +`View` is the view that a VM's configuration, a [`VmConfig`](#nvxhostconfig), +gives its workload: its hostname and `MachineIdMode`, without an identity, which +each execution names. + `ActivationRecord::new` refuses a configuration that `Configuration::check` finds past a public limit, `MAX_MAPPINGS`, `MAX_HOSTNAME_BYTES`, or `MAX_PROXY_ENDPOINT_BYTES`, which keep every record within `MAX_RECORD_BYTES`, @@ -441,6 +447,30 @@ credential that the host presents at a VM's control endpoint: `as_bytes` returns it, and its `Debug` form hides it. The library never writes it to a file; a caller that reconnects after a restart keeps it. +### `nvx::host::config` + +`VmConfig`: the configuration of a VM as one value, its processors, its memory +in MiB, its `RootConfig`, the `View` that its workload has of it, the +`ExportPlan` of the host paths that it exports, if any, and the +`ConsoleAttachment` of its boot console, a `Listener` for a client or +`Disconnected`; the console's device exists either way. + +A root is `Blockless`, the guest's own system from its initramfs, or `Layered`, +a `LayeredRoot`: the path of an image and its `RootImage`, EROFS or a GPT disk, +with the identity that the guest expects, an optional bound of a GPT disk's +first partition, the `OverlayMode`, and the `UpperConfig` of the overlay's +upper, a scratch image with its `Journal` policy, or RAM up to a cap of 1 to 90 +percent of the VM's memory, 50 when the caller has no other +(`UpperConfig::DEFAULT_MAX_MEMORY_PERCENT`). + +`VmConfig::check` checks the whole value without reading the host: it opens no +path and checks no support of the host. It fails with an `nvx::Error` of kind +`Unsupported` that names the first refusal: no processor or no memory; a layered +root without an image or a scratch path, with a bound of an EROFS image or of 0, +or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation +record refuses; or an export that `ExportPlan::check` refuses for the guest +targets that the root reserves. + ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 4952a2274..02b5bdeaf 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -226,6 +226,9 @@ field nvx::activation::Upper::Scratch::binding field nvx::activation::Upper::Scratch::journal variant nvx::activation::Upper::Tmpfs const nvx::activation::VERSION +struct nvx::activation::View +field nvx::activation::View::hostname +field nvx::activation::View::machine_id struct nvx::activation::WorkloadIdentity field nvx::activation::WorkloadIdentity::gid field nvx::activation::WorkloadIdentity::uid @@ -1746,6 +1749,35 @@ fn nvx::host::artifacts::check_manifest fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) +mod nvx::host::config +enum nvx::host::config::ConsoleAttachment +variant nvx::host::config::ConsoleAttachment::Disconnected +variant nvx::host::config::ConsoleAttachment::Listener +struct nvx::host::config::LayeredRoot +field nvx::host::config::LayeredRoot::format +field nvx::host::config::LayeredRoot::gpt_first_partition_bound +field nvx::host::config::LayeredRoot::image +field nvx::host::config::LayeredRoot::overlay +field nvx::host::config::LayeredRoot::upper +enum nvx::host::config::RootConfig +variant nvx::host::config::RootConfig::Blockless +variant nvx::host::config::RootConfig::Layered +field nvx::host::config::RootConfig::Layered::0 +enum nvx::host::config::UpperConfig +const nvx::host::config::UpperConfig::DEFAULT_MAX_MEMORY_PERCENT +variant nvx::host::config::UpperConfig::Ram +field nvx::host::config::UpperConfig::Ram::max_memory_percent +variant nvx::host::config::UpperConfig::Scratch +field nvx::host::config::UpperConfig::Scratch::journal +field nvx::host::config::UpperConfig::Scratch::path +struct nvx::host::config::VmConfig +method nvx::host::config::VmConfig::check +field nvx::host::config::VmConfig::console +field nvx::host::config::VmConfig::filesystem +field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::processors +field nvx::host::config::VmConfig::root +field nvx::host::config::VmConfig::view mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index e5e94305a..d30cf2110 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -226,6 +226,9 @@ field nvx::activation::Upper::Scratch::binding field nvx::activation::Upper::Scratch::journal variant nvx::activation::Upper::Tmpfs const nvx::activation::VERSION +struct nvx::activation::View +field nvx::activation::View::hostname +field nvx::activation::View::machine_id struct nvx::activation::WorkloadIdentity field nvx::activation::WorkloadIdentity::gid field nvx::activation::WorkloadIdentity::uid @@ -672,6 +675,35 @@ fn nvx::host::artifacts::check_manifest fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) +mod nvx::host::config +enum nvx::host::config::ConsoleAttachment +variant nvx::host::config::ConsoleAttachment::Disconnected +variant nvx::host::config::ConsoleAttachment::Listener +struct nvx::host::config::LayeredRoot +field nvx::host::config::LayeredRoot::format +field nvx::host::config::LayeredRoot::gpt_first_partition_bound +field nvx::host::config::LayeredRoot::image +field nvx::host::config::LayeredRoot::overlay +field nvx::host::config::LayeredRoot::upper +enum nvx::host::config::RootConfig +variant nvx::host::config::RootConfig::Blockless +variant nvx::host::config::RootConfig::Layered +field nvx::host::config::RootConfig::Layered::0 +enum nvx::host::config::UpperConfig +const nvx::host::config::UpperConfig::DEFAULT_MAX_MEMORY_PERCENT +variant nvx::host::config::UpperConfig::Ram +field nvx::host::config::UpperConfig::Ram::max_memory_percent +variant nvx::host::config::UpperConfig::Scratch +field nvx::host::config::UpperConfig::Scratch::journal +field nvx::host::config::UpperConfig::Scratch::path +struct nvx::host::config::VmConfig +method nvx::host::config::VmConfig::check +field nvx::host::config::VmConfig::console +field nvx::host::config::VmConfig::filesystem +field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::processors +field nvx::host::config::VmConfig::root +field nvx::host::config::VmConfig::view mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index 9a2972203..d66ca0523 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -45,7 +45,8 @@ //! - the [`WorkloadView`]: the hostname, the [`MachineIdMode`], the image's own machine ID or one //! that the guest generates, and the [`WorkloadIdentity`], if any: a UID and a GID, neither 0 //! nor `u32::MAX`, which the guest ignores as it applies the record, since each execution of a -//! clone names its own identity, as CRI containers do; +//! clone names its own identity, as CRI containers do. A VM's configuration names its hostname +//! and machine ID in a [`View`], which leaves the identity to each execution; //! - the root's settings: the [`OverlayMode`], the [`Journal`] policy of a scratch upper, and the //! bound of a GPT disk's first partition; //! - the [`ExpectedResources`]: the CPU count and the memory size that the guest must have, as a @@ -481,6 +482,16 @@ pub struct WorkloadView { pub identity: Option, } +/// The view that a VM's workload has of it: its hostname and its machine ID, without an identity +/// of the executions, which each execution names. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct View { + /// The hostname: 1 to [`MAX_HOSTNAME_BYTES`] bytes, without a NUL. + pub hostname: Vec, + /// The machine ID that the workload sees. + pub machine_id: MachineIdMode, +} + /// The resources that the guest must have, as a clone's repair produces them. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ExpectedResources { @@ -550,8 +561,7 @@ impl Configuration { { return Err(ActivationError::ProxyEndpoint); } - let hostname = &self.workload.hostname; - if hostname.is_empty() || hostname.len() > MAX_HOSTNAME_BYTES || hostname.contains(&0) { + if !is_hostname(&self.workload.hostname) { return Err(ActivationError::Hostname); } // The kernel reads `u32::MAX` as an unchanged ID, which the guest's entry refuses. @@ -745,6 +755,12 @@ impl fmt::Display for ActivationError { impl std::error::Error for ActivationError {} +/// Returns whether `hostname` is a hostname that a record or a VM's [`View`] holds: 1 to +/// [`MAX_HOSTNAME_BYTES`] bytes without a NUL, since the guest's entry sets any other. +pub(crate) fn is_hostname(hostname: &[u8]) -> bool { + !hostname.is_empty() && hostname.len() <= MAX_HOSTNAME_BYTES && !hostname.contains(&0) +} + /// Returns whether `endpoint` is a proxy endpoint that a record holds: 1 to /// [`MAX_PROXY_ENDPOINT_BYTES`] visible ASCII characters, without an `@`, which would start a /// URL's user information, where credentials go. diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs new file mode 100644 index 000000000..d3a04ba59 --- /dev/null +++ b/nvx-rs/src/host/config/mod.rs @@ -0,0 +1,165 @@ +//! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the +//! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has +//! of it, the host paths that it exports, and its [`ConsoleAttachment`]. + +use std::ops::RangeInclusive; +use std::path::PathBuf; + +use super::filesystem::{ExportPlan, ReservedTargets}; +use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; +use crate::error::{Error, Result}; +use crate::protocol::root::{ImageFormat, RootImage}; + +/// The caps of a RAM upper that [`VmConfig::check`] accepts, in percent of the VM's memory. +const RAM_UPPER_CAPS: RangeInclusive = 1..=90; + +/// The configuration of a VM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VmConfig { + /// The number of processors: at least 1. + pub processors: u32, + /// The memory, in MiB: at least 1. + pub memory_mib: u64, + /// The root that the guest runs. + pub root: RootConfig, + /// The view that the VM's workload has of it. + pub view: View, + /// The host paths that the VM exports to its guest, if any. + pub filesystem: Option, + /// Where the output of the VM's boot console goes. + pub console: ConsoleAttachment, +} + +/// The root of a VM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootConfig { + /// The guest's own system, from its initramfs, without a block device. + Blockless, + /// A read-only image under an upper, which overlayfs combines. + Layered(LayeredRoot), +} + +/// A layered root: a read-only image under an upper. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LayeredRoot { + /// The image, which OpenVMM opens by its path. + pub image: PathBuf, + /// The image's format, and the identity that the guest expects of the image, if any. + pub format: RootImage, + /// The most bytes that the first partition of a GPT disk may hold, if bounded: at least 1. An + /// EROFS image has none. + pub gpt_first_partition_bound: Option, + /// Where the overlay keeps its upper. + pub upper: UpperConfig, + /// The overlay's mode. + pub overlay: OverlayMode, +} + +/// Where a layered root keeps the upper of its overlay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpperConfig { + /// A whole-device ext4 filesystem on a scratch image. + Scratch { + /// The scratch image, which OpenVMM opens by its path. + path: PathBuf, + /// Whether the scratch's filesystem may have a journal. + journal: Journal, + }, + /// RAM, up to a cap. + Ram { + /// The most that the upper may hold, in percent of the VM's memory: 1 to 90. + max_memory_percent: u8, + }, +} + +impl UpperConfig { + /// The cap of a RAM upper that a caller sets when it has no other: half of the VM's memory. + pub const DEFAULT_MAX_MEMORY_PERCENT: u8 = 50; +} + +/// Where the output of a VM's boot console goes. The console's device exists either way. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConsoleAttachment { + /// OpenVMM listens for a client of the console. + Listener, + /// OpenVMM discards the console's output. + Disconnected, +} + +impl VmConfig { + /// Checks the whole configuration without reading the host: it opens no path, and checks no + /// support of the host. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), naming the first + /// refusal in the order of the fields: no processor; no memory; a layered root without an image + /// path, with a bound of an EROFS image's first partition or a bound of 0, with a scratch upper + /// without a path, or with a RAM upper whose cap is not 1 to 90 percent; a hostname that is + /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that + /// [`ExportPlan::check`] refuses for the root, whose error is the source. + pub fn check(&self) -> Result<()> { + if self.processors == 0 { + return Err(Error::unsupported("a VM needs at least one processor")); + } + if self.memory_mib == 0 { + return Err(Error::unsupported("a VM needs memory")); + } + let reserved = match &self.root { + RootConfig::Blockless => ReservedTargets::BLOCKLESS_ROOT, + RootConfig::Layered(root) => { + root.check()?; + ReservedTargets::CONTAINER_ROOT + } + }; + if !activation::is_hostname(&self.view.hostname) { + return Err(Error::unsupported(format!( + "the hostname is not 1 to {MAX_HOSTNAME_BYTES} bytes without a NUL" + ))); + } + if let Some(plan) = &self.filesystem { + plan.check(reserved).map_err(|error| { + Error::unsupported("the export plan is invalid").with_source(error) + })?; + } + Ok(()) + } +} + +impl LayeredRoot { + /// Checks the root's fields, as [`VmConfig::check`] describes. + fn check(&self) -> Result<()> { + if self.image.as_os_str().is_empty() { + return Err(Error::unsupported("the layered root names no image")); + } + match (self.format.format, self.gpt_first_partition_bound) { + (ImageFormat::Erofs, Some(_)) => { + return Err(Error::unsupported( + "an EROFS image has no first partition to bound, as a GPT disk has", + )); + } + (_, Some(0)) => { + return Err(Error::unsupported( + "the bound of a GPT disk's first partition is 0, but a partition holds at \ + least one block", + )); + } + _ => {} + } + match &self.upper { + UpperConfig::Scratch { path, .. } if path.as_os_str().is_empty() => Err( + Error::unsupported("the scratch upper names no scratch image"), + ), + UpperConfig::Ram { max_memory_percent } + if !RAM_UPPER_CAPS.contains(max_memory_percent) => + { + Err(Error::unsupported(format!( + "the RAM upper's cap of {max_memory_percent}% of the VM's memory is not 1 to \ + 90%" + ))) + } + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs new file mode 100644 index 000000000..b92819e19 --- /dev/null +++ b/nvx-rs/src/host/config/tests.rs @@ -0,0 +1,218 @@ +//! Tests of a VM's configuration and of its check, on paths that don't exist, since the check +//! reads nothing on the host. + +use std::error::Error as _; + +use super::*; +use crate::ErrorKind; +use crate::activation::MachineIdMode; +use crate::host::filesystem::{Bind, ExportChild, Owner}; +use crate::uuid::Uuid; + +/// Returns the path of `name` in a directory that doesn't exist. +fn missing(name: &str) -> PathBuf { + std::env::temp_dir().join("nvx-config-missing").join(name) +} + +/// Returns a blockless configuration of one processor and 512 MiB. +fn blockless() -> VmConfig { + VmConfig { + processors: 1, + memory_mib: 512, + root: RootConfig::Blockless, + view: View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }, + filesystem: None, + console: ConsoleAttachment::Listener, + } +} + +/// Returns a layered root of an EROFS image with a RAM upper of the default cap. +fn erofs() -> LayeredRoot { + LayeredRoot { + image: missing("image.erofs"), + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Ram { + max_memory_percent: UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, + }, + overlay: OverlayMode::TrustedLowers, + } +} + +/// Returns a configuration of `root`, which is otherwise [`blockless`]'s. +fn layered(root: LayeredRoot) -> VmConfig { + VmConfig { + root: RootConfig::Layered(root), + ..blockless() + } +} + +/// Returns a plan that exports one host directory read-only at `target`. +fn export(target: &str) -> ExportPlan { + ExportPlan { + children: vec![ExportChild { + root: missing("export"), + file: false, + writable: false, + denied: Vec::new(), + denied_identities: Vec::new(), + allowed: Vec::new(), + write: Vec::new(), + }], + binds: vec![Bind { + child: 0, + source: String::new(), + target: target.to_owned(), + read_only: true, + identity: None, + }], + owner: Owner::Vmm, + } +} + +#[test] +fn blockless_and_layered_configurations_pass_with_either_console() { + let gpt = LayeredRoot { + image: missing("image.gpt"), + format: RootImage::gpt(Some(Uuid::from_bytes([7; 16]))), + gpt_first_partition_bound: Some(64 * 1024), + upper: UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Forbidden, + }, + overlay: OverlayMode::UntrustedLowers, + }; + let unbounded = LayeredRoot { + gpt_first_partition_bound: None, + ..gpt.clone() + }; + for config in [ + blockless(), + layered(erofs()), + layered(gpt), + layered(unbounded), + ] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let config = VmConfig { + console, + ..config.clone() + }; + config.check().unwrap(); + } + } +} + +#[test] +fn a_ram_upper_holds_1_to_90_percent_of_the_memory_and_half_by_default() { + assert_eq!(UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, 50); + let ram = |max_memory_percent| { + layered(LayeredRoot { + upper: UpperConfig::Ram { max_memory_percent }, + ..erofs() + }) + }; + for cap in [1, UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, 90] { + ram(cap).check().unwrap(); + } + for cap in [0, 91, u8::MAX] { + let error = ram(cap).check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("RAM upper's cap"), "{error}"); + } +} + +#[test] +fn each_refusal_names_what_it_refuses() { + let view = |hostname: &[u8]| VmConfig { + view: View { + hostname: hostname.to_vec(), + machine_id: MachineIdMode::Generated, + }, + ..blockless() + }; + // The longest hostname passes. + view(&[b'h'; MAX_HOSTNAME_BYTES]).check().unwrap(); + let refused = [ + ( + VmConfig { + processors: 0, + ..blockless() + }, + "processor", + ), + ( + VmConfig { + memory_mib: 0, + ..blockless() + }, + "memory", + ), + ( + layered(LayeredRoot { + image: PathBuf::new(), + ..erofs() + }), + "no image", + ), + ( + layered(LayeredRoot { + gpt_first_partition_bound: Some(1), + ..erofs() + }), + "EROFS image", + ), + ( + layered(LayeredRoot { + format: RootImage::gpt(None), + gpt_first_partition_bound: Some(0), + ..erofs() + }), + "first partition is 0", + ), + ( + layered(LayeredRoot { + upper: UpperConfig::Scratch { + path: PathBuf::new(), + journal: Journal::Allowed, + }, + ..erofs() + }), + "no scratch image", + ), + (view(b""), "hostname"), + (view(&[b'h'; MAX_HOSTNAME_BYTES + 1]), "hostname"), + (view(b"a\0b"), "hostname"), + ]; + for (config, refusal) in refused { + let error = config.check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(refusal), "{error}"); + } +} + +#[test] +fn an_export_passes_the_check_of_the_targets_that_its_root_reserves() { + // A blockless root runs the guest's own system from /usr, which a layered root's image holds. + let usr = export("/usr/local/work"); + // A layered root's runtime stages /.nvx-agent, which a blockless root leaves free. + let agent = export("/.nvx-agent/work"); + let with = |config: VmConfig, plan: &ExportPlan| VmConfig { + filesystem: Some(plan.clone()), + ..config + }; + with(layered(erofs()), &usr).check().unwrap(); + with(blockless(), &agent).check().unwrap(); + for (config, target) in [ + (with(blockless(), &usr), "/usr"), + (with(layered(erofs()), &agent), "/.nvx-agent"), + ] { + let error = config.check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(error.to_string(), "the export plan is invalid"); + let source = error.source().expect("the export's error").to_string(); + assert!(source.contains(target), "{source}"); + } +} diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 69e47ff5c..c03af0f9c 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -6,6 +6,9 @@ //! [`RuntimeIdentity`](runtime::RuntimeIdentity), the hypervisor, which //! [`probe_hypervisor`](runtime::probe_hypervisor) checks, and the //! [`LaunchOptions`](runtime::LaunchOptions). +//! - [`config`] holds the configuration of a VM as one value, a [`VmConfig`](config::VmConfig), +//! which it checks without reading the host: the VM's processors and memory, its root, the view +//! that its workload has of it, its export, and its boot console. //! - [`launch`] builds OpenVMM's command line from a [`LaunchSpec`](launch::LaunchSpec): the //! managed lifecycle, the workload identity, the kernel command line, and the network and //! filesystem options. @@ -70,6 +73,7 @@ pub mod artifacts; #[cfg(feature = "cli")] #[doc(hidden)] pub mod cli; +pub mod config; pub mod filesystem; pub mod instance; pub mod launch; From 3bd7e0d77a88d6fe401125f05dd945406527b85f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 11:54:55 -0800 Subject: [PATCH 2/2] test-microvm: bound polling over static loopback fixtures The fixture already freezes socket tables and waits for stubbed server output. Observe a failed condition twice instead of spawning 101 awk or grep processes over immutable input. Keep all 13 cases, the 10-second subprocess timeout and production polling unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d81c32c-865b-42d0-8f3e-6881809e0157 --- scripts/test_microvm_tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index 5526b590f..aeeb7f3a2 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -5203,6 +5203,9 @@ def _run_host_loopback_allow_script( .replace( "/tmp/nvx-host-loopback-", f"{root.as_posix()}/nvx-host-loopback-" ) + # Fixture inputs never change, so repeated polling only adds + # process-launch overhead, especially with Git's Windows shell. + .replace('[ "$tries" -lt 100 ]', '[ "$tries" -lt 1 ]') # The stubbed servers finish at once; wait for their output # before releasing the guest. .replace("read -r release", "wait; read -r release")