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; 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")