diff --git a/doc/usage.md b/doc/usage.md index 01746cffe..8954e5e8d 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -383,7 +383,60 @@ evidence and reports an error. Unknown files or nonempty unjournaled capture-slo 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. +separate from their immutable directories. [`snapshot restore`](#snapshot-restore) +starts managed sandboxes from them. + +### `snapshot restore` + +```sh +nvx-rs/target/release/nvx-rs snapshot restore baseline \ + --store-dir /private/snapshots --state-dir /private/sandboxes/restored \ + --layer distro,/images/distro.erofs,UUID --upper ram --hypervisor kvm +``` + +`snapshot restore` provisions a managed sandbox in `--state-dir` with the +options of [`sandbox provision`](#sandbox), but for those that only an +execution or a cold boot takes, `--cwd`, `--environment`, `--environment-file`, +`--inherit-default-environment`, `--exec-timeout-ms`, `--wait`, +`--outcome-report`, `--cmdline`, and `--dry-run`, then starts it from the named +snapshot of `--store-dir`. The sandbox's configuration records the snapshot, +its store, and the sandbox's workload, `--entrypoint` with its `--arg` values, +and each later `sandbox start` of the sandbox restores the snapshot again. Each +restore runs the workload over the control protocol, as `run` and `exec` do, +not from the kernel command line, so its entrypoint and arguments may contain +whitespace and double quotes, which `sandbox provision` refuses; the command +checks them as `exec` checks its workload before it provisions anything. The +state directory must be outside the store. The guest of a restore accepts only +a fresh scratch image, whose upper no earlier root wrote, such as a new empty +ext4 image, so a later start of a restored sandbox with a scratch image refuses +once an earlier start has created the sandbox's view of the snapshot, as each +start does before its guest can use the image: deprovision the sandbox and +restore the snapshot again with a fresh one. A missing snapshot provisions +nothing; a sandbox whose start fails stays provisioned, as after +`sandbox provision`. + +A restore attaches the sandbox's image and upper in place of the snapshot's +placeholder image and dormant scratch: the image must fit the placeholder, +with its block sizes, and a scratch upper must be as long as the dormant +scratch. The VM keeps the processors that the snapshot had online, and +`--memory-mib` must be a multiple of 128 between the snapshot's boot memory and +its capacity. The snapshot fixes the NIC: one that `snapshot capture` captured +has none, so its restores take no `--net`. A restore of a snapshot with a NIC +installs the restore's own network policy, whose DNS server and listener +announcements the guest applies as it activates. + +Each start holds the snapshot's exclusive lock, which it waits for up to +`--timeout`, from before it links the snapshot's files into the sandbox's view +of it, `openvmm/` in the state directory, until the start ends or the VM has +exited, and `sandbox deprovision` holds it while it removes the view, so it +needs the snapshot's store: concurrent restores of one snapshot start one at a +time. Once the guest is ready, it maps the sandbox's shares and runs the +workload, whose output the command forwards, and waits for it to exit with +status 0 within `--timeout`, as the default workload, `/bin/sh`, does at once; +any other end fails the start, which ends the VM. The sandbox then runs as a +started one does, for `sandbox exec` and `sandbox stop`. A start journals its +launch as a cold start does, so a restore whose command dies before the guest +is ready never completes, and the next command of the sandbox ends its VM. ### `download` diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 32db5aac9..5acf67c51 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -2560,6 +2560,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] +nvx-rs [-h] [--root PATH] snapshot restore NAME [OPTIONS] ``` ```bash @@ -2617,7 +2618,24 @@ 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. +and recovery behavior. + +`snapshot restore NAME --store-dir PATH --state-dir PATH` provisions a managed +sandbox, with the options of `sandbox provision` that a restore takes, whose +configuration also records the snapshot, its store, and the sandbox's workload, +then starts it: that start, and each later one, creates the VM with +`host::vm::create` from the snapshot, through the store's launch journal, and +drives its restore with the blocking restore driver, which activates the guest +with the start's own network policy, maps the sandbox's shares, and runs the +workload, whose output the start forwards until it exits. A restore's guest +accepts only a fresh upper, so a later start refuses a scratch image once an +earlier start has created its view, as each start does before its guest can use +the image. Each start holds the snapshot's exclusive lock from before it links +the snapshot's files into the sandbox's `openvmm/` view until the start ends or +the VM has exited, and `deprovision` holds it while it removes the view; a start +whose command dies leaves a journal whose recovery ends the VM. No index of a +snapshot's sandboxes or record of a restore in progress exists. See +[`snapshot restore`](../doc/usage.md#snapshot-restore). ### Artifact root @@ -2868,7 +2886,7 @@ them: | File | Contents | | --- | --- | -| `config.json` | The provisioned sandbox, in format 9 only: `image` holds its format, path and identity; `upper` is tagged as scratch with its path or RAM with `max_memory_percent`. `distro_binding` binds the image, and `scratch_binding` binds scratch or is `null` for RAM. The export and network are typed, `null` when absent. An atomic pretty-JSON write uses mode 0600, and start reads the same strict typed fields without a legacy reader. | +| `config.json` | The provisioned sandbox, in format 9 only: `image` holds its format, path and identity; `upper` is tagged as scratch with its path or RAM with `max_memory_percent`. `distro_binding` binds the image, and `scratch_binding` binds scratch or is `null` for RAM. The export and network are typed, `null` when absent. A sandbox that `snapshot restore` provisioned also records its snapshot's name under `source_snapshot`, the absolute path of the snapshot's store under `source_store`, and the program and arguments of the workload that each restore runs under `workload`; one that boots cold records none of them. An atomic pretty-JSON write uses mode 0600, and start reads the same strict typed fields without a legacy reader. | | `sandbox.lock` | The store's lock, which serializes the lifecycle transitions | | `exec.lock` | The exec lock, which an `exec` holds from before it finds the running VM until its workload ends, so that one `exec` at a time runs in the sandbox; it stays from the first `exec` until `deprovision` | | `launch.json` | The store's journal of a start in progress, or of one that was interrupted | @@ -2877,6 +2895,8 @@ them: | `control.sock` | The control endpoint on POSIX hosts | | `openvmm.log` | OpenVMM's output, which each start replaces | | `outcome.json` | OpenVMM's outcome report of the last start | +| `openvmm/` | A restored sandbox's view of its snapshot, hard links to the files of the snapshot's `openvmm/`, which a start creates and `deprovision` removes, each under the snapshot's lock | +| `restore-ready.sock` | On Linux, the endpoint of a restore's restore-ready event, which the next start of a restored sandbox, or its `deprovision`, removes under the snapshot's lock | `provision`, `start`, `stop`, and `deprovision` hold the store's lock for their whole transition, and `exec` only while it finds the running VM, so that a long diff --git a/nvx-rs/src/host/cli/mod.rs b/nvx-rs/src/host/cli/mod.rs index 69580d2e9..dd01ce08e 100644 --- a/nvx-rs/src/host/cli/mod.rs +++ b/nvx-rs/src/host/cli/mod.rs @@ -7,6 +7,7 @@ //! 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] +//! nvx-rs [-h] [--root PATH] snapshot restore NAME [OPTIONS] //! ``` //! //! - `artifacts` prints the paths of the artifacts of the artifact root as JSON. @@ -19,7 +20,8 @@ //! 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. +//! - `snapshot capture` captures a blockless VM into an immutable named snapshot, and +//! `snapshot restore` starts a new managed sandbox from one. //! //! 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. @@ -126,7 +128,8 @@ positional arguments: run run an OpenVMM microVM sandbox run or manage workloads over EROFS layers and private ext4 scratch - snapshot capture an immutable named snapshot + snapshot capture an immutable named snapshot, or restore a + managed sandbox from one options: -h, --help show this help message and exit @@ -345,8 +348,8 @@ enum Command { Run(Box), /// `sandbox ...`. Sandbox(Box), - /// `snapshot capture ...`. - Snapshot(snapshots::capture::Options), + /// `snapshot capture ...` or `snapshot restore ...`. + Snapshot(snapshots::Command), } /// The top-level options. @@ -403,8 +406,8 @@ fn run(arguments: &[OsString]) -> Result { (Command::Sandbox(Box::new(options)), unrecognized) } "snapshot" => { - let (options, unrecognized) = snapshots::capture::parse(rest)?; - (Command::Snapshot(options), unrecognized) + let (command, unrecognized) = snapshots::parse(rest)?; + (Command::Snapshot(command), unrecognized) } other => { return Err(usage( @@ -430,7 +433,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), + Command::Snapshot(command) => snapshots::run(root, command), } } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index c79519624..ed1b61918 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -31,7 +31,7 @@ use crate::host::cli::artifacts::{Located, locate}; use crate::host::cli::deadline::RETRY_INTERVAL; use crate::host::cli::sandbox::lock::LifecycleLock; use crate::host::cli::sandbox::state::{ - Config, RecordedDistro, RecordedScratch, read_config, serialize_launch, write_json, + Config, RecordedDistro, RecordedScratch, Source, read_config, serialize_launch, write_json, }; use crate::host::cli::sandbox::{Artifacts, error_with_cause}; use crate::host::cli::session::Session; @@ -76,12 +76,14 @@ pub(crate) const REQUIRED_FEATURES: GuestFeatures = GuestFeatures::LAYERED_SANDB /// Records a launch and its distro layer, at the path at which it was verified, with the layer's /// binding, in a new state directory, as `provision` does, with the binding of its scratch image, -/// if its upper is on scratch, at the path that it records for it. +/// if its upper is on scratch, at the path that it records for it, and `source`, the named +/// snapshot that each start of a sandbox of `snapshot restore` restores, or `None`. pub(crate) fn provision( state_path: &str, launch: &SandboxLaunch, distro: &RecordedDistro, config: &ProvisionConfig<'_>, + source: Option<&Source>, timeout: f64, ) -> Result { let state_dir = prepare_state_directory(state_path, true)?; @@ -98,7 +100,7 @@ pub(crate) fn provision( let scratch = RecordedScratch::new(launch)?; write_json( &config_path, - &serialize_launch(launch, distro, scratch.as_ref(), config, None)?, + &serialize_launch(launch, distro, scratch.as_ref(), config, source)?, ) } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs index 3e93d8e2d..a863bb2fa 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/start/tests/restore.rs @@ -9,13 +9,17 @@ use serde_json::{Value, json}; use super::super::*; use crate::activation::{ActivationRecord, Image, Network, Publication, ReadyActivation, Upper}; +use crate::host::artifacts::{ + ArtifactDir, Guest, INITRAMFS_PROVENANCE_NAME, KERNEL_PROVENANCE_NAME, OPENVMM_PROVENANCE_NAME, +}; use crate::host::cli::hex; use crate::host::cli::sandbox::lifecycle::{ProvisionConfig, deprovision}; use crate::host::cli::sandbox::state::{RecordedDistro, serialize_launch, write_json}; -use crate::host::cli::snapshots::Name; +use crate::host::cli::snapshots::{self, Name}; use crate::host::cli::store::StateDir; use crate::host::filesystem::{Bind, ExportChild, ExportPlan, Owner}; use crate::host::network::{Access, NetworkPolicy}; +use crate::host::paths::resolve; use crate::host::process::{Opened, Process, ProcessRef}; use crate::host::sandbox::distro::DistroBinding; use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; @@ -600,6 +604,102 @@ fn a_restored_start_refuses_a_share_of_its_snapshot_store() { assert!(fixture.spawned().is_empty()); } +/// Returns an artifact root with the fakes and provenance whose presence a start checks before +/// it launches anything, which no start of these tests runs. +fn artifact_root(root: &Path) -> String { + let dir = ArtifactDir::from_dir(root).unwrap(); + for (artifact, provenance, field) in [ + (dir.openvmm(), OPENVMM_PROVENANCE_NAME, "executable_sha256"), + (dir.kernel(), KERNEL_PROVENANCE_NAME, "kernel_sha256"), + ( + dir.initramfs(Guest::Alpine), + INITRAMFS_PROVENANCE_NAME, + "initramfs_sha256", + ), + ] { + for (path, contents) in [ + (artifact, "fake".to_owned()), + ( + dir.artifact(provenance), + json!({ "format": 1, field: "ab".repeat(32) }).to_string(), + ), + ] { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + } + root.to_str().unwrap().to_owned() +} + +/// `snapshot restore` provisions a sandbox whose configuration records the snapshot, the store as +/// the command resolved it, and the workload, an exec request that may hold whitespace and +/// quotes, which no boot token can, and starts it as a restore of that snapshot: a start with +/// memory that the snapshot refuses, which only a restore checks, fails before it launches or +/// links anything, and leaves the sandbox provisioned. +#[test] +fn snapshot_restore_records_its_source_and_starts_a_restore_of_it() { + let fixture = Fixture::new(false); + let root = artifact_root(&fixture.state("checkout")); + let layer = fixture.state("distro.erofs"); + fs::write(&layer, "distro").unwrap(); + let store = fixture.root.path().join("store"); + let state = fixture.state("command"); + let workload = ["/bin/echo", "a \"restored\" sandbox"]; + let arguments = [ + "restore", + "source", + "--store-dir", + store.to_str().unwrap(), + "--state-dir", + state.to_str().unwrap(), + "--layer", + &format!( + "distro,{},11111111-1111-1111-1111-111111111111", + layer.display() + ), + "--upper", + "ram", + "--hypervisor", + if cfg!(windows) { "whp" } else { "kvm" }, + "--memory-mib", + "2176", + "--entrypoint", + workload[0], + "--arg", + workload[1], + "--timeout", + "10", + ] + .map(str::to_owned); + let (command, unrecognized) = snapshots::parse(&arguments).unwrap(); + assert!(unrecognized.is_empty(), "{unrecognized:?}"); + let error = snapshots::run(Some(&root), command).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::Unsupported), "{error:?}"); + let message = error.message().unwrap_or_default(); + assert!( + message.contains("snapshot memory must be from boot"), + "{message}" + ); + let record = read_config(&state.join(CONFIG_NAME)).unwrap(); + assert_eq!(record["source_snapshot"], "source"); + assert_eq!(record["workload"], json!(workload)); + assert_eq!( + Config::new(record).unwrap().source().unwrap(), + Some(Source { + snapshot: name(), + store: resolve(&store).unwrap(), + workload: workload.map(str::to_owned).to_vec(), + }) + ); + assert_eq!(fixture.view("command"), 0); + assert!(fixture.snapshot_free()); + let artifacts = Artifacts { + root: Some(root.as_str()), + }; + deprovision(state.to_str().unwrap(), &artifacts, 5.0).unwrap(); + assert!(!state.exists()); +} + /// Reads one record of the control endpoint, or `None` once the host has gone. fn read_record(stream: &mut impl Read) -> Option<(u8, Vec)> { let mut header = [0; OUTER_HEADER_LEN]; diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 8fed77427..d7fbc23be 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -44,10 +44,11 @@ pub(super) use self::lifecycle::{copy_openvmm_outcome, validate_outcome_destinat pub(super) use self::one_shot::run_openvmm; pub(super) use self::options::sandbox_network; pub(super) use self::options::{ - HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, OPTIONS, hypervisor, parse, path_text, }; pub(crate) use self::options::{Options, path_string}; use self::state::RecordedDistro; +pub(crate) use self::state::Source; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { @@ -133,6 +134,25 @@ pub(crate) fn error_with_cause(error: crate::Error) -> Exit { /// Runs the operation of `options`, as `command_sandbox` did. pub(super) fn run(root: Option<&str>, options: Options) -> Result { + run_from(root, options, None) +} + +/// Provisions the sandbox of `options`, whose operation is `provision`, with `source`, the named +/// snapshot that each of its starts restores, then starts it, as `snapshot restore` does. +pub(super) fn restore(root: Option<&str>, options: Options, source: Source) -> Result { + let (state_dir, timeout) = (options.state_dir.clone(), options.timeout); + run_from(root, options, Some(&source))?; + lifecycle::start( + state_dir + .as_deref() + .expect("provision required --state-dir"), + &Artifacts { root }, + timeout, + ) +} + +/// Runs the operation of `options`, recording `source` with the sandbox that it provisions. +fn run_from(root: Option<&str>, options: Options, source: Option<&Source>) -> Result { let operation = options.operation; if !matches!( operation, @@ -198,9 +218,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { workload_identity: options.workload_user, ..SandboxSettings::default() }; - // A run's workload is its exec request, which no boot token carries, and which its control - // session checks. - if operation == SandboxOperation::Run { + // The workload of a run, and that of a restore, is an exec request, which no boot token + // carries: a run's control session checks it, and `snapshot restore` a restore's. + if operation == SandboxOperation::Run || source.is_some() { let workload = SandboxSettings::default(); (settings.entrypoint, settings.args) = (workload.entrypoint, workload.args); } @@ -283,6 +303,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { cmdline: &options.cmdline, ram_upper_percent: options.ram_upper_percent, }, + source, options.timeout, ) } diff --git a/nvx-rs/src/host/cli/snapshots.rs b/nvx-rs/src/host/cli/snapshots.rs index 9456f727d..d7abb378b 100644 --- a/nvx-rs/src/host/cli/snapshots.rs +++ b/nvx-rs/src/host/cli/snapshots.rs @@ -1,4 +1,4 @@ -//! Immutable named snapshots and their external, exclusive locks. +//! Immutable named snapshots and their external, exclusive locks, and the `snapshot` command. //! //! Names map to hexadecimal directory names under `snapshots/`. Each published directory //! contains only the strict snapshot's three entries; its lock stays under `locks/`. @@ -7,6 +7,10 @@ //! Each start of a restored sandbox links the snapshot's files into the sandbox's view of it //! under the snapshot's lock, which [`LockedSnapshot::remove_view`] also takes to remove the //! view. +//! +//! `snapshot capture` publishes a named snapshot, as [`capture`] describes. `snapshot restore` +//! provisions a managed sandbox whose configuration records the snapshot, its store and a +//! workload, then starts it, and each later start of the sandbox restores the snapshot again. pub(super) mod capture; @@ -16,14 +20,222 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use super::args::{Parser, Receiver, Spec, Syntax, choice}; use super::deadline::Deadline; +use super::sandbox::{self, Source}; +use super::session::{ExecRequest, Session}; use super::store::{LockedState, StateDir}; use super::{Exit, Result, hex, io_error, temporary_path}; +use crate::host::sandbox::options::SandboxOperation; use crate::host::snapshot::{OPENVMM_DIR, SNAPSHOT_ARTIFACTS, Snapshot}; use crate::host::vm::VmDir; use crate::host::{filesystem, paths, platform, store}; use crate::{Error, ErrorCode}; +/// The parser of the snapshot command. +const SYNTAX: Syntax = Syntax { + command: "snapshot", + arguments: "[-h] {capture,restore} ...", + help: "\ +positional arguments: + {capture,restore} + capture capture a named snapshot + restore restore a managed sandbox from a named snapshot + +options: + -h, --help show this help message and exit", +}; + +/// The parser of the restore command. +const RESTORE_SYNTAX: Syntax = Syntax { + command: "snapshot restore", + arguments: "[-h] --store-dir PATH --state-dir PATH [OPTIONS] NAME", + help: "\ +Provision a managed sandbox with the options of sandbox provision, then start +it from a named snapshot, under the snapshot's lock, and run its workload, the +entrypoint and its arguments, which must exit with status 0 within the +timeout. Each later start of the sandbox restores the snapshot again. + +positional arguments: + NAME the snapshot's name + +options: + -h, --help show this help message and exit + --store-dir PATH the named snapshot store + --state-dir STATE_DIR + --layer ROLE,PATH,IMAGE_UUID + --layer-format {erofs,gpt} + --scratch SCRATCH + --upper {scratch,ram} + --ram-upper-percent PERCENT + --entrypoint ENTRYPOINT + --arg SANDBOX_ARG + --hostname HOSTNAME + --workload-user UID:GID + --memory-mib MEMORY_MIB + --timeout TIMEOUT + --hypervisor {auto,whp,kvm,mshv} + --mount GUEST_TARGET,HOST_PATH[,ro|rw] + --mount-deny HOST_PATH + --mount-allow HOST_PATH + --mount-write HOST_PATH + --mount-owner {vmm,caller} + --net IPV4/PREFIX + --network-profile {portable} + --network-egress {allow,deny} + --network-ingress {allow,deny} + --network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]] + --network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]] + --network-egress-policy-file PATH + --host-loopback {allow,deny} + --network-proxy IPV4:TCP-PORT + --host-loopback-forward HOST_LOOPBACK_FORWARD", +}; + +/// The options of `sandbox` that only an execution or a cold boot takes, which a restore lacks. +const NOT_RESTORED: [&str; 9] = [ + "--cwd", + "--environment", + "--environment-file", + "--inherit-default-environment", + "--exec-timeout-ms", + "--wait", + "--outcome-report", + "--cmdline", + "--dry-run", +]; + +/// A parsed `snapshot` command. +pub(super) enum Command { + /// `snapshot capture ...`. + Capture(capture::Options), + /// `snapshot restore ...`. + Restore(Box), +} + +/// The options of `snapshot restore`: the snapshot's name and store, and those of the sandbox +/// that it provisions. +pub(super) struct RestoreOptions { + /// `NAME`, which the parser requires. + name: Option, + /// `--store-dir`, which the parser requires. + store_dir: String, + /// The options of the sandbox, whose operation is `provision`. + sandbox: sandbox::Options, +} + +impl Receiver for RestoreOptions { + fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { + let text = value.unwrap_or_default().to_owned(); + match name { + "NAME" => self.name = Some(Name::try_from(text).map_err(|error| error.to_string())?), + "--store-dir" => self.store_dir = text, + _ => self.sandbox.take(name, value)?, + } + Ok(()) + } +} + +/// Parses a `snapshot` command, and returns it with the arguments that its parser does not +/// recognize. +pub(super) fn parse(arguments: &[String]) -> Result<(Command, 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))?; + let operation = choice(&arguments[index], &["capture", "restore"]) + .map_err(|message| Exit::usage(&SYNTAX, ErrorCode::ArgumentInvalid, message))?; + let arguments = &arguments[index + 1..]; + if operation == "capture" { + let (options, unrecognized) = capture::parse(arguments)?; + extras.extend(unrecognized); + return Ok((Command::Capture(options), extras)); + } + let mut options = RestoreOptions { + name: None, + store_dir: String::new(), + sandbox: sandbox::Options { + operation: SandboxOperation::Provision, + ..sandbox::Options::default() + }, + }; + extras.extend( + Parser::new(&restore_options(), &[Spec::one("NAME")]) + .parse(arguments, &mut options) + .map_err(|stop| Exit::from_stop(stop, &RESTORE_SYNTAX))?, + ); + for (missing, option) in [ + (options.store_dir.is_empty(), "--store-dir"), + (options.sandbox.state_dir.is_none(), "--state-dir"), + ] { + if missing { + return Err(Exit::usage( + &RESTORE_SYNTAX, + ErrorCode::ArgumentMissing, + format!("{option} is required"), + )); + } + } + Ok((Command::Restore(Box::new(options)), extras)) +} + +/// Returns the options of `snapshot restore`: those of `sandbox`, but those that only an +/// execution or a cold boot takes, and `--store-dir`. +fn restore_options() -> Vec { + sandbox::OPTIONS + .into_iter() + .filter(|spec| !NOT_RESTORED.contains(&spec.name)) + .chain([Spec::one("--store-dir")]) + .collect() +} + +/// Runs a `snapshot` command. +pub(super) fn run(root: Option<&str>, command: Command) -> Result { + match command { + Command::Capture(options) => capture::run(root, options), + Command::Restore(options) => restore(root, *options), + } +} + +/// Restores a new managed sandbox from a named snapshot, as `snapshot restore` does: opens the +/// snapshot, so that a missing one provisions nothing, then provisions the sandbox with its +/// source, whose workload is the sandbox's entrypoint and arguments, and starts it. Before it +/// provisions anything, it refuses a state directory in the store, as each start and deprovision +/// of the sandbox refuses one before it removes anything, and a workload that is not a valid exec +/// request: each restored start sends the workload as one, not as boot tokens, so it may contain +/// whitespace, which `sandbox provision` refuses. +fn restore(root: Option<&str>, options: RestoreOptions) -> Result { + let RestoreOptions { + name, + store_dir, + sandbox: options, + } = options; + let snapshot = name.expect("the parser requires a snapshot name"); + let store = paths::resolve(Path::new(&store_dir)).map_err(Exit::from_os_error)?; + outside_store( + &store, + Path::new(options.state_dir.as_deref().unwrap_or_default()), + )?; + let workload: Vec = std::iter::once(options.entrypoint.clone()) + .chain(options.args.iter().cloned()) + .collect(); + Session::exec_payload(&ExecRequest { + arguments: workload.clone(), + timeout_ms: 0, + cwd: None, + environment: None, + inherit_default_environment: false, + })?; + Store::open(&store)? + .lock(&snapshot, Deadline::after(options.timeout))? + .open()?; + let source = Source { + snapshot, + store, + workload, + }; + sandbox::restore(root, options, source) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String")] pub(crate) struct Name(String); diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs index eb2235443..2bb9e58a2 100644 --- a/nvx-rs/src/host/cli/snapshots/capture.rs +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -25,13 +25,6 @@ use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, OPENVMM_DIR, finish_capt 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", @@ -153,19 +146,12 @@ impl Receiver for Options { } } -/// Parses only the capture operation; restore belongs to later slices. +/// Parses the options of a capture, which follow `capture`. 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))?, - ); + let extras = Parser::new(&OPTIONS, &[Spec::one("NAME")]) + .parse(arguments, &mut options) + .map_err(|stop| Exit::from_stop(stop, &CAPTURE_SYNTAX))?; if options.store_dir.is_empty() { return Err(Exit::usage( &CAPTURE_SYNTAX, diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index 98848a887..9baf978b7 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -165,7 +165,6 @@ fn name(text: &str) -> Name { 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(), @@ -298,20 +297,12 @@ fn wait_file(path: &Path) { } #[test] -fn capture_parser_refuses_restore_bad_names_and_invalid_options() { +fn capture_parser_refuses_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", - ], + vec!["../escape", "--store-dir", "store"], + vec!["name"], + vec!["name", "--store-dir", "store", "--timeout", "0"], + vec!["name", "--store-dir", "store", "--processors", "3"], ] { assert!( parse( @@ -323,10 +314,7 @@ fn capture_parser_refuses_restore_bad_names_and_invalid_options() { .is_err() ); } - assert!(matches!( - parse(&["capture".to_owned(), "--help".to_owned()]), - Err(Exit::Help(_)) - )); + assert!(matches!(parse(&["--help".to_owned()]), Err(Exit::Help(_)))); CAPTURE_SYNTAX.assert_lists(&OPTIONS); } diff --git a/nvx-rs/src/host/cli/snapshots/tests.rs b/nvx-rs/src/host/cli/snapshots/tests.rs index c0b7aae5d..ea980aa71 100644 --- a/nvx-rs/src/host/cli/snapshots/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/tests.rs @@ -237,6 +237,177 @@ fn shared_directory_acls_are_refused_before_publication() { assert!(!held.dir.exists()); } +/// Parses the `snapshot` command line `arguments`. +fn command(arguments: &[&str]) -> Result<(Command, Vec)> { + parse( + &arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect::>(), + ) +} + +#[test] +fn the_snapshot_parser_selects_capture_or_a_restore_with_the_options_of_a_provision() { + let Ok((Command::Capture(_), extras)) = command(&["capture", "name", "--store-dir", "store"]) + else { + panic!("capture parses"); + }; + assert!(extras.is_empty()); + let layer = "distro,image.gpt,11111111-1111-1111-1111-111111111111"; + let Ok((Command::Restore(options), extras)) = command(&[ + "restore", + "name", + "--store-dir", + "store", + "--state-dir", + "state", + "--layer", + layer, + "--layer-format", + "gpt", + "--upper", + "ram", + "--ram-upper-percent", + "25", + "--entrypoint", + "/bin/echo", + "--arg", + "restored", + "--workload-user", + "1000:1001", + ]) else { + panic!("restore parses"); + }; + assert!(extras.is_empty()); + assert_eq!(options.name, Some(name("name"))); + assert_eq!(options.store_dir, "store"); + let sandbox = options.sandbox; + assert_eq!(sandbox.operation, SandboxOperation::Provision); + assert_eq!(sandbox.state_dir.as_deref(), Some("state")); + assert_eq!(sandbox.layers.len(), 1); + // The restored sandbox's typed record takes provision's image format and RAM upper cap. + assert_eq!( + sandbox.layer_format, + Some(crate::host::sandbox::distro::DistroFormat::Gpt) + ); + assert_eq!(sandbox.upper, Some(crate::protocol::root::RootUpper::Ram)); + assert_eq!(sandbox.ram_upper_percent, Some(25)); + assert_eq!( + (sandbox.entrypoint.as_str(), sandbox.args.as_slice()), + ("/bin/echo", ["restored".to_owned()].as_slice()) + ); + assert_eq!(sandbox.workload_user, (1000, 1001)); + // A restore needs its snapshot, its store, and its sandbox's state directory. + for arguments in [ + &["restore", "name", "--state-dir", "state"][..], + &["restore", "name", "--store-dir", "store"], + &["restore", "--store-dir", "store", "--state-dir", "state"], + &[ + "restore", + "../escape", + "--store-dir", + "store", + "--state-dir", + "state", + ], + &["bogus"], + ] { + let Err(Exit::Usage { code, .. }) = command(arguments) else { + panic!("{arguments:?} is a usage error"); + }; + let expected = if arguments.contains(&"../escape") || arguments == ["bogus"] { + ErrorCode::ArgumentInvalid + } else { + ErrorCode::ArgumentMissing + }; + assert_eq!(code, expected, "{arguments:?}"); + } + // A restore's sandbox runs no execution and boots nothing cold, so it takes none of their + // options. + for option in NOT_RESTORED { + let Ok((_, extras)) = command(&[ + "restore", + "name", + "--store-dir", + "store", + "--state-dir", + "state", + option, + ]) else { + panic!("{option} parses as an unrecognized argument"); + }; + assert_eq!(extras, [option], "{option}"); + } + for arguments in [ + &["--help"][..], + &["restore", "--help"], + &["capture", "--help"], + ] { + assert!( + matches!(command(arguments), Err(Exit::Help(_))), + "{arguments:?}" + ); + } + RESTORE_SYNTAX.assert_lists(&restore_options()); +} + +#[test] +fn a_restore_refuses_a_state_directory_in_its_snapshot_store_before_it_provisions() { + let root = tempfile::tempdir().unwrap(); + let store = root.path().join("store"); + let state = store.join("snapshots").join(hex(b"first")); + let Ok((Command::Restore(options), _)) = command(&[ + "restore", + "first", + "--store-dir", + store.to_str().unwrap(), + "--state-dir", + state.to_str().unwrap(), + ]) else { + panic!("restore parses"); + }; + let error = restore(None, *options).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::Unsupported), "{error:?}"); + assert!(!store.exists()); +} + +#[test] +fn a_restore_refuses_a_workload_that_is_not_a_valid_exec_request_before_it_provisions() { + let root = tempfile::tempdir().unwrap(); + let store = root.path().join("store"); + let state = root.path().join("state"); + let restore_of = [ + "restore", + "first", + "--store-dir", + store.to_str().unwrap(), + "--state-dir", + state.to_str().unwrap(), + ]; + for (workload, expected) in [ + ( + &["--entrypoint", "echo"][..], + "managed exec entrypoint must be absolute", + ), + ( + &["--arg", "x"].repeat(64)[..], + "managed exec requires 1 through 64 arguments", + ), + ] { + let Ok((Command::Restore(options), _)) = command(&[&restore_of[..], workload].concat()) + else { + panic!("restore parses"); + }; + let error = restore(None, *options).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::Unsupported), "{error:?}"); + let message = error.message().unwrap_or_default(); + assert!(message.contains(expected), "{message}"); + } + assert!(!store.exists()); + assert!(!state.exists()); +} + /// Returns a store with the published snapshot `first`, and a VM directory with a view of it. fn restored_view(root: &Path) -> (Store, VmDir) { fs::create_dir_all(root).unwrap();