Skip to content
Closed
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
1 change: 1 addition & 0 deletions doc/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ each code for callers of the `nvx` library.
| `template-ineligible` | 1 | The restore window of a template's generation, which the time contract of its capture sets, excludes the host's time, so no clone may restore it now. |
| `template-tombstoned` | 1 | A tombstone quarantines the template's generation, which takes no new clones. |
| `exited` | 1 | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one. |
| `still-running` | 1 | A stop could not confirm the VM process's exit, so the caller keeps its verified process. |
| `exec-refused` | 1 | The guest agent refused the workload, as for a working directory that it cannot use. |
| `endpoint-closed` | 1 | OpenVMM closed the control endpoint, as it does when the VM shuts down. |
| `protocol` | 1 | The control session failed otherwise, as when the guest breaks the protocol or the session is reset. |
Expand Down
5 changes: 5 additions & 0 deletions nvx-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ path = "src/bin/nvx-rs.rs"
required-features = ["cli"]
doc = false

[[example]]
name = "external-launch"
path = "examples/external-launch.rs"
required-features = ["host"]

# The guest binaries. Their unit tests live in the library, so the binaries have no test harness.
[[bin]]
name = "nvx-console-pending"
Expand Down
91 changes: 85 additions & 6 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ changes only when the module's purpose does.
| [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature |
| [`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, and caller-saved reattachment |
| [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, restore-time eligibility, and the opaque input of a future VM restore |
| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, owned cold-launch command, verified detached launch, confirmed stop, and caller-saved reattachment |
| [`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 @@ -500,6 +500,17 @@ or reading runtime files.
`Reattached` holds both the process and authenticated connection. Failures
retain their original kinds and sources and leave the saved values unchanged.

`stop(process, outputs, graceful, grace, deadline)` waits through the verified
process, first after the caller's graceful request, then after forced termination
if needed. It returns `Stopped` only after confirmed exit. `self_exited` records
confirmation before a forced attempt, and `forced` records that attempt, not
proof from kill success. An unconfirmed exit is `StillRunning`, and the caller
keeps the process. The callback receives the bounded grace deadline and must
respect it; its boolean result is never exit proof.

`Stopped::report` retains the outcome-report read's result separately, so report
failure never hides confirmed exit.

`vm::create(&runtime, &config, None, &dir)` creates an inspectable cold
`Prepared` launch without starting a process or binding a listener. It requires
`config.boot`, refuses a snapshot input, rechecks the directory's privacy, and
Expand All @@ -517,7 +528,7 @@ these names before creating any output, including the log.

`Prepared::capability()` and `outputs()` return what the caller may journal
before a spawn. `log()` returns the same opened log, without reopening its
path. `inherit(file)` moves an already opened resource into the preparation's
path. `inherit(resource)` moves an already opened resource into the preparation's
inheritance allowlist and keeps it open. `arguments()` returns the whole
configuration's OpenVMM arguments. Creation makes caller-relative runtime
and block-image paths absolute without resolving links or `..` components,
Expand All @@ -529,6 +540,25 @@ directory and runtime inputs stable through creation and a later spawn.
Dropping a successful preparation closes its resources but leaves its output
paths owned by the caller.

`Prepared::into_command()` moves the complete launch into `VmmCommand`: its
program, arguments, working directory, capability, outputs, inherited resources,
opened log and Windows breakaway setting. It neither reopens nor duplicates
resources, renders again nor rediscovers paths. `InheritedHandle` owns its
resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows,
and also accepts an opened `File`. The command documents the caller's spawn
contract, including standard streams, allowlisted inheritance, Linux detachment
and umask, Windows log job and breakaway, and cleanup of a failed child.

The compiled [external-launch example](examples/external-launch.rs) consumes
this command, uses the existing public low-level spawn primitive without a
sandbox store, checks the cold guest's boot marker and shell response, then asks
it to exit cleanly. It requires the VMM's actual exit status 0 and a successful
outcome with complete teardown. Build with
`cargo build --locked --example external-launch` and supply the backend, OpenVMM,
kernel, initramfs, new VM directory, and the three artifact SHA-256 digests from
their installation metadata, in that order. Artifact paths may be relative to
the caller's directory.

`vm::spawn(prepared)` consumes that preparation and returns `Launched` with
its verified `process`, the same `outputs`, and the same `capability`. The log
and inherited resources are passed as already opened objects, never reopened
Expand Down Expand Up @@ -568,9 +598,47 @@ The key's canonical encoding and identity use the snapshot names and omit
and no reader or migration of the prior encoding is supported. `CaptureClass`
still records the legacy capture inputs, including its guest build.

`Snapshot` is an opaque input of a future restore. `vm::create` currently
refuses `Some(snapshot)` with `ErrorKind::Unsupported`; only cold boots are
supported.
`Snapshot::open` checks exactly `key.bin`, `snapshot.json` and
`openvmm/{manifest.bin,memory.bin,state.bin}`. Directories are private, files
are regular and private to their owner, artifact lengths equal the strict
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
or lock. `Snapshot` remains an opaque input of a future restore:
`vm::create` still refuses `Some(snapshot)` with `ErrorKind::Unsupported`;
only cold boots are supported.

### `nvx::host::config`

Expand Down Expand Up @@ -976,6 +1044,10 @@ On Windows, the duplicate retains the original handle's access rights.
Duplication failures have kind `Io` and retain their original I/O source.
No watcher thread, future or callback is created by the library.

`Process::wait(deadline)` returns `Some(Exited)` only after verified exit, or
`None` when the deadline passes without confirmation. `Exited` cannot be made
by a caller.

`Process::bytes_read` reports how many bytes the process has read through the
operating system's read calls, whether the host's cache served them or not:
`rchar` of `/proc/<pid>/io` on Linux, which it reads only while the process
Expand Down Expand Up @@ -1422,6 +1494,11 @@ The authenticated control session, which carries NVX's control protocol, `NVXC`,
one frame per DATA record of a `HostConnection`; Linux and Windows primitives;
and OpenVMM's outcome report.

`report::read` returns `Ok(None)` for a missing report, and an `Io` error for an
unreadable or malformed report, retaining the underlying error. It keeps today's
summary of the outcome category, status code and teardown steps, not free-form
fields.

A `Transport` to an OpenVMM control endpoint reads and writes with deadlines,
and tells, through `Transport::unread`, what the endpoint has not read yet of
what it wrote, where the platform tells: on Linux, what `SIOCOUTQ` reports of
Expand Down Expand Up @@ -3055,6 +3132,7 @@ optional cause.
| `Unavailable` | A missing or unusable artifact, hypervisor, or release, a guest image without a required control feature, or an unsupported host |
| `Unsupported` | A request that NVX refuses: a value that it does not accept, such as a malformed or out-of-range one, options that it cannot combine, or a request that it cannot represent or enforce, such as a policy beyond a limit of OpenVMM or the guest agent |
| `Exited` | The VM's OpenVMM process exited |
| `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process |
| `Io` | A host I/O failure, or state that NVX recorded on the host and cannot read |
| `NotFound` | State that an operation names and that does not exist on the host, such as the store of a sandbox that was never created or that a removal deleted |
| `Protocol` | A control session that failed, or a request that the guest agent refused |
Expand Down Expand Up @@ -3087,6 +3165,7 @@ usage errors, which no library error has.
| `unavailable` | `Unavailable` | A dependency is missing or unusable, other than as a finer code says: install metadata, the hypervisor, a release, a distro layer or scratch image that the request names, or a host that NVX does not support |
| `unsupported` | `Unsupported` | NVX refuses the request: options that it cannot combine, a value that it does not accept, or a policy that it cannot represent or enforce. The message names the option or field |
| `exited` | `Exited` | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one |
| `still-running` | `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process |
| `io` | `Io` | A host I/O operation failed, or state that NVX recorded on the host is unreadable or malformed |
| `not-found` | `NotFound` | Other state that the operation names does not exist on the host |
| `protocol` | `Protocol` | The control session failed otherwise: the peer violated the protocol, reset the session, or failed authentication |
Expand Down
175 changes: 175 additions & 0 deletions nvx-rs/examples/external-launch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! A caller-owned cold launch, using the existing low-level spawn primitive without a store.
//!
//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and
//! the three artifact SHA-256 digests from their installation metadata, in that order.

use std::error::Error;
use std::io::{self, Write};
use std::path::Path;
use std::time::{Duration, Instant};

use nvx::Hypervisor;
use nvx::activation::{MachineIdMode, View};
use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig};
use nvx::host::platform::Transport;
use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity};
use nvx::host::supervisor::{self, Inherited};
use nvx::host::{artifacts, report, session, vm};

fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = std::env::args_os().collect();
if args.len() != 9 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \
OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256",
)
.into());
}
let hypervisor = match args[1].to_str() {
Some("kvm") => Hypervisor::Kvm,
Some("mshv") => Hypervisor::Mshv,
Some("whp") => Hypervisor::Whp,
_ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid backend").into()),
};
nvx::host::runtime::probe_hypervisor(hypervisor)?;
let identity = |index: usize| {
ArtifactIdentity::sha256(args[index].to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"artifact digest is not Unicode",
)
})?)
.map_err(Box::<dyn Error>::from)
};
let runtime = Runtime {
openvmm: artifacts::absolute(Path::new(&args[2]))?,
kernel: artifacts::absolute(Path::new(&args[3]))?,
initramfs: artifacts::absolute(Path::new(&args[4]))?,
identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?),
hypervisor,
cpu_profile: Some("auto".to_owned()),
windows_breakaway: false,
options: LaunchOptions::default(),
};
let config = VmConfig {
processors: 1,
memory_mib: 256,
root: RootConfig::Blockless,
view: View {
hostname: b"external".to_vec(),
machine_id: MachineIdMode::Image,
},
network: None,
filesystem: None,
console: ConsoleAttachment::Listener,
boot: Some(Boot {
command_line: "quiet loglevel=0".to_owned(),
capture: None,
}),
};
vm::create_private_dir(Path::new(&args[5]))?;
let dir = vm::VmDir::open(Path::new(&args[5]))?;
let vm::VmmCommand {
program,
arguments,
working_dir,
capability,
outputs,
inherit,
log,
breakaway_from_job,
} = vm::create(&runtime, &config, None, &dir)?.into_command();
let command = supervisor::VmmCommand {
program,
arguments,
working_dir,
breakaway_from_job,
inherit: inherit.iter().map(Inherited::new).collect(),
};
let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?;
let result = (|| -> Result<(), Box<dyn Error>> {
let process = launched.process()?;
let deadline = Instant::now() + Duration::from_secs(60);
let console = outputs.console.as_deref().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console")
})?;
let mut console = session::connect(
console,
process.reference().pid,
process.reference().start_time,
deadline,
)?;
read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?;
console.write_all(
b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n",
Some(deadline.saturating_duration_since(Instant::now())),
)?;
read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?;
if !process.wait(deadline)? {
return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into());
}
match launched.wait_timeout(Duration::ZERO)? {
Some(0) => {}
Some(status) => {
return Err(io::Error::other(format!("VMM exited with status {status}")).into());
}
None => return Err(io::Error::other("VMM exit status is unavailable").into()),
}
let outcome = report::read(&outputs.outcome)
.ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?;
if outcome.category.as_deref() != Some("success")
|| outcome.status_code != Some(0)
|| outcome.teardown_complete != Some(true)
{
return Err(
io::Error::other(format!("VM did not shut down cleanly: {outcome:?}")).into(),
);
}
Ok(())
})();
if let Err(error) = result {
if !launched.kill_and_wait() {
return Err(
io::Error::other(format!("{error}; could not reap the launched VM")).into(),
);
}
return Err(error);
}
launched.detach();
println!(
"external cold launch completed; outcome={}",
outputs.outcome.display()
);
Ok(())
}

/// Prints console output through a marker, retaining only enough bytes to match across reads.
fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> {
let mut pending = Vec::new();
let mut buffer = [0; 4096];
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"console marker not received",
));
}
let count = console.read(&mut buffer, Some(remaining))?;
if count == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"boot console closed",
));
}
io::stdout().write_all(&buffer[..count])?;
pending.extend_from_slice(&buffer[..count]);
if pending.windows(marker.len()).any(|bytes| bytes == marker) {
return Ok(());
}
if pending.len() > marker.len() {
pending.drain(..pending.len() - marker.len());
}
}
}
Loading
Loading