diff --git a/doc/usage.md b/doc/usage.md index f0b6e793a..c0a78ea5c 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -343,6 +343,39 @@ boot of `nvx-rs run` does. See [Run](run.md) for artifact preparation, the security boundary, and current snapshot/configuration limitations. +### `snapshot capture` + +```sh +nvx-rs/target/release/nvx-rs snapshot capture baseline \ + --store-dir /private/snapshots --hypervisor kvm +``` + +Use `mshv` on Linux/MSHV or `whp` on Windows. The store's parent must exist; +the store and its capture VM are private to the current user. Capture uses the +identified OpenVMM, kernel and Alpine initramfs already installed in the artifact +root, without downloading substitutes or accepting `NVX_OPENVMM` overrides. + +The VM boots with `nvx_sandbox=1` and no workload or layered root. Its sparse +placeholder image is at most `--image-capacity-mib` (1024 by default), and its +dormant scratch is `--scratch-mib` (64 by default). `--processors` (1) and +`--memory-mib` (256) select boot resources; `--processor-capacity` and +`--memory-capacity-mib` may reserve larger capacities. RAM capacity must be a +multiple of 128 MiB. `--cpu-profile` selects the OpenVMM profile. + +Only a successful capture whose own VMM exits within `--timeout` (60 whole +seconds by default) can publish. The immutable named directory contains exactly +`key.bin`, `snapshot.json` and `openvmm/`; an existing name is never replaced. +Capture's own VM directory is then removed. A timed-out VM is stopped but its +capture is never finished. Other control or launch-record failures also stop +the command's verified VM without finishing it. The next capture command using +this store ends and removes an interrupted attempt through ordinary launch +recovery, without resuming or publishing it. Indeterminate recovery retains its +evidence and reports an error. Unknown files or nonempty unjournaled capture-slot +contents are kept for inspection, not deleted; an empty launch lock alone is +harmless. Known host and OpenVMM atomic staging files are recovered only with +a valid launch journal. Captures in one store serialize; snapshot locks remain +separate from their immutable directories. Restore commands are not added here. + ### `download` ```text diff --git a/nvx-rs/README.md b/nvx-rs/README.md index e885c5c9b..bf3756829 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -2519,6 +2519,7 @@ nvx-rs [-h] [--root PATH] download [--repository OWNER/REPOSITORY] [--hypervisor nvx-rs [-h] [--root PATH] egress compile PATH nvx-rs [-h] [--root PATH] run [OPTIONS] nvx-rs [-h] [--root PATH] sandbox [run|provision|start|exec|stop|deprovision] [OPTIONS] +nvx-rs [-h] [--root PATH] snapshot capture NAME [OPTIONS] ``` ```bash @@ -2569,6 +2570,15 @@ which take many options, name them `[OPTIONS]`, as in `usage: nvx-rs run [OPTIONS]`, and their help lists every option. A usage error names the parser after the usage, as in `nvx-rs run: error[CODE]: MESSAGE`. +`snapshot capture NAME --store-dir PATH` captures a blockless VM before any +layered root or workload exists. It journals the launch before spawning, +requests capture through authenticated NVXC, and requires that VM's timely +exit before finishing and publishing exactly the strict snapshot's three +entries. The next capture using that store ends an interrupted attempt through +ordinary launch recovery, never resuming it. See +[`snapshot capture`](../doc/usage.md#snapshot-capture) for its resource options +and recovery behavior. Restore commands are separate follow-ups. + ### Artifact root Every command uses the artifacts of one root: a repository checkout, with the diff --git a/nvx-rs/src/host/cli/mod.rs b/nvx-rs/src/host/cli/mod.rs index 4af6a3051..0ded2ec87 100644 --- a/nvx-rs/src/host/cli/mod.rs +++ b/nvx-rs/src/host/cli/mod.rs @@ -6,6 +6,7 @@ //! nvx-rs [-h] [--root PATH] egress compile PATH //! nvx-rs [-h] [--root PATH] run [OPTIONS] //! nvx-rs [-h] [--root PATH] sandbox [run|provision|start|exec|stop|deprovision] [OPTIONS] +//! nvx-rs [-h] [--root PATH] snapshot capture NAME [OPTIONS] //! ``` //! //! - `artifacts` prints the paths of the artifacts of the artifact root as JSON. @@ -18,6 +19,7 @@ //! exit statuses of the `nvx.py run` that it replaced. //! - `sandbox` runs or manages a sandbox, with the options, outputs, exit statuses, outcome //! reports, and state directories of the `nvx.py sandbox` that it replaced. +//! - `snapshot capture` captures a blockless VM into an immutable named snapshot. //! //! The artifact root is `--root`, or else the release or repository checkout that holds the //! executable, and the `NVX_OPENVMM` environment variable names another OpenVMM executable. @@ -97,25 +99,33 @@ pub(crate) fn hex(bytes: &[u8]) -> String { } /// The commands, in the order in which usage messages list them. -const COMMANDS: [&str; 5] = ["artifacts", "download", "egress", "run", "sandbox"]; +const COMMANDS: [&str; 6] = [ + "artifacts", + "download", + "egress", + "run", + "sandbox", + "snapshot", +]; /// The options of the top-level parser. const OPTIONS: [Spec; 1] = [Spec::one("--root")]; /// The usage and help of the top-level parser. const SYNTAX: Syntax = Syntax { command: "", - arguments: "[-h] [--root PATH] {artifacts,download,egress,run,sandbox} ...", + arguments: "[-h] [--root PATH] {artifacts,download,egress,run,sandbox,snapshot} ...", help: "\ Run NVX microVMs and sandboxes, download and look up NVX artifacts, and compile egress policies. positional arguments: - {artifacts,download,egress,run,sandbox} + {artifacts,download,egress,run,sandbox,snapshot} artifacts print the paths of the NVX artifacts as JSON download download and install the NVX release of the checkout egress compile egress policy files run run an OpenVMM microVM sandbox run or manage workloads over EROFS layers and private ext4 scratch + snapshot capture an immutable named snapshot options: -h, --help show this help message and exit @@ -334,6 +344,8 @@ enum Command { Run(Box), /// `sandbox ...`. Sandbox(Box), + /// `snapshot capture ...`. + Snapshot(snapshots::capture::Options), } /// The top-level options. @@ -389,6 +401,10 @@ fn run(arguments: &[OsString]) -> Result { let (options, unrecognized) = sandbox::parse(rest)?; (Command::Sandbox(Box::new(options)), unrecognized) } + "snapshot" => { + let (options, unrecognized) = snapshots::capture::parse(rest)?; + (Command::Snapshot(options), unrecognized) + } other => { return Err(usage( ErrorCode::ArgumentInvalid, @@ -413,6 +429,7 @@ fn run(arguments: &[OsString]) -> Result { Command::EgressCompile(path) => egress::run(&path), Command::Run(options) => run::run(root, *options), Command::Sandbox(options) => sandbox::run(root, *options), + Command::Snapshot(options) => snapshots::capture::run(root, options), } } diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 60100036e..33a6b2876 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -55,7 +55,7 @@ use self::state::RecordedDistro; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { /// `--root`, if given. - root: Option<&'a str>, + pub(super) root: Option<&'a str>, } impl Artifacts<'_> { diff --git a/nvx-rs/src/host/cli/snapshots.rs b/nvx-rs/src/host/cli/snapshots.rs index 044c8fd6c..1125b20fe 100644 --- a/nvx-rs/src/host/cli/snapshots.rs +++ b/nvx-rs/src/host/cli/snapshots.rs @@ -3,10 +3,8 @@ //! Names map to hexadecimal directory names under `snapshots/`. Each published directory //! contains only the strict snapshot's three entries; its lock stays under `locks/`. //! Completed private directories under `staging/` publish with one non-replacing rename. -#![cfg_attr( - not(test), - expect(dead_code, reason = "SL3.7b and SL3.8a use the named snapshot store") -)] + +pub(super) mod capture; use std::path::{Path, PathBuf}; diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs new file mode 100644 index 000000000..eb2235443 --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -0,0 +1,488 @@ +//! A blockless capture, kept in the ordinary launch journal until it ends. + +use std::fs; +use std::path::Path; +use std::time::Instant; + +use super::{Name, Store}; +use crate::ErrorCode; +use crate::activation::{MachineIdMode, View}; +use crate::host::cli::args::{Parser, Receiver, Spec, Syntax, choice, integer}; +use crate::host::cli::deadline::{Deadline, RETRY_INTERVAL}; +use crate::host::cli::sandbox::{ + Artifacts, HYPERVISORS, MAX_MEMORY_MIB, error_with_cause, hypervisor, +}; +use crate::host::cli::store::{LockedState, StateDir, StateFile}; +use crate::host::cli::{Exit, Result, check_interrupt, io_error, output}; +use crate::host::config::{ + BlockGeometry, Boot, Capacity, CaptureConfig, ConsoleAttachment, DiskCapture, Invariants, + RootConfig, VmConfig, +}; +use crate::host::process::{Exited, KILL_TIMEOUT, Process}; +use crate::host::runtime::{LaunchOptions, Runtime}; +use crate::host::session::{ControlSession, SessionError}; +use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, OPENVMM_DIR, finish_capture}; +use crate::host::transport; +use crate::host::vm::{self, Prepared, VmDir, VmOutputs}; + +/// The snapshot subcommand parser. +const SYNTAX: Syntax = Syntax { + command: "snapshot", + arguments: "[-h] {capture} ...", + help: "positional arguments:\n {capture} capture a named snapshot\n\noptions:\n -h, --help show this help message and exit", +}; + +/// The capture command parser. +const CAPTURE_SYNTAX: Syntax = Syntax { + command: "snapshot capture", + arguments: "[-h] --store-dir PATH [OPTIONS] NAME", + help: "\ +Capture a blockless VM before any workload or layered root exists. +The store's next capture command ends an interrupted attempt; it never resumes it. + +positional arguments: + NAME a new snapshot name + +options: + -h, --help show this help message and exit + --store-dir PATH private named snapshot store; its parent must exist + --hypervisor {auto,whp,kvm,mshv} + --cpu-profile ID OpenVMM CPU profile (default: auto) + --processors COUNT boot processors: 1, 2, 4 or 8 (default: 1) + --processor-capacity COUNT + processor capacity (default: boot processors) + --memory-mib MIB boot RAM (default: 256) + --memory-capacity-mib MIB + RAM capacity, a multiple of 128 (default: boot RAM) + --image-capacity-mib MIB + longest restore image (default: 1024) + --scratch-mib MIB dormant scratch length (default: 64) + --timeout SECONDS positive whole seconds for launch, capture and exit, + and each lock wait (default: 60)", +}; + +/// Capture's small, fixed option set. +const OPTIONS: [Spec; 10] = [ + Spec::one("--store-dir"), + Spec::one("--hypervisor"), + Spec::one("--cpu-profile"), + Spec::one("--processors"), + Spec::one("--processor-capacity"), + Spec::one("--memory-mib"), + Spec::one("--memory-capacity-mib"), + Spec::one("--image-capacity-mib"), + Spec::one("--scratch-mib"), + Spec::one("--timeout"), +]; + +/// Inputs of one capture, with no workload, mounts or caller command-line tokens. +pub(in crate::host::cli) struct Options { + /// The private store. + store_dir: String, + /// The required name, filled by the parser. + name: Option, + /// The selected backend. + hypervisor: &'static str, + /// The requested CPU profile. + cpu_profile: Option, + /// Boot processors and optional capacity. + processors: u32, + /// Maximum processors. + processor_capacity: Option, + /// Boot memory. + memory_mib: u64, + /// Maximum memory. + memory_capacity_mib: Option, + /// Maximum restore image size. + image_mib: u64, + /// Dormant scratch size. + scratch_mib: u64, + /// Bounded operation time. + timeout: u32, +} + +impl Default for Options { + fn default() -> Self { + Self { + store_dir: String::new(), + name: None, + hypervisor: "auto", + cpu_profile: None, + processors: 1, + processor_capacity: None, + memory_mib: 256, + memory_capacity_mib: None, + image_mib: 1024, + scratch_mib: 64, + timeout: 60, + } + } +} + +impl Receiver for Options { + fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { + let value = value.unwrap_or_default(); + match name { + "NAME" => { + self.name = + Some(Name::try_from(value.to_owned()).map_err(|error| error.to_string())?) + } + "--store-dir" => self.store_dir = value.to_owned(), + "--hypervisor" => self.hypervisor = choice(value, &HYPERVISORS)?, + "--cpu-profile" => self.cpu_profile = Some(value.to_owned()), + "--processors" | "--processor-capacity" => { + let count = choice(value, &["1", "2", "4", "8"])? + .parse() + .expect("processor choice"); + if name == "--processors" { + self.processors = count; + } else { + self.processor_capacity = Some(count); + } + } + "--memory-mib" => self.memory_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--memory-capacity-mib" => { + self.memory_capacity_mib = Some(integer(value, 1..=MAX_MEMORY_MIB)?) + } + "--image-capacity-mib" => self.image_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--scratch-mib" => self.scratch_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--timeout" => self.timeout = integer(value, 1..=u32::MAX)?, + _ => unreachable!("the capture parser has a fixed option set"), + } + Ok(()) + } +} + +/// Parses only the capture operation; restore belongs to later slices. +pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { + let (index, mut extras) = Parser::new(&[], &[Spec::one("snapshot_operation")]) + .split_command(arguments, &mut |_: &'static str, _: Option<&str>| Ok(())) + .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; + choice(&arguments[index], &["capture"]) + .map_err(|message| Exit::usage(&SYNTAX, ErrorCode::ArgumentInvalid, message))?; + let mut options = Options::default(); + extras.extend( + Parser::new(&OPTIONS, &[Spec::one("NAME")]) + .parse(&arguments[index + 1..], &mut options) + .map_err(|stop| Exit::from_stop(stop, &CAPTURE_SYNTAX))?, + ); + if options.store_dir.is_empty() { + return Err(Exit::usage( + &CAPTURE_SYNTAX, + ErrorCode::ArgumentMissing, + "--store-dir is required".to_owned(), + )); + } + Ok((options, extras)) +} + +/// Uses the identified artifacts, never an unlabelled OpenVMM override or a downloaded substitute. +pub(in crate::host::cli) fn run(root: Option<&str>, options: Options) -> Result { + { + let store = Store::create(Path::new(&options.store_dir))?; + let deadline = Deadline::after(f64::from(options.timeout)); + let _lock = store.root.lock(deadline)?; + remove_capture(&store, deadline)?; + } + let artifacts = Artifacts { root }; + let _hold = artifacts.hold()?; + let [openvmm, kernel, initramfs] = artifacts.require()?; + let runtime = Runtime { + openvmm: openvmm.into(), + kernel: kernel.into(), + initramfs: initramfs.into(), + identity: artifacts.launch_identity()?, + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + capture(&options, &runtime, |prepared| { + Ok(vm::spawn(prepared)?.process) + })?; + output::print_stdout("Snapshot captured").map_err(Exit::from_os_error) +} + +/// Runs a single launch. The spawn callback consumes exactly the journaled preparation. +fn capture( + options: &Options, + runtime: &Runtime, + spawn: impl FnOnce(Prepared) -> crate::Result, +) -> Result { + let store = Store::create(Path::new(&options.store_dir))?; + let deadline = Deadline::after(f64::from(options.timeout)); + // This persistent outer lock protects removal of the inner VM directory and its lock file. + let _capture_lock = store.root.lock(deadline)?; + remove_capture(&store, deadline)?; + let name = options + .name + .as_ref() + .expect("the parser requires a snapshot name"); + let named = store.lock(name, deadline)?; + match fs::symlink_metadata(&named.dir) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::Io, + "the named snapshot already exists", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(ErrorCode::Io, &error, &named.dir)), + } + let state = StateDir::create(&store.root.dir().join("capture"))?; + let mut locked = state.lock(deadline)?; + let dir = VmDir::open(state.dir())?; + let mut geometry = disk_geometry(&dir)?; + geometry.length = options.image_mib << 20; + let config = VmConfig { + processors: options.processors, + memory_mib: options.memory_mib, + root: RootConfig::Blockless, + view: View { + hostname: b"nvx-capture".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Disconnected, + boot: Some(Boot { + memory_capacity_mib: None, + command_line: "nvx_sandbox=1".to_owned(), + capture: Some(CaptureConfig { + disks: DiskCapture::Fresh { + image: geometry, + scratch: BlockGeometry { + length: options.scratch_mib << 20, + ..geometry + }, + }, + processors: Capacity { + boot: u64::from(options.processors), + capacity: u64::from(options.processor_capacity.unwrap_or(options.processors)), + }, + memory: Capacity { + boot: options.memory_mib, + capacity: options.memory_capacity_mib.unwrap_or(options.memory_mib), + }, + invariants: Invariants::new(), + }), + }), + }; + let prepared = vm::create(runtime, &config, None, &dir)?; + let context = prepared + .capture_context() + .cloned() + .expect("boot.capture was checked"); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let mut launch = locked.begin_launch(&prepared, &runtime.identity)?; + check_interrupt()?; + let process = spawn(prepared).map_err(error_with_cause)?; + let result = (|| { + launch + .record_process(process.reference()) + .map_err(error_with_cause)?; + drop(launch); + request_and_wait(&process, &outputs, &capability, deadline) + })(); + let exited = match result { + Ok(exited) => exited, + Err(Exit::Interrupted) => return Err(Exit::Interrupted), + Err(error) => { + let now = Instant::now(); + vm::stop(&process, &outputs, |_| false, now, now + KILL_TIMEOUT) + .and_then(|stopped| stopped.report.map(|_| ())) + .map_err(|cleanup| { + Exit::error( + cleanup.code(), + format!( + "{}; ending its capture VM failed: {}", + error.message().unwrap_or_default(), + error_with_cause(cleanup).message().unwrap_or_default() + ), + ) + })?; + return Err(error); + } + }; + check_interrupt()?; + finish_capture(&dir, &context, &exited).map_err(error_with_cause)?; + check_interrupt()?; + let staged = named.stage()?; + let publication: Result = (|| { + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(dir.path().join(entry), staged.dir().join(entry)) + .map_err(|error| io_error(ErrorCode::Io, &error, &dir.path().join(entry)))?; + } + named.publish(staged.clone())?; + Ok(()) + })(); + if let Err(error) = publication { + return Err(match fs::remove_dir_all(staged.dir()) { + Ok(()) => error, + Err(cleanup) if cleanup.kind() == std::io::ErrorKind::NotFound => error, + Err(cleanup) => Exit::error( + ErrorCode::Io, + format!( + "{}; cannot remove capture staging {}: {cleanup}", + error.message().unwrap_or_default(), + staged.dir().display() + ), + ), + }); + } + drop(locked); + drop(process); + remove_capture(&store, Deadline::after(KILL_TIMEOUT.as_secs_f64())).map_err(|error| { + Exit::error( + error.code().unwrap_or(ErrorCode::Io), + format!( + "snapshot was published; capture VM cleanup failed: {}", + error.message().unwrap_or_default() + ), + ) + }) +} + +/// Reads the geometry through the existing block-device helper on this VM's filesystem. +fn disk_geometry(dir: &VmDir) -> Result { + let path = dir.path().join("geometry.raw"); + let file = crate::host::store::files::create_new(&path) + .map_err(|error| io_error(ErrorCode::Io, &error, &path))?; + file.set_len(512) + .map_err(|error| io_error(ErrorCode::Io, &error, &path))?; + let geometry = crate::host::sandbox::block_geometry(&file, &path, "capture probe")?; + drop(file); + fs::remove_file(&path).map_err(|error| io_error(ErrorCode::Io, &error, &path))?; + Ok(geometry) +} + +/// Normal launch recovery must prove exit before the reserved capture directory can be removed. +fn remove_capture(store: &Store, deadline: Deadline) -> Result { + let path = store.root.dir().join("capture"); + let state = match StateDir::open(&path) { + Ok(state) => state, + Err(error) if error.code() == Some(ErrorCode::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + let journaled = state + .path(StateFile::Journal) + .try_exists() + .map_err(Exit::from_os_error)?; + for entry in fs::read_dir(&path).map_err(Exit::from_os_error)? { + let entry = entry.map_err(Exit::from_os_error)?; + let name = entry.file_name(); + let known = StateFile::ALL.iter().any(|file| name == file.name()) + || [ + "geometry.raw", + "capture-image.raw", + "capture-scratch.raw", + "console.sock", + KEY_FILE, + DESCRIPTION_FILE, + OPENVMM_DIR, + ] + .iter() + .any(|file| name == *file) + || name.to_str().is_some_and(|name| { + name.starts_with(".openvmm") || (journaled && capture_temporary(name)) + }); + let empty_lock = name == StateFile::Lock.name() + && entry.metadata().map_err(Exit::from_os_error)?.len() == 0; + if !known || name == StateFile::Config.name() || (!journaled && !empty_lock) { + return Err(Exit::error( + ErrorCode::Io, + format!( + "refusing to remove unowned capture content {}", + entry.path().display() + ), + )); + } + } + let locked: LockedState = state.lock(deadline)?; + if locked.runtime()?.is_some() { + return Err(Exit::error( + ErrorCode::Io, + "a capture directory cannot hold a committed VM", + )); + } + drop(locked); + fs::remove_dir_all(&path).map_err(|error| io_error(ErrorCode::Io, &error, &path)) +} + +fn capture_temporary(name: &str) -> bool { + if let Some(random) = name.strip_prefix(".tmp") { + return random.len() == 6 && random.bytes().all(|byte| byte.is_ascii_alphanumeric()); + } + let Some((file, suffix)) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + .and_then(|name| name.rsplit_once('.')) + else { + return false; + }; + [ + StateFile::Journal.name(), + StateFile::Capability.name(), + StateFile::Outcome.name(), + KEY_FILE, + DESCRIPTION_FILE, + ] + .contains(&file) + && suffix + .parse::() + .is_ok_and(|value| value.to_string() == suffix) +} + +/// Uses authentication and the actual held process, never the capture acknowledgement as exit. +fn request_and_wait( + process: &Process, + outputs: &VmOutputs, + capability: &[u8; crate::protocol::CAPABILITY_LEN], + deadline: Deadline, +) -> Result { + let until = deadline.instant(); + let transport = transport::connect_verified(&outputs.endpoint, process.reference(), until); + check_interrupt()?; + let attached = ControlSession::attach(transport.map_err(error_with_cause)?, capability, until); + check_interrupt()?; + let requested = attached.map_err(control_error)?.capture(false, until); + check_interrupt()?; + requested.map_err(control_error)?; + loop { + check_interrupt()?; + let now = Instant::now(); + if deadline.passed() { + return Err(Exit::error( + ErrorCode::TimedOut, + "capture VM did not exit before its deadline", + )); + } + let exited = process + .wait(until.min(now + RETRY_INTERVAL)) + .map_err(Exit::from_os_error)?; + if let Some(exited) = exited { + if deadline.passed() { + return Err(Exit::error( + ErrorCode::TimedOut, + "capture VM exit was not confirmed before its deadline", + )); + } + return Ok(exited); + } + } +} + +/// Keeps control failures explicit, including the distinction between timeout and I/O. +fn control_error(error: SessionError) -> Exit { + let code = match error { + SessionError::TimedOut => ErrorCode::TimedOut, + SessionError::Io(_) | SessionError::Refused(_) => ErrorCode::Io, + SessionError::Closed | SessionError::ProcessExited => ErrorCode::Exited, + _ => ErrorCode::Protocol, + }; + Exit::error(code, format!("capture control failed: {error}")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs new file mode 100644 index 000000000..dc9b04c87 --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -0,0 +1,717 @@ +#![cfg(any(target_os = "linux", windows))] + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::*; +use crate::host::platform; +use crate::host::process::{Opened, ProcessRef}; +use crate::host::snapshot::tests::{ManifestSpec, SnapshotFixture, class}; +use crate::host::snapshot::{MANIFEST_NAME, Snapshot}; +use crate::host::spawn::{self, VmmCommand}; +use crate::protocol::{ + APP_CAPTURE, APP_ERROR, APP_READY, CAPABILITY_LEN, OUTER_DATA, OUTER_HEADER_LEN, + OUTER_HOST_ATTACH, OUTER_READY, decode_app, decode_outer_header, encode_app, encode_outer, +}; + +const HELPER: &str = "host::cli::snapshots::capture::tests::capture_helper"; +const COMMAND_HELPER: &str = "host::cli::snapshots::capture::tests::command_helper"; + +type Inventory = Vec<(PathBuf, (u64, u128), u64, std::time::SystemTime)>; + +struct Fixture { + root: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + Self { + root: tempfile::tempdir().unwrap(), + } + } + + fn options(&self, name: &str) -> Options { + options(self.root.path(), name) + } + + fn capture(&self, name: &str, behavior: &str, timeout: u32) -> Result { + let mut options = self.options(name); + options.timeout = timeout; + capture(&options, &runtime(self.root.path()), |prepared| { + Ok(fake_spawn(self.root.path(), prepared, behavior)) + }) + } + + fn store(&self) -> Store { + Store::create(&self.root.path().join("store")).unwrap() + } + + fn vm_dir(&self) -> PathBuf { + self.root.path().join("store").join("capture") + } + + fn references(&self) -> Vec { + match fs::read(self.root.path().join("processes.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("cannot read owned process receipts: {error}"), + } + } + + fn existing(&self) { + let store = self.store(); + let held = store + .lock(&name("existing"), Deadline::after(10.0)) + .unwrap(); + let staged = held.stage().unwrap(); + let fixture = SnapshotFixture::new(); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.join(entry), staged.dir().join(entry)).unwrap(); + } + held.publish(staged).unwrap(); + } + + fn inventory(&self) -> Inventory { + let dir = self + .root + .path() + .join("store") + .join("snapshots") + .join("6578697374696e67"); + [ + KEY_FILE, + DESCRIPTION_FILE, + "openvmm/manifest.bin", + "openvmm/memory.bin", + "openvmm/state.bin", + ] + .into_iter() + .map(|entry| { + let path = dir.join(entry); + let metadata = fs::metadata(&path).unwrap(); + let id = platform::file_id(&path).unwrap(); + (path, id, metadata.len(), metadata.modified().unwrap()) + }) + .collect() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + for reference in self.references() { + if let Opened::Running(process) = Process::open(&reference).unwrap() { + if process.wait(Instant::now()).unwrap().is_none() { + process.kill().unwrap(); + } + assert!( + process + .wait(Instant::now() + KILL_TIMEOUT) + .unwrap() + .is_some() + ); + } + } + } +} + +fn name(text: &str) -> Name { + Name::try_from(text.to_owned()).unwrap() +} + +fn options(root: &Path, snapshot: &str) -> Options { + let arguments = [ + "capture".to_owned(), + snapshot.to_owned(), + "--store-dir".to_owned(), + root.join("store").to_str().unwrap().to_owned(), + "--processors".to_owned(), + "2".to_owned(), + "--memory-mib".to_owned(), + "512".to_owned(), + "--memory-capacity-mib".to_owned(), + "2048".to_owned(), + ]; + let (options, extras) = parse(&arguments).unwrap(); + assert!(extras.is_empty()); + options +} + +fn runtime(root: &Path) -> Runtime { + Runtime { + openvmm: std::env::current_exe().unwrap(), + kernel: root.join("fake-kernel"), + initramfs: root.join("fake-initramfs"), + identity: class().runtime, + hypervisor: if cfg!(windows) { + crate::Hypervisor::Whp + } else { + crate::Hypervisor::Kvm + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +fn fake_spawn(root: &Path, prepared: Prepared, behavior: &str) -> Process { + let dir = prepared.outputs().log.parent().unwrap(); + let journal: Value = + serde_json::from_slice(&fs::read(dir.join("launch.json")).unwrap()).unwrap(); + assert!( + journal["process"].is_null(), + "spawn must follow the durable prepared barrier" + ); + assert_eq!(journal["log"], prepared.outputs().log.to_str().unwrap()); + assert_eq!( + journal["outcome"], + prepared.outputs().outcome.to_str().unwrap() + ); + assert_eq!(journal["endpoint"], prepared.outputs().endpoint); + assert_eq!( + fs::read(dir.join("control.capability")).unwrap(), + prepared.capability().as_bytes() + ); + assert_eq!( + journal["capability"], + json!(prepared.capability().as_bytes()) + ); + assert!(journal["console"].is_null()); + assert!(!dir.join("runtime.json").exists()); + let command_line = prepared + .arguments() + .windows(2) + .find(|pair| pair[0] == "--cmdline") + .unwrap()[1] + .to_str() + .unwrap(); + assert_eq!( + command_line + .split_whitespace() + .filter(|token| *token == "nvx_sandbox=1") + .count(), + 1 + ); + let context = prepared.capture_context().unwrap(); + let DiskCapture::Fresh { image, scratch } = context.capture.disks; + let mut manifest = ManifestSpec::template(); + let contract = manifest.contract(); + contract.source_hypervisor = runtime(root).hypervisor.to_string(); + contract.blocks[0].physical_block_size = image.physical_block_size; + contract.blocks[1].physical_block_size = scratch.physical_block_size; + let recipe = json!({ + "root": root, "endpoint": prepared.outputs().endpoint, "behavior": behavior, + "manifest": manifest.encode(), "memory": manifest.memory_size, "state": manifest.state_size, + }); + fs::write( + root.join("helper.json"), + serde_json::to_vec(&recipe).unwrap(), + ) + .unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + dir, + ); + let child = spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(); + let process = child.process().unwrap(); + let path = root.join("processes.json"); + let mut references: Vec = if path.exists() { + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap() + } else { + Vec::new() + }; + references.push(process.reference().clone()); + fs::write(path, serde_json::to_vec(&references).unwrap()).unwrap(); + child.detach(); + drop(prepared); + process +} + +fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(20); + while !path.exists() { + assert!(Instant::now() < until, "missing receipt {}", path.display()); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn capture_parser_refuses_restore_bad_names_and_invalid_options() { + for arguments in [ + vec!["restore", "name"], + vec!["capture", "../escape", "--store-dir", "store"], + vec!["capture", "name"], + vec!["capture", "name", "--store-dir", "store", "--timeout", "0"], + vec![ + "capture", + "name", + "--store-dir", + "store", + "--processors", + "3", + ], + ] { + assert!( + parse( + &arguments + .iter() + .map(|value| (*value).to_owned()) + .collect::>() + ) + .is_err() + ); + } + assert!(matches!( + parse(&["capture".to_owned(), "--help".to_owned()]), + Err(Exit::Help(_)) + )); + CAPTURE_SYNTAX.assert_lists(&OPTIONS); +} + +#[test] +fn capture_publishes_only_three_strict_entries_after_its_own_exit_and_removes_its_vm() { + let fixture = Fixture::new(); + fixture.capture("first", "publish", 20).unwrap(); + let store = fixture.store(); + let held = store.lock(&name("first"), Deadline::after(10.0)).unwrap(); + let snapshot = held.open().unwrap(); + assert_eq!( + snapshot.key().runtime(), + &runtime(fixture.root.path()).identity + ); + let entries: Vec<_> = fs::read_dir(&held.dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries.len(), 3); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + assert!(entries.iter().any(|name| name == entry)); + } + assert!(Snapshot::open(&held.dir).is_ok()); + assert!(!fixture.vm_dir().exists()); + let receipt: Value = + serde_json::from_slice(&fs::read(fixture.root.path().join("ack.json")).unwrap()).unwrap(); + assert_eq!(receipt["capture"], true); + assert_eq!(receipt["finalizedWhileAlive"], false); + assert_eq!(receipt["pid"], fixture.references()[0].pid); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); +} + +#[test] +fn duplicate_names_and_a_failed_new_capture_never_change_an_existing_snapshot() { + let fixture = Fixture::new(); + fixture.existing(); + let before = fixture.inventory(); + let options = fixture.options("existing"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert_eq!(fixture.inventory(), before); + assert!(!fixture.vm_dir().exists()); + assert_eq!( + fixture.capture("failed", "fail", 20).unwrap_err().code(), + Some(ErrorCode::Exited) + ); + let store = fixture.store(); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 1); + assert_eq!(fixture.inventory(), before); + assert!( + store + .lock(&name("existing"), Deadline::after(10.0)) + .unwrap() + .open() + .is_ok() + ); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert!(!fixture.vm_dir().exists()); + assert_eq!(fixture.inventory(), before); +} + +#[test] +fn a_capture_ack_and_successful_output_without_timely_exit_are_stopped_but_never_finished() { + let fixture = Fixture::new(); + let error = fixture.capture("late", "hang", 1).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::TimedOut), "{error:?}"); + assert!(fixture.root.path().join("ack.json").exists()); + assert!(fixture.vm_dir().join(OPENVMM_DIR).exists()); + assert!(fixture.vm_dir().join("outcome.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(!fixture.vm_dir().join(DESCRIPTION_FILE).exists()); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + fixture.existing(); + let options = fixture.options("existing"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert!( + !fixture.vm_dir().exists(), + "later exit must only permit recovery, not finishing" + ); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 1 + ); +} + +#[test] +fn a_capture_rejection_stops_its_verified_vm_without_finishing_or_publishing() { + let fixture = Fixture::new(); + let error = fixture.capture("refused", "refuse", 20).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::Protocol), "{error:?}"); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(!fixture.vm_dir().join(DESCRIPTION_FILE).exists()); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); +} + +#[test] +fn unjournaled_user_content_is_preserved_before_any_capture_slot_mutation() { + for entry in [ + "personal.txt", + KEY_FILE, + OPENVMM_DIR, + ".openvmm-personal", + ".tmpAb3xY9", + ".outcome.json.7.tmp", + ] { + let fixture = Fixture::new(); + let store = fixture.store(); + StateDir::create(&fixture.vm_dir()).unwrap(); + let path = fixture.vm_dir().join(entry); + let data = if entry == OPENVMM_DIR { + StateDir::create(&path).unwrap(); + path.join("personal.txt") + } else { + path + }; + fs::write(&data, b"preserve user content").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("first")).unwrap_err(); + assert!(error.message().unwrap().contains("unowned capture content")); + assert_eq!(fs::read(data).unwrap(), b"preserve user content"); + assert!(!fixture.vm_dir().join("sandbox.lock").exists()); + assert!(!fixture.vm_dir().join("launch.json").exists()); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 0); + } +} + +struct OwnedCommand(Child); + +impl Drop for OwnedCommand { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } +} + +fn interrupted(fixture: &Fixture) { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", COMMAND_HELPER, "--ignored", "--nocapture"]) + .env("NVX_CAPTURE_COMMAND_ROOT", fixture.root.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = OwnedCommand(crate::host::tests::spawn_locked(&mut command).unwrap()); + wait_file(&fixture.root.path().join("ack.json")); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); +} + +#[test] +fn the_next_command_ends_an_interrupted_capture_and_removes_its_directory_without_publishing() { + let fixture = Fixture::new(); + fixture.existing(); + let before = fixture.inventory(); + interrupted(&fixture); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("existing")).unwrap_err(); + let opened = Process::open(&fixture.references()[0]).unwrap(); + assert!( + matches!(&opened, Opened::Exited(_)), + "next-command recovery returned {error:?}; process is {opened:?}" + ); + assert!(!fixture.vm_dir().exists()); + assert_eq!(fixture.inventory(), before); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 1 + ); +} + +#[test] +fn interrupted_atomic_outputs_are_recovered_only_after_their_vm_exits() { + for entry in [ + ".launch.json.0.tmp", + ".control.capability.18446744073709551615.tmp", + ".outcome.json.7.tmp", + ".key.bin.0.tmp", + ".snapshot.json.1.tmp", + ".tmpAb3xY9", + ] { + let fixture = Fixture::new(); + interrupted(&fixture); + fs::write(fixture.vm_dir().join(entry), b"interrupted atomic output").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("next")).unwrap_err(); + let opened = Process::open(&fixture.references()[0]).unwrap(); + assert!( + matches!(&opened, Opened::Exited(_)), + "{entry}: recovery returned {error:?}; process is {opened:?}" + ); + assert!(!fixture.vm_dir().exists(), "{entry}: {error:?}"); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + } +} + +#[test] +fn journaled_unknown_and_near_miss_names_are_preserved_without_recovery_or_publication() { + for entry in [ + ".tmp12345", + ".tmp1234567", + ".tmpabcd\u{e9}", + ".outcome.json..tmp", + ".outcome.json.-1.tmp", + ".outcome.json.+1.tmp", + ".outcome.json.01.tmp", + ".outcome.json.18446744073709551616.tmp", + ".config.json.1.tmp", + ".runtime.json.1.tmp", + ".personal.txt.1.tmp", + "config.json", + ] { + let fixture = Fixture::new(); + interrupted(&fixture); + let journal = fs::read(fixture.vm_dir().join("launch.json")).unwrap(); + let capability = fs::read(fixture.vm_dir().join("control.capability")).unwrap(); + let path = fixture.vm_dir().join(entry); + fs::write(&path, b"preserve user content").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("next")).unwrap_err(); + assert!( + error.message().unwrap().contains("unowned capture content"), + "{entry}: {error:?}" + ); + assert_eq!(fs::read(path).unwrap(), b"preserve user content"); + assert_eq!( + fs::read(fixture.vm_dir().join("launch.json")).unwrap(), + journal + ); + assert_eq!( + fs::read(fixture.vm_dir().join("control.capability")).unwrap(), + capability + ); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + } +} + +#[test] +fn indeterminate_recovery_retains_the_live_vm_its_capability_and_journal() { + let fixture = Fixture::new(); + interrupted(&fixture); + let journal_path = fixture.vm_dir().join("launch.json"); + let mut journal: Value = serde_json::from_slice(&fs::read(&journal_path).unwrap()).unwrap(); + journal["process"]["pid"] = json!(0); + fs::write(&journal_path, serde_json::to_vec(&journal).unwrap()).unwrap(); + let journal = fs::read(&journal_path).unwrap(); + let capability = fs::read(fixture.vm_dir().join("control.capability")).unwrap(); + fs::write(fixture.vm_dir().join(".tmpAb3xY9"), b"owned report staging").unwrap(); + let options = fixture.options("next"); + let error = capture(&options, &runtime(fixture.root.path()), |_| { + panic!("recovery must refuse") + }) + .unwrap_err(); + assert!( + error + .message() + .unwrap() + .contains("cannot verify the recorded VM process"), + "{error:?}" + ); + assert_eq!(fs::read(journal_path).unwrap(), journal); + assert_eq!( + fs::read(fixture.vm_dir().join("control.capability")).unwrap(), + capability + ); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); +} + +#[test] +#[ignore = "owned subprocess of interrupted-capture command tests"] +fn command_helper() { + let root = PathBuf::from(std::env::var_os("NVX_CAPTURE_COMMAND_ROOT").unwrap()); + let result = capture(&options(&root, "abandoned"), &runtime(&root), |prepared| { + Ok(fake_spawn(&root, prepared, "hang")) + }); + panic!("the test must interrupt this capture: {result:?}"); +} + +fn read_record(stream: &mut (impl Read + Write)) -> (u8, Vec) { + let mut header = [0; OUTER_HEADER_LEN]; + stream.read_exact(&mut header).unwrap(); + let header = decode_outer_header(&header).unwrap(); + assert!(header.length <= crate::protocol::OUTER_MAX_PAYLOAD); + let mut payload = vec![0; header.length]; + stream.read_exact(&mut payload).unwrap(); + (header.record_type, payload) +} + +fn respond(stream: &mut (impl Read + Write), capability: &[u8], refused: bool) { + let (kind, payload) = read_record(stream); + assert_eq!(kind, OUTER_HOST_ATTACH); + assert_eq!(payload, capability); + stream + .write_all(&encode_outer(OUTER_READY, &[7; 16], 1, 0, &[]).unwrap()) + .unwrap(); + let (kind, payload) = read_record(stream); + assert_eq!(kind, OUTER_DATA); + let request = decode_app(&payload).unwrap(); + assert_eq!(request.kind, APP_CAPTURE); + assert_eq!(request.payload, [0]); + let reply = if refused { + encode_app(APP_ERROR, request.request_id, 22, b"capture-failed") + } else { + encode_app(APP_READY, request.request_id, 0, &[]) + }; + stream + .write_all(&encode_outer(OUTER_DATA, &[7; 16], 1, 1, &reply).unwrap()) + .unwrap(); +} + +#[test] +#[ignore = "authenticated fake VMM subprocess; not real-VM capture proof"] +fn capture_helper() { + let mut capability = Vec::new(); + std::io::stdin().read_to_end(&mut capability).unwrap(); + assert_eq!(capability.len(), CAPABILITY_LEN); + assert_eq!(fs::read("control.capability").unwrap(), capability); + let directory = std::env::current_dir().unwrap(); + let recipe_path = directory + .parent() + .unwrap() + .parent() + .unwrap() + .join("helper.json"); + let recipe: Value = serde_json::from_slice(&fs::read(recipe_path).unwrap()).unwrap(); + let endpoint = recipe["endpoint"].as_str().unwrap(); + let refused = recipe["behavior"] == "refuse"; + #[cfg(target_os = "linux")] + { + let server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); + let (mut stream, _) = server.accept().unwrap(); + respond(&mut stream, &capability, refused); + } + #[cfg(windows)] + { + let mut server = platform::tests::control_server(endpoint); + platform::tests::accept_control(&server); + respond(&mut server, &capability, refused); + } + let journal: Value = serde_json::from_slice(&fs::read("launch.json").unwrap()).unwrap(); + assert_eq!(journal["process"]["pid"], std::process::id()); + assert_eq!(journal["endpoint"], endpoint); + let root = PathBuf::from(recipe["root"].as_str().unwrap()); + fs::write(root.join("ack.json"), serde_json::to_vec(&json!({ + "capture": true, "pid": std::process::id(), + "finalizedWhileAlive": Path::new(KEY_FILE).exists() || Path::new(DESCRIPTION_FILE).exists(), + })).unwrap()).unwrap(); + vm::create_private_dir(Path::new(OPENVMM_DIR)).unwrap(); + let manifest: Vec = serde_json::from_value(recipe["manifest"].clone()).unwrap(); + crate::host::store::files::write_private( + &Path::new(OPENVMM_DIR).join(MANIFEST_NAME), + &manifest, + ) + .unwrap(); + if refused { + thread::sleep(Duration::from_secs(120)); + return; + } + for (entry, field) in [("memory.bin", "memory"), ("state.bin", "state")] { + let file = + crate::host::store::files::create_new(&Path::new(OPENVMM_DIR).join(entry)).unwrap(); + platform::make_sparse(&file).unwrap(); + file.set_len(recipe[field].as_u64().unwrap()).unwrap(); + } + let failed = recipe["behavior"] == "fail"; + crate::host::store::files::write_private(Path::new("outcome.json"), &serde_json::to_vec(&json!({ + "outcome": { "operation": "capture", "category": if failed { "failure" } else { "success" }, + "status_code": if failed { 1 } else { 0 } }, + })).unwrap()).unwrap(); + if recipe["behavior"] == "hang" { + thread::sleep(Duration::from_secs(120)); + } else { + thread::sleep(Duration::from_millis(100)); + assert!( + !Path::new(KEY_FILE).exists(), + "ACK and completed output are not exit proof" + ); + assert!(!Path::new(DESCRIPTION_FILE).exists()); + } +} diff --git a/nvx-rs/src/host/session.rs b/nvx-rs/src/host/session.rs index b5d388a90..8fb8c1f77 100644 --- a/nvx-rs/src/host/session.rs +++ b/nvx-rs/src/host/session.rs @@ -168,11 +168,8 @@ impl ControlSession { /// Asks nvx-init to capture the VM, killing its executions first when requested. #[cfg_attr( - not(test), - expect( - dead_code, - reason = "SL3.7b requests capture once nvx-init handles it in SL7.7a" - ) + not(any(test, feature = "cli")), + expect(dead_code, reason = "NVX's capture command requires the cli feature") )] pub(crate) fn capture( &mut self, diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 157ae9784..3938ca914 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2382,7 +2382,8 @@ fn a_relative_openvmm_override_is_relative_to_the_working_directory() { #[test] fn top_level_usage_errors_exit_with_status_2() { - let usage = "usage: nvx-rs [-h] [--root PATH] {artifacts,download,egress,run,sandbox} ...\n"; + let usage = + "usage: nvx-rs [-h] [--root PATH] {artifacts,download,egress,run,sandbox,snapshot} ...\n"; for (arguments, message) in [ ( &[][..], @@ -2395,7 +2396,7 @@ fn top_level_usage_errors_exit_with_status_2() { ( &["build"][..], "nvx-rs: error[argument-invalid]: argument command: invalid choice: \"build\" (choose \ - from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\")\n", + from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\", \"snapshot\")\n", ), ( &["artifacts", "--bogus"][..], @@ -2405,7 +2406,7 @@ fn top_level_usage_errors_exit_with_status_2() { ( &["--prog", "nvx.py", "sandbox"][..], "nvx-rs: error[argument-invalid]: argument command: invalid choice: \"nvx.py\" \ - (choose from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\")\n", + (choose from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\", \"snapshot\")\n", ), ( &["--prog=nvx.py", "artifacts"][..], diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 347e17ac4..2b0a9cc65 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -12,7 +12,6 @@ "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, "nvx-rs/src/host/cli/sandbox/state.rs": 1, - "nvx-rs/src/host/cli/snapshots.rs": 1, "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1,