Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,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) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment |
| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore |
| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified metadata, VM compatibility, captured NIC addresses, and time eligibility |
| [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity |
| [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener |
| [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity |
Expand Down Expand Up @@ -586,6 +586,34 @@ manifest's, and the canonical key is consistent with that manifest.
`KEY_FILE`, `DESCRIPTION_FILE` and `OPENVMM_DIR` name the root entries.
The description must exist, but its contents are never read, parsed or
trusted. `Snapshot::key` and `manifest` expose the verified metadata.
Opening also checks that a captured NIC address fits `NicConfig`, refusing
its network, gateway and broadcast addresses with `Io`, and caches that
checked value for the infallible `nic` accessor.

`Snapshot::accepts(&config, &runtime, &now)` checks a layered restore against
the captured boot resources and capacity. Processor counts are 1, 2, 4 or 8;
an encoded boot count of zero means all capacity processors are already
online. Memory is between boot and capacity, in multiples of 128 MiB, with
checked conversion from MiB to bytes. The image fits the placeholder and
keeps its logical and physical block sizes; a scratch upper matches the
dormant scratch's complete geometry. These checks read the requested disks'
real metadata, not the snapshot entries again.

NIC presence must match, and an explicitly named address equals the captured
one; an unnamed address inherits it. `Snapshot::nic` returns only the captured
presence and address as an `Option<NicConfig>`, never a network policy.
Each restore supplies its own policy. The boot console retains its captured
attachment policy: `recreate-listener` for a listener, or
`discard-while-disconnected` for a disconnected console. A mismatched,
missing or unknown policy is refused. These facts come from the same
validated manifest parse; the control listener and console device do not
prove that attachment. The runtime uses the same backend and captured CPU
profile, and has the same runtime identity. An omitted profile or `auto`
names the snapshot's profile; `host` also names a captured host-derived
profile. Requested disks must be regular files; on Linux, the geometry
check refuses a FIFO without waiting for its writer.
Compatibility refusals are `Unsupported`; an ineligible time contract at
the supplied `HostTime` is `Unavailable`.

The caller keeps the directory and every entry private and immutable while
using the snapshot; opening checks them once and retains no file handles
Expand Down
2 changes: 2 additions & 0 deletions nvx-rs/public-api/linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3087,8 +3087,10 @@ const nvx::host::snapshot::MAX_DOWNTIME_NS
const nvx::host::snapshot::MAX_MANIFEST_LEN
const nvx::host::snapshot::OPENVMM_DIR
struct nvx::host::snapshot::Snapshot
method nvx::host::snapshot::Snapshot::accepts
method nvx::host::snapshot::Snapshot::key
method nvx::host::snapshot::Snapshot::manifest
method nvx::host::snapshot::Snapshot::nic
method nvx::host::snapshot::Snapshot::open
struct nvx::host::snapshot::SnapshotKey
const nvx::host::snapshot::SnapshotKey::MAX_LEN
Expand Down
2 changes: 2 additions & 0 deletions nvx-rs/public-api/windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1673,8 +1673,10 @@ const nvx::host::snapshot::MAX_DOWNTIME_NS
const nvx::host::snapshot::MAX_MANIFEST_LEN
const nvx::host::snapshot::OPENVMM_DIR
struct nvx::host::snapshot::Snapshot
method nvx::host::snapshot::Snapshot::accepts
method nvx::host::snapshot::Snapshot::key
method nvx::host::snapshot::Snapshot::manifest
method nvx::host::snapshot::Snapshot::nic
method nvx::host::snapshot::Snapshot::open
struct nvx::host::snapshot::SnapshotKey
const nvx::host::snapshot::SnapshotKey::MAX_LEN
Expand Down
213 changes: 209 additions & 4 deletions nvx-rs/src/host/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! [`SnapshotKey`] derives a compatibility key from a capture's class and manifest.
//! [`SnapshotManifest::read`] reads the manifest that OpenVMM wrote, and
//! [`TimeContract::eligibility`] checks its restore window at a [`HostTime`].
//! [`Snapshot::accepts`] checks a requested VM against that verified metadata.
//!
//! # Canonical encoding
//!
Expand Down Expand Up @@ -40,9 +41,14 @@ use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::host::config::{
ConsoleAttachment, NicConfig, PROCESSOR_COUNTS, RootConfig, UpperConfig, VmConfig,
};
use crate::host::paths::{absolute, normalized};
use crate::host::runtime::Runtime;
use crate::host::store::regular;
use crate::host::{filesystem, platform};
use crate::host::{filesystem, platform, sandbox, seal};
use crate::net::Ipv4Cidr;
use crate::{Error, Result};

pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS};
Expand Down Expand Up @@ -74,6 +80,8 @@ pub struct Snapshot {
key: SnapshotKey,
/// The strictly parsed metadata of OpenVMM's snapshot.
manifest: SnapshotManifest,
/// The captured NIC's checked address, without a network policy.
nic: Option<NicConfig>,
}

impl Snapshot {
Expand All @@ -91,8 +99,8 @@ impl Snapshot {
/// the returned snapshot. This check retains no open file handles or lock.
///
/// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) for an unreadable or invalid directory,
/// entry, length or key relationship, and otherwise as [`SnapshotManifest::read`] and
/// [`SnapshotKey::from_bytes`] do.
/// entry, length, key relationship or captured NIC address, and otherwise as
/// [`SnapshotManifest::read`] and [`SnapshotKey::from_bytes`] do.
pub fn open(dir: &Path) -> Result<Self> {
let dir = absolute(&normalized(dir)).map_err(|error| entry_error(dir, error))?;
check_directory(&dir, &[KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR])?;
Expand Down Expand Up @@ -128,7 +136,26 @@ impl Snapshot {
.map_err(|error| entry_error(&path, error))?;
let key = SnapshotKey::from_bytes(&bytes)?;
key.check_manifest(&manifest)?;
Ok(Self { dir, key, manifest })
let nic = manifest
.machine
.nic
.as_ref()
.map(|nic| {
Ipv4Cidr::new(nic.ipv4, nic.prefix_length)
.map(|guest_network| NicConfig { guest_network })
.map_err(|problem| {
Error::io(format!(
"the snapshot's captured NIC address is invalid: {problem}"
))
})
})
.transpose()?;
Ok(Self {
dir,
key,
manifest,
nic,
})
}

/// Returns the verified snapshot key.
Expand All @@ -140,6 +167,184 @@ impl Snapshot {
pub fn manifest(&self) -> &SnapshotManifest {
&self.manifest
}

/// Returns the captured NIC's presence and checked address, without a network policy.
///
/// An unnamed restore address inherits this value. This accessor reads no snapshot entry.
pub fn nic(&self) -> Option<NicConfig> {
self.nic
}

/// Checks whether this snapshot can restore `config` under `runtime` at `now`.
///
/// Requires a layered root, processors from the effective boot count to capacity in
/// counts of 1, 2, 4 or 8, and memory from boot to capacity in multiples of 128 MiB.
/// An encoded boot processor count of zero means the entire capacity is already online.
/// The image must fit the placeholder with the same logical and physical block sizes;
/// a scratch upper must have the dormant scratch's complete geometry.
///
/// NIC presence must match. A named address must equal the captured address; an unnamed
/// one inherits it. Network policies are not compared. The boot console must keep its
/// captured attachment policy: `recreate-listener` for a listener, or
/// `discard-while-disconnected` for a disconnected console. Neither its device nor the
/// control console proves this attachment. The runtime must use the captured backend and
/// CPU profile, and have the same runtime identity. An omitted profile or `auto` names
/// the captured profile; `host` also names it when that profile is host-derived.
///
/// Reads only the requested image and scratch metadata, never the snapshot entries again.
/// The caller keeps the snapshot private and immutable, as [`open`](Self::open) requires.
///
/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a compatibility
/// refusal, with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for an ineligible
/// time contract, and with [`ErrorKind::Io`](crate::ErrorKind::Io) for disk I/O failures.
pub fn accepts(&self, config: &VmConfig, runtime: &Runtime, now: &HostTime) -> Result<()> {
config.check()?;
let RootConfig::Layered(root) = &config.root else {
return Err(Error::unsupported(
"a snapshot restore requires a layered root",
));
};
let capacity = self.key.processors();
let boot = match self.key.boot_processors() {
0 => capacity,
boot => boot,
};
if !PROCESSOR_COUNTS.contains(&config.processors)
|| config.processors < boot
|| config.processors > capacity
{
return Err(Error::unsupported(format!(
"snapshot processors must be 1, 2, 4 or 8, from boot {boot} to capacity \
{capacity}, not {}",
config.processors
)));
}
let memory = config.memory_mib.checked_mul(1024 * 1024).ok_or_else(|| {
Error::unsupported("snapshot memory in MiB overflows its byte length")
})?;
if memory < self.key.memory() || memory > self.key.memory_capacity() {
return Err(Error::unsupported(format!(
"snapshot memory must be from boot {} to capacity {} bytes, not {memory}",
self.key.memory(),
self.key.memory_capacity()
)));
}
if !config.memory_mib.is_multiple_of(128) {
return Err(Error::unsupported(
"snapshot memory must be aligned to 128 MiB",
));
}
let image = restore_geometry(&root.image, "image")?;
let placeholder = self.key.distro();
if image.length > placeholder.length {
return Err(Error::unsupported(format!(
"snapshot image length {} exceeds the placeholder's {} bytes",
image.length, placeholder.length
)));
}
if image.logical_block_size != placeholder.logical_block_size
|| image.physical_block_size != placeholder.physical_block_size
{
return Err(Error::unsupported(format!(
"snapshot image block sizes {image:?} differ from the placeholder's \
{placeholder:?}"
)));
}
if let UpperConfig::Scratch { path, .. } = &root.upper {
let scratch = restore_geometry(path, "scratch")?;
if scratch != self.key.scratch() {
return Err(Error::unsupported(format!(
"snapshot scratch geometry {scratch:?} differs from the dormant scratch's {:?}",
self.key.scratch()
)));
}
}
match (&config.network, self.nic()) {
(None, Some(_)) | (Some(_), None) => {
return Err(Error::unsupported(
"snapshot NIC presence differs from the requested VM's",
));
}
(Some(network), Some(captured)) => {
if network
.guest_network
.is_some_and(|address| address != captured.guest_network)
{
return Err(Error::unsupported(format!(
"snapshot NIC address must remain {}",
captured.guest_network
)));
}
}
(None, None) => {}
}
let console = self
.manifest
.attachments
.iter()
.find(|attachment| attachment.stable_id == "console:microvm-virtio0");
let policy = match config.console {
ConsoleAttachment::Listener => "recreate-listener",
ConsoleAttachment::Disconnected => "discard-while-disconnected",
};
if console.is_none_or(|attachment| attachment.reconnect_policy != policy) {
return Err(Error::unsupported(
"snapshot boot console attachment policy does not match the requested console",
));
}
if runtime.hypervisor != self.key.hypervisor() {
return Err(Error::unsupported(
"snapshot backend differs from the runtime's hypervisor",
));
}
let profile = self.key.cpu_profile();
if !key::cpu_profile_matches(
&self.manifest.machine.cpu,
runtime.cpu_profile.as_deref().unwrap_or("auto"),
) {
return Err(Error::unsupported(format!(
"snapshot CPU profile must remain {profile}"
)));
}
if &runtime.identity != self.key.runtime() {
return Err(Error::unsupported(format!(
"snapshot runtime identity differs in {:?}",
self.key.runtime().differences(&runtime.identity)
)));
}
let eligibility = self.manifest.time().eligibility(now);
if !eligibility.is_eligible() {
return Err(Error::unavailable(format!(
"snapshot time contract is {eligibility}"
)));
}
Ok(())
}
}

/// Reads a requested regular disk's geometry without waiting for a Linux FIFO writer.
fn restore_geometry(path: &Path, description: &str) -> Result<BlockGeometry> {
let file = seal::open_read(path).map_err(|error| {
Error::io(format!(
"cannot open snapshot restore {description} {}",
path.display()
))
.with_source(error)
})?;
let metadata = file.metadata().map_err(|error| {
Error::io(format!(
"cannot examine snapshot restore {description} {}",
path.display()
))
.with_source(error)
})?;
if !metadata.is_file() {
return Err(Error::unsupported(format!(
"the snapshot restore {description} {} is not a regular file",
path.display()
)));
}
sandbox::block_geometry(&file, path, description)
}

/// Names the failing snapshot entry while retaining its I/O error.
Expand Down
15 changes: 10 additions & 5 deletions nvx-rs/src/host/snapshot/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ fn cpu_profile_problem(profile: &str) -> Option<String> {
})
}

/// Returns whether a CPU-profile request names the captured effective profile.
pub(super) fn cpu_profile_matches(profile: &CpuProfile, requested: &str) -> bool {
match requested {
"auto" => true,
"host" => profile.generation == HOST_GENERATION,
requested => profile.id() == requested,
}
}

/// Returns the guest's network on `nic`, its IPv4 address and prefix length, in the canonical form
/// of a [`SandboxNetwork`].
fn guest_network_of(nic: &Nic) -> String {
Expand Down Expand Up @@ -548,11 +557,7 @@ impl SnapshotKey {
let profile = machine.cpu.id();
// `auto` selects the built-in profile of the host's CPU, or falls back to a profile that
// the host derives, whose generation is `host`, when no built-in profile serves it.
let selected = match class.cpu_profile.as_str() {
"auto" => true,
"host" => machine.cpu.generation == HOST_GENERATION,
requested => profile == requested,
};
let selected = cpu_profile_matches(&machine.cpu, &class.cpu_profile);
if !selected {
return mismatch(
format!("its CPU profile is {profile}"),
Expand Down
13 changes: 7 additions & 6 deletions nvx-rs/src/host/snapshot/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,8 @@ pub struct SnapshotManifest {
time: TimeContract,
/// The machine.
pub(super) machine: Machine,
/// The captured host attachments, retained by the same strict manifest parse.
pub(super) attachments: Vec<Attachment>,
}

pub(in crate::host) use SnapshotManifest as TemplateManifest;
Expand Down Expand Up @@ -766,26 +768,25 @@ impl SnapshotManifest {
"has the invalid creation time of {seconds} seconds and {nanos} nanoseconds"
))
})?;
let Some(contract) = raw.contract else {
let Some(mut contract) = raw.contract else {
return Err(invalid("has no machine contract"));
};
let attachments = std::mem::take(&mut contract.attachments);
let (machine, time) = check_contract(contract, raw.vp_count, raw.memory_size)?;
Ok(Self {
created_at,
openvmm_version: raw.openvmm_version,
state_len: raw.state_size,
time,
machine,
attachments,
})
}
}

/// A host attachment of a snapshot's machine contract, `openvmm.snapshot.SnapshotAttachment`: an
/// endpoint outside the VM, such as the backend of a console, which a restore reconnects, as far
/// as `nvx-rs run` reads it to restore the snapshot.
// A template's manifest checks the form of its attachments alone, so only the CLI reads what this
// holds.
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
/// endpoint outside the VM, such as the backend of a console, whose identity and reconnect
/// policy snapshot compatibility and `nvx-rs run` read for a restore.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Attachment {
/// The stable ID of the device that it attaches, such as `console:microvm-virtio0`.
Expand Down
Loading
Loading