diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 322214b80..6091d4321 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,7 +14,7 @@ the distribution tooling. | Rust `libc` crate 0.2.189 | linked into the guest binaries and the Linux `bin/nvx-rs` through `nix` and `getrandom` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are at [`rust-lang/libc` tag 0.2.189](https://github.com/rust-lang/libc/tree/0.2.189). | | Rust `nix` crate 0.31.3 | system-call wrappers used by the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`nix-rust/nix`](https://crates.io/crates/nix/0.31.3). | | Rust `getrandom` crate 0.4.3 | random bytes for the guest binaries, such as `nvx-reseed --sample`, and for `bin/nvx-rs[.exe]`, such as control capabilities | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-random/getrandom`](https://crates.io/crates/getrandom/0.4.3). | -| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | +| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and `nix` or `crossterm` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | | Rust `cfg-if` crate 1.0.5 | transitive `nix`, `getrandom`, and `sha2` dependency in the guest binaries and `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-lang/cfg-if`](https://crates.io/crates/cfg-if/1.0.5). | | Rust `memoffset` crate 0.9.1 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`Gilnaa/memoffset`](https://crates.io/crates/memoffset/0.9.1). | | Rust `serde` crate 1.0.229 | serialization framework of `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde/1.0.229). | @@ -37,6 +37,18 @@ the distribution tooling. | Rust `typenum` crate 1.20.1 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`paholg/typenum`](https://crates.io/crates/typenum/1.20.1). | | Rust `windows-sys` crate 0.61.2 | Windows API bindings of `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-sys/0.61.2). | | Rust `windows-link` crate 0.2.1 | transitive `windows-sys` dependency in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-link/0.2.1). | +| Rust `crossterm` crate 0.29.0 | terminal-mode preservation in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm`](https://crates.io/crates/crossterm/0.29.0). | +| Rust `crossterm_winapi` crate 0.9.1 | transitive `crossterm` Windows console support in `bin/nvx-rs.exe` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm_winapi`](https://crates.io/crates/crossterm_winapi/0.9.1). | +| Rust `winapi` crate 0.3.9 | transitive `crossterm` and `crossterm_winapi` Windows API bindings in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`winapi`](https://crates.io/crates/winapi/0.3.9). | +| Rust `parking_lot` crate 0.12.5 | transitive `crossterm` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot`](https://crates.io/crates/parking_lot/0.12.5). | +| Rust `parking_lot_core` crate 0.9.12 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot_core`](https://crates.io/crates/parking_lot_core/0.9.12). | +| Rust `lock_api` crate 0.4.14 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`lock_api`](https://crates.io/crates/lock_api/0.4.14). | +| Rust `scopeguard` crate 1.2.0 | transitive `lock_api` cleanup guards in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`scopeguard`](https://crates.io/crates/scopeguard/1.2.0). | +| Rust `smallvec` crate 1.16.5 | transitive `parking_lot_core` storage in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`smallvec`](https://crates.io/crates/smallvec/1.16.5). | +| Rust `document-features` crate 0.2.12 | build-time procedural macro used by `crossterm`, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`document-features`](https://crates.io/crates/document-features/0.2.12). | +| Rust `litrs` crate 1.0.1 | build-time `document-features` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`litrs`](https://crates.io/crates/litrs/1.0.1). | +| Rust `rustix` crate 1.1.5 | transitive `crossterm` terminal-mode support in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`rustix`](https://crates.io/crates/rustix/1.1.5). | +| Rust `linux-raw-sys` crate 0.12.1 | transitive `rustix` Linux API bindings in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`linux-raw-sys`](https://crates.io/crates/linux-raw-sys/0.12.1). | | musl libc | statically linked into `/sbin/nvx-console-pending`, `/sbin/nvx-mmio-write`, `/sbin/nvx-reseed`, `/sbin/nvx-exit`, `/sbin/nvx-device-io`, `/sbin/nvx-managed-agent`, `/sbin/nvx-ingress`, `/sbin/nvx-time`, `/sbin/nvx-time-probe`, and `/sbin/nvx-init` in every generated initramfs, and into `bin/nvx-rs` of the `linux-kvm` and `linux-mshv` release packages | MIT; Copyright © 2005-2020 Rich Felker, et al. The exact implementation is supplied by the pinned Rust release's `x86_64-unknown-linux-musl` target. Upstream source and the full copyright notice are at [musl libc](https://musl.libc.org/). | | LLVM `libunwind` | statically linked, with musl, into the ten guest binaries and the Linux `bin/nvx-rs`, which unwind and print backtraces through it | Apache-2.0 WITH LLVM-exception, whose exception lifts the attribution conditions for code embedded in a compiled program. The exact implementation is the `libunwind.a` that the pinned Rust release's `x86_64-unknown-linux-musl` target supplies, which the Rust project builds from its fork of [`llvm-project/libunwind`](https://github.com/llvm/llvm-project/tree/main/libunwind). | | Microsoft C runtime: the Visual C++ runtime and the Universal C Runtime | statically linked into `bin/nvx-rs.exe` of the `windows-whp` release package, which CI builds with `-C target-feature=+crt-static`, so that it runs without the Visual C++ Redistributable | Proprietary Microsoft object code, linked in by the Visual Studio build tools and the Windows SDK of CI's Windows runner under their Microsoft Software License Terms, which permit distributing the programs built with them; see [Redistribute Visual C++ files](https://learn.microsoft.com/cpp/windows/redistributing-visual-cpp-files). No source is distributed. | diff --git a/doc/usage.md b/doc/usage.md index 12eb06dd9..7b819b422 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -164,7 +164,7 @@ nvx-rs/target/release/nvx-rs run | `--memory-capacity-mib MIB` | none | Reserve an immutable, 128 MiB-aligned RAM capacity for a fresh microVM snapshot, in the range of `--memory-mib`. | | `--processors {1,2,4,8}` | `1` | Select the microVM processor count, written as one of the choices. | | `--cpu-profile ID` | `auto` | Select the guest's [CPU profile](#cpu-profiles): `auto` for the built-in profile of the host's CPU, which falls back with a warning to a development profile derived from this host on an Intel or AMD CPU that no built-in profile serves, a built-in profile ID, or `host` for that development profile. A restore uses the snapshot's profile, which `auto` and, for a host profile, `host` also name. | -| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | +| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. Cold boots also protect the system paths listed below. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | | `--mount-deny HOST_PATH` | none | Hide one existing file or directory inside a mounted host root; repeat to deny multiple paths. It may name the root itself when `--mount-allow` paths inside it remain. With several mappings, or a shared file, the path must be absolute; a shared file takes none. | | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path again; the hidden directories on the way list only the entries that lead to it. Repeat to allow multiple paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` mapping; the guest's writes anywhere else in it fail with `EROFS`. Repeat to declare multiple writable paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. Active snapshot restore requires the same denied, allowed, and writable paths. | @@ -179,8 +179,8 @@ nvx-rs/target/release/nvx-rs run | `--host-loopback {allow,deny}` | existing mapping | Control access between the guest and host loopback services. Explicit `allow` is bidirectional: the guest reaches host loopback through its gateway, and every TCP and UDP port on which the guest listens is published on host loopback for the host alone, or with `--network-ingress allow` admits the host as well; it needs a `--net` prefix of `/29` or shorter, which OpenVMM checks; see [Host-loopback access](run.md#host-loopback-access). Snapshot capture rejects `allow`, and so does a restore, unless `--restore-network-policy` installs a new policy. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards. Snapshot capture rejects forwards, and so does a restore, unless `--restore-network-policy` installs a new policy. | -| `--outcome-report PATH` | none | Write a bounded local JSON outcome report. | -| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. | +| `--outcome-report PATH` | none | Write a bounded local JSON outcome report to a new file in an existing plain directory. A cold boot rejects an existing destination before launch and never replaces a file or follows a destination symlink. A missing report or publication failure is diagnosed without replacing OpenVMM's exit status. | +| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. Cold boots reject double quotes and a standalone `--` token, which the VM control console cannot represent; snapshot restore retains its existing command-line handling. | | `--restore-snapshot PATH` | none | Restore the immutable machine contract and saved state from a snapshot directory. A snapshot captured with `--net` needs `--network-profile portable` and the network policy options of its capture, unless `--restore-network-policy` installs a new policy; see [Networked snapshots](run.md#networked-snapshots). | | `--restore-processors {1,2,4,8}` | none | Bring this contiguous processor prefix online before restore readiness. Requires an opt-in microVM snapshot and cannot exceed `--processors` capacity. | | `--restore-memory-mib MIB` | none | Select the 128 MiB-aligned RAM target for an expansion-capable snapshot restore, in the range of `--memory-mib`. | @@ -188,6 +188,15 @@ nvx-rs/target/release/nvx-rs run | `--restore-network-policy` | off | Install the policy of the network options, which may include `--network-ingress allow`, explicit `--host-loopback allow`, and forwards, in place of the complete deny-all policy, `--network-egress deny` and `--host-loopback deny` without egress rules, a proxy, or ingress, that the snapshot's NIC was captured with. The guest applies the new policy's DNS server and listener announcements before the host releases its input; see [Networked snapshots](run.md#networked-snapshots). Requires `--restore-snapshot`. | | `--dry-run` | off | Print the generated OpenVMM command without running it. | +Cold `--mount` targets may not cover, equal, or lie below `/bin`, `/boot`, +`/dev`, `/etc`, `/lib`, `/proc`, `/root`, `/run`, `/sbin`, `/sys`, `/usr`, +`/var`, or `/init`: these paths belong to the guest's blockless system. +For example, use `/config/single.txt` rather than `/etc/single.txt` for a +shared configuration file. Snapshot restore keeps its existing mapping rules. +Cold mount-policy options require a `--mount`, and network-policy options +require networking. A proxy is the guest's sole egress path, so it cannot be +combined with `--network-egress-allow` rules for direct connections. + `nvx-rs run` checks its options, prints the OpenVMM command after `>> `, and unless `--dry-run` is given, runs OpenVMM with the standard streams. A cold boot attaches the guest's console, the boot console `hvc1`, to them, so that diff --git a/guest/common/init b/guest/common/init index 322333176..38e818f7e 100644 --- a/guest/common/init +++ b/guest/common/init @@ -35,6 +35,13 @@ case "$cmdline" in *" nvx_sandbox=1 "*) sandbox_mode=true ;; *) sandbox_mode=false ;; esac +new_init=false +if [ "$sandbox_mode" = true ]; then + case "$cmdline" in + *" nvx_lifecycle="*) ;; + *) new_init=true ;; + esac +fi fatal() { echo "$*" >&2 @@ -189,7 +196,7 @@ for token in $cmdline; do esac done set +f -if [ -n "$nvx_exec" ]; then +if [ -n "$nvx_exec" ] && [ "$new_init" = false ]; then case "$nvx_exec" in /*) ;; *) fatal "nvx_exec must be an absolute initramfs path" ;; @@ -206,6 +213,7 @@ if [ "$sandbox_mode" = false ]; then mount_virtfs fi # virt-net: if --net attached a NIC, configure the only non-loopback interface. +announcer_pid= nip=$(sed -n 's/.*virtnet_ip=\([^ ]*\).*/\1/p' /proc/cmdline) if [ -n "$nip" ]; then nmask=$(sed -n 's/.*virtnet_mask=\([^ ]*\).*/\1/p' /proc/cmdline) @@ -289,6 +297,7 @@ if [ -n "$nip" ]; then allow | host-loopback) if [ -n "$ngw" ] && [ -x /sbin/nvx-ingress ]; then /sbin/nvx-ingress "$ngw" , /// The repair whose RESET must be acknowledged before later activation. - repaired: Option, + pub(crate) restored: Option, /// A fatal capture disposition, whose status PID 1 must preserve. shutdown: Option, } @@ -218,14 +221,14 @@ impl Capture { helpers, fresh, pending: None, - repaired: None, + restored: None, shutdown: None, } } /// Keeps one pending request and anchors its deadline before cleanup. pub(crate) fn queue(&mut self, request_id: u64, kill: bool) -> bool { - if self.pending.is_some() || self.repaired.is_some() || self.shutdown.is_some() { + if self.pending.is_some() || self.restored.is_some() || self.shutdown.is_some() { return false; } self.pending = Some((request_id, kill, Instant::now() + CAPTURE_TIMEOUT)); @@ -244,12 +247,14 @@ impl Capture { /// Whether this session must admit no root-dependent operation. pub(crate) fn rootless(&self) -> bool { - self.fresh || self.repaired.is_some() + self.restored.as_ref().map_or(self.fresh, |restored| { + restored.activation.established().is_none() + }) } - /// Keeps the original repair for later activation, which this slice does not perform. + /// Starts one activation from the repair's original acknowledgement. pub(crate) fn restored(&mut self, repaired: Repaired) { - self.repaired = Some(repaired); + self.restored = Some(Restored::new(&repaired, Instant::now())); } /// Records the fatal status before the loop returns directly to PID 1. @@ -318,6 +323,14 @@ pub(crate) fn report_capture(failure: impl std::fmt::Display) { ); } +/// Reports an activation failure before PID 1 ends the VM. +pub(crate) fn report_activation(failure: impl std::fmt::Display) { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ACTIVATION: {failure}\n").as_bytes(), + ); +} + /// Whether execution requires an account in the shared root's databases. #[cfg_attr( not(test), @@ -1003,6 +1016,57 @@ impl Runtime { }) } + /// Starts ingress from the applied root's publication and gateway, before its root switch. + /// The fork keeps the initramfs root and PID 1's priority; the sole collector owns its exit. + pub(crate) fn restore_announcer( + &mut self, + publication: Publication, + gateway: Option, + ) -> crate::Result<()> { + if publication == Publication::Unpublished { + return Ok(()); + } + if self.helpers.announcer.is_some() { + return Err(crate::Error::unsupported( + "an announcer is already registered", + )); + } + let gateway = gateway + .ok_or_else(|| crate::Error::unsupported("published listeners require a gateway"))?; + let program = super::ingress::PROGRAM.to_owned(); + #[cfg(test)] + let program = tests::restore::announcer_program(program); + let arguments = [ + c"nvx-ingress".to_owned(), + CString::new(gateway.to_string()).expect("an IPv4 address has no NUL"), + ]; + sys::check_released().map_err(|error| { + crate::Error::io("cannot fork the restored announcer with port permission") + .with_source(error) + })?; + let fork = sys::fork().map_err(|error| { + crate::Error::io("cannot fork the restored announcer") + .with_source(io::Error::from(error)) + })?; + if let sys::ForkResult::Parent(process) = fork { + self.helpers.announcer = Some(process); + return Ok(()); + } + if let Err(error) = sys::reset_signals() { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot reset announcer signals: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + let error = sys::exec(&program, &arguments, None); + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot exec /sbin/nvx-ingress: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + /// Poll alongside control input so a blocked peer cannot hide a helper's end. pub(crate) fn wake(&self) -> Pollable<'_> { Pollable::Signals(self.system.signals.as_ref().expect("listening")) diff --git a/nvx-rs/src/guest/init/boot.rs b/nvx-rs/src/guest/init/boot.rs index 2e66abc53..62c427890 100644 --- a/nvx-rs/src/guest/init/boot.rs +++ b/nvx-rs/src/guest/init/boot.rs @@ -1,13 +1,17 @@ -//! The fixed cold-boot sequence of the VM's shared layered root. +//! The fixed cold-boot and restored-space setup of the VM's shared layered root. use std::ffi::{CString, OsStr}; use std::net::IpAddr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::activation::{self, View}; use crate::gpt::Expected; +use crate::guest::activation::{ + self as guest_activation, Activation, ActivationAction, ActivationInput, ActivationState, + Applied, RootTemplate, +}; use crate::guest::mappings::{Access, Mapper, MappingTable, Reserved, SourceRoot, TargetRoot}; use crate::guest::root::{self, Assembly, Device, Root, ShareChild, ShareKind}; use crate::guest::sys::FileDescriptor; @@ -323,5 +327,187 @@ fn share_child(value: &[u8]) -> Result { }) } +/// One repaired activation and the space it owns across control epochs. +pub(crate) struct Restored { + pub(crate) activation: Activation, + payload: Option>, + _applied: Option, +} + +/// The account and open mapping handles handed to the shared dispatcher after activation. +pub(crate) struct RestoreHandoff { + pub(crate) workload: protocol::workload::WorkloadIdentity, + pub(crate) user: CString, + pub(crate) home: CString, + pub(crate) hostname: CString, + pub(crate) table: MappingTable, + pub(crate) mapper: Mapper, + pub(crate) export: Option, +} + +impl Restored { + /// Anchors the activation once, even when the next session attaches later. + pub(crate) fn new(repaired: &crate::guest::snapshot::Repaired, now: Instant) -> Self { + Self { + activation: Activation::new(repaired, Duration::from_secs(30), now), + payload: None, + _applied: None, + } + } + + /// Takes the first bootstrap reset or checks the same deadline on a replacement session. + pub(crate) fn reset(&mut self, now: Instant) -> Result<()> { + if self.activation.state() == ActivationState::Bootstrap { + self.activation + .input(now, ActivationInput::Reset) + .map_err(activation_error)?; + } else { + self.activation.tick(now).map_err(activation_error)?; + } + Ok(()) + } + + /// Compares the complete retry payload before delegating record replay to the state machine. + pub(crate) fn accept( + &mut self, + payload: &[u8], + now: Instant, + ) -> Result<(ActivationAction, protocol::workload::WorkloadIdentity)> { + if self + .payload + .as_deref() + .is_some_and(|accepted| accepted != payload) + { + return Err(Error::unsupported( + "the activate request changed after acceptance", + )); + } + let (_, workload) = protocol::decode_activate_payload(payload).map_err(protocol_error)?; + let action = self + .activation + .input(now, ActivationInput::Activate(&payload[9..])) + .map_err(activation_error)?; + if matches!(action, ActivationAction::Apply(_)) { + self.payload = Some(payload.to_vec()); + } + Ok((action, workload)) + } + + /// Applies and switches one restored space, retaining its root and handing over its mapper. + pub(crate) fn apply( + &mut self, + record: &activation::ActivationRecord, + workload: protocol::workload::WorkloadIdentity, + mut announce: impl FnMut(activation::Publication, Option) -> Result<()>, + ) -> Result { + let command_line = std::fs::read("/proc/cmdline").map_err(activation_error)?; + let template = RootTemplate { + runtime: "/run/nvx".into(), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: None, + }, + scratch: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: None, + }, + timeout: Duration::from_secs(30), + filesystem_tag: "microvm".into(), + gateway: cmdline::gateway(&command_line).map_err(protocol_error)?, + }; + let (applied, handoff) = restore_in_order( + self.activation.deadline(), + || { + guest_activation::apply(record, &template, self.activation.deadline()) + .map_err(activation_error) + }, + |applied| { + applied + .root_mut() + .mount_runtime(Some(Path::new("/run/nvx/time"))) + .map_err(activation_error)?; + let mut target = + TargetRoot::open(applied.root().path()).map_err(activation_error)?; + target + .add_mount(&applied.root().path().join("run")) + .map_err(activation_error)?; + let (user, home) = account::resolve( + applied.root().path(), + (workload.uid, workload.gid), + workload.create_account, + )?; + Ok(RestoreHandoff { + workload, + user, + home, + hostname: CString::new(applied.hostname()).map_err(activation_error)?, + table: u32::try_from(record.configuration().mappings) + .ok() + .and_then(MappingTable::new) + .ok_or_else(|| { + Error::unsupported("the restored mapping count is invalid") + })?, + mapper: Mapper::new(target, Reserved::container_root()), + export: applied.take_export(), + }) + }, + |applied| { + if applied.publication() == activation::Publication::Published { + announce(applied.publication(), applied.gateway())?; + } + Ok(()) + }, + |applied| applied.root_mut().switch().map_err(activation_error), + |applied| applied.teardown().map_err(activation_error), + )?; + self._applied = Some(applied); + Ok(handoff) + } +} + +/// Closes prepared handles before tearing down a space whose setup failed. +fn restore_in_order( + deadline: Option, + apply: impl FnOnce() -> Result, + mount_runtime: impl FnOnce(&mut T) -> Result, + announce: impl FnOnce(&T) -> Result<()>, + switch: impl FnOnce(&mut T) -> Result<()>, + teardown: impl FnOnce(T) -> Result<()>, +) -> Result<(T, R)> { + let check = || { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(Error::io( + "the activation deadline expired before the next space step", + )) + } else { + Ok(()) + } + }; + check()?; + let mut applied = apply()?; + let result = check() + .and_then(|()| mount_runtime(&mut applied)) + .and_then(|retained| { + check()?; + announce(&applied)?; + check()?; + switch(&mut applied)?; + Ok(retained) + }); + match result { + Ok(retained) => Ok((applied, retained)), + Err(error) => match teardown(applied) { + Ok(()) => Err(error), + Err(cleanup) => { + Err(Error::io(format!("{error}; cleanup failed: {cleanup}")).with_source(error)) + } + }, + } +} + +fn activation_error(error: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::io(format!("failed to activate the restored root: {error}")).with_source(error) +} + #[cfg(test)] mod tests; diff --git a/nvx-rs/src/guest/init/boot/tests.rs b/nvx-rs/src/guest/init/boot/tests.rs index 8ad287155..6ec9fef60 100644 --- a/nvx-rs/src/guest/init/boot/tests.rs +++ b/nvx-rs/src/guest/init/boot/tests.rs @@ -9,6 +9,219 @@ const CLI: &str = "nvx_identity=1000:1001 nvx_control_tty=hvc2"; const EXPORT: &str = "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=hostfs virtfs_mode=rw virtfs_aggregate=1"; +#[test] +fn restored_sessions_keep_the_deadline_of_the_actual_repair_acknowledgement() { + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let acknowledged = repaired.acknowledged_at().unwrap(); + let mut restored = Restored::new(&repaired, acknowledged + Duration::from_secs(12)); + let deadline = acknowledged + Duration::from_secs(30); + assert_eq!(restored.activation.deadline(), Some(deadline)); + restored + .reset(acknowledged + Duration::from_secs(13)) + .unwrap(); + restored + .reset(acknowledged + Duration::from_secs(20)) + .unwrap(); + assert_eq!(restored.activation.deadline(), Some(deadline)); + let (_, payload) = super::super::Capture::activation_fixture(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(21)) + .unwrap() + .0, + ActivationAction::Apply(_) + )); + restored + .reset(acknowledged + Duration::from_secs(29)) + .unwrap(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(29)) + .unwrap() + .0, + ActivationAction::Join + )); + assert_eq!(restored.activation.deadline(), Some(deadline)); + assert!( + restored + .reset(deadline) + .unwrap_err() + .to_string() + .contains("deadline") + ); + assert_eq!(restored.activation.state(), ActivationState::Failed); +} + +#[test] +fn restored_resource_steps_finish_before_completion_and_ready() { + use crate::activation::{Network, Publication, ReadyActivation}; + use std::cell::RefCell; + let (mut capture, mut payload) = super::super::Capture::activation_fixture(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.network = Some(Network { + dns: Some("10.88.0.53".parse().unwrap()), + publication: Publication::Published, + proxy: Some("http://10.88.0.1:3128".into()), + }); + let record = + activation::ActivationRecord::new(record.restore().copied(), configuration).unwrap(); + payload.truncate(9); + payload.extend(record.encode()); + let restored = capture.restored.as_mut().unwrap(); + let (ActivationAction::Apply(accepted), _) = restored.accept(&payload, Instant::now()).unwrap() + else { + panic!("one accepted activation"); + }; + let calls = RefCell::new(Vec::new()); + let (retained, ()) = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(accepted.configuration().clone()) + }, + |space| { + calls.borrow_mut().push("mount-runtime"); + assert_eq!( + space.network.as_ref().unwrap().dns, + Some("10.88.0.53".parse().unwrap()) + ); + assert_eq!( + space.upper, + activation::Upper::Tmpfs { + max_memory_percent: 63 + } + ); + Ok(()) + }, + |space| { + assert_eq!( + space.network.as_ref().unwrap().publication, + Publication::Published + ); + calls.borrow_mut().push("owned-announcer"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("switch"); + Ok(()) + }, + |_| panic!("a successful switched root is retained"), + ) + .unwrap(); + assert_eq!(retained.network, accepted.configuration().network); + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .unwrap(); + calls.borrow_mut().push("complete"); + assert_eq!(ready, ReadyActivation::of(&accepted)); + calls.borrow_mut().push("ready"); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "owned-announcer", + "switch", + "complete", + "ready" + ] + ); + assert!(matches!( + restored.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Ready(_) + )); +} + +#[test] +fn restored_setup_drops_prepared_handles_before_cleanup_and_preserves_both_errors() { + use std::cell::RefCell; + struct Handle<'a>(&'a RefCell>); + impl Drop for Handle<'_> { + fn drop(&mut self) { + self.0.borrow_mut().push("close-mapper"); + } + } + let calls = RefCell::new(Vec::new()); + let error = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("mount-runtime"); + Ok(Handle(&calls)) + }, + |_| { + calls.borrow_mut().push("announcer-failed"); + Err(Error::io("announcer failed")) + }, + |_| panic!("failed setup cannot switch"), + |_| { + calls.borrow_mut().push("teardown"); + Err(Error::io("teardown failed")) + }, + ) + .err() + .unwrap(); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "announcer-failed", + "close-mapper", + "teardown" + ] + ); + assert!(error.to_string().contains("announcer failed")); + assert!(error.to_string().contains("teardown failed")); + assert!( + std::error::Error::source(&error) + .unwrap() + .to_string() + .contains("announcer failed") + ); +} + +#[test] +fn an_expired_activation_cannot_begin_or_advance_to_another_space_step() { + assert!( + restore_in_order::<(), ()>( + Some(Instant::now()), + || panic!("an expired activation cannot apply"), + |_| panic!("cannot mount"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| panic!("nothing was applied"), + ) + .is_err() + ); + let deadline = Instant::now() + Duration::from_secs(1); + let applied = std::cell::Cell::new(false); + let cleaned = std::cell::Cell::new(false); + let error = restore_in_order::<(), ()>( + Some(deadline), + || { + applied.set(true); + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + Ok(()) + }, + |_| panic!("an application that used its deadline cannot mount runtime"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| { + cleaned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + assert_eq!(cleaned.get(), applied.get()); + assert!(error.to_string().contains("deadline")); +} + fn tokens(extra: &str) -> Tokens { Tokens::parse(format!("{ROOT} {VIEW} {CLI} {extra}").as_bytes()).unwrap() } diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 706bf18a7..66e525181 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -9,6 +9,7 @@ mod capture; mod exec; +pub(super) mod restore; pub(super) use exec::prepare_child; use std::collections::VecDeque; diff --git a/nvx-rs/src/guest/init/tests/capture.rs b/nvx-rs/src/guest/init/tests/capture.rs index 78aca2f9e..d4a05839c 100644 --- a/nvx-rs/src/guest/init/tests/capture.rs +++ b/nvx-rs/src/guest/init/tests/capture.rs @@ -14,6 +14,51 @@ impl Capture { fresh, ) } + + pub(crate) fn activation_fixture() -> (Self, Vec) { + use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Image, MachineIdMode, OverlayMode, + RestoreSection, TransactionId, Upper, View, + }; + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let record = ActivationRecord::new( + Some(RestoreSection { + transaction: TransactionId::from_bytes([0x22; TransactionId::LEN]), + }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 63, + }, + overlay: OverlayMode::UntrustedLowers, + export: None, + mappings: 0, + network: None, + view: View { + hostname: b"restored".to_vec(), + machine_id: MachineIdMode::Image, + }, + resources: ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + }, + ) + .unwrap(); + let mut capture = Self::fixture(true); + capture.restored(repaired); + capture + .restored + .as_mut() + .unwrap() + .reset(Instant::now()) + .unwrap(); + let mut payload = 1000u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&1001u32.to_le_bytes()); + payload.push(1); + payload.extend(record.encode()); + (capture, payload) + } } #[test] diff --git a/nvx-rs/src/guest/init/tests/restore.rs b/nvx-rs/src/guest/init/tests/restore.rs new file mode 100644 index 000000000..86cf3a741 --- /dev/null +++ b/nvx-rs/src/guest/init/tests/restore.rs @@ -0,0 +1,313 @@ +//! The restore-only announcer step and its existing helper supervision. + +use std::cell::RefCell; +use std::io::{Seek, SeekFrom, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::*; + +thread_local! { + static PROGRAM: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn announcer_program(program: CString) -> CString { + PROGRAM.with_borrow(|replacement| replacement.clone().unwrap_or(program)) +} + +fn with_program(path: &Path, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + PROGRAM.replace(self.0.take()); + } + } + let _restore = Restore(PROGRAM.replace(Some(sys::path_c_string(path).unwrap()))); + run() +} + +fn runtime() -> Runtime { + Runtime { + system: RealSystem { signals: None }, + helpers: Helpers { + time: pid(i32::MAX), + announcer: None, + }, + terminating: false, + } +} + +struct Child { + process: ProcessId, + descriptor: FileDescriptor, + collected: bool, +} + +impl Child { + fn new(process: ProcessId) -> Self { + Self { + process, + descriptor: sys::open_pidfd(process).unwrap(), + collected: false, + } + } +} + +impl Drop for Child { + fn drop(&mut self) { + if !self.collected { + let _ = sys::kill_through_pidfd(self.descriptor.borrowed(), false); + let _ = sys::wait(self.process); + } + } +} + +fn script(directory: &Path, body: &str) -> std::path::PathBuf { + let path = directory.join("announcer"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +fn until(mut ready: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !ready() { + assert!(Instant::now() < deadline, "the announcer did not advance"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn collect_failure(runtime: &mut Runtime, child: &mut Child) -> String { + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + let error = runtime + .service(|_, _| panic!("the restored announcer was treated as a workload")) + .unwrap_err(); + child.collected = true; + error +} + +fn blocked(process: ProcessId) -> u64 { + let status = std::fs::read_to_string(format!("/proc/{}/status", process.raw())).unwrap(); + let mask = status + .lines() + .find_map(|line| line.strip_prefix("SigBlk:")) + .unwrap(); + u64::from_str_radix(mask.trim(), 16).unwrap() +} + +#[test] +fn unpublished_restores_start_nothing_even_without_a_gateway() { + let mut runtime = runtime(); + for gateway in [None, Some(Ipv4Addr::new(10, 88, 0, 1))] { + runtime + .restore_announcer(Publication::Unpublished, gateway) + .unwrap(); + assert_eq!(runtime.helpers.announcer, None); + } +} + +#[test] +fn a_missing_gateway_or_an_existing_announcer_starts_no_replacement() { + let mut runtime = runtime(); + assert_eq!( + runtime + .restore_announcer(Publication::Published, None) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, None); + runtime.helpers.announcer = Some(pid(7)); + assert_eq!( + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, Some(pid(7))); +} + +#[test] +fn the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision() { + let name = "guest::init::tests::restore::the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision"; + if isolate_process_test("NVX_INIT_RESTORED_CHILD_TEST", name) { + return; + } + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let record = directory.path().join("arguments"); + let program = script( + directory.path(), + &format!( + "printf '%s\\n' \"$#\" \"$1\" \"$PPID\" > '{}'\nexec sleep 30", + record.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + let expected = format!("1\n10.88.0.1\n{}\n", sys::current_process().raw()); + until(|| std::fs::read_to_string(&record).is_ok_and(|record| record == expected)); + assert_eq!(nice_of(child.process), nice_of(sys::current_process())); + assert_eq!( + std::fs::read(format!("/proc/{}/oom_score_adj", child.process.raw())).unwrap(), + std::fs::read("/proc/self/oom_score_adj").unwrap() + ); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + let error = collect_failure(&mut runtime, &mut child); + assert_eq!( + error, + format!( + "the announcer, process {}, was killed by signal 9", + child.process.raw() + ) + ); + let mut system = Fake::default(); + finish(&mut system, Err(error.clone())); + assert_eq!(system.calls, [failure(&error), Call::ShutDown(FAILURE)]); + }); +} + +#[test] +fn a_failed_exec_remains_a_registered_fatal_helper() { + let name = "guest::init::tests::restore::a_failed_exec_remains_a_registered_fatal_helper"; + if isolate_process_test("NVX_INIT_RESTORED_EXEC_FAILURE_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(Path::new("/nonexistent/nvx-ingress"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 125", + child.process.raw() + ) + ); + }); +} + +#[test] +fn the_restored_announcer_unblocks_pid_1_signals_before_exec() { + let name = + "guest::init::tests::restore::the_restored_announcer_unblocks_pid_1_signals_before_exec"; + if isolate_process_test("NVX_INIT_RESTORED_SIGNALS_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + assert_ne!(blocked(sys::current_process()), 0); + with_program(Path::new("/bin/false"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + assert_eq!(blocked(child.process), 0); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 1", + child.process.raw() + ) + ); + }); +} + +#[test] +fn a_refused_fork_keeps_the_helper_registry_unchanged() { + let mut runtime = runtime(); + let error = runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert_eq!(runtime.helpers.announcer, None); +} + +#[test] +#[ignore = "requires a guest's root, CAP_SYS_NICE, CAP_SYS_RESOURCE, and CAP_SYS_CHROOT"] +fn the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority() { + let name = "guest::init::tests::restore::the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority"; + if std::env::var_os("NVX_INIT_RESTORED_PRIORITY_TEST").is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--ignored", "--nocapture"]) + .env("NVX_INIT_RESTORED_PRIORITY_TEST", "1") + .status() + .unwrap(); + assert!(status.success()); + return; + } + assert!(holds_capability(CAP_SYS_NICE).unwrap()); + assert!(holds_capability(CAP_SYS_RESOURCE).unwrap()); + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let root = directory.path().join("new-root"); + std::fs::create_dir(&root).unwrap(); + let marker = directory.path().join("old-root-marker"); + std::fs::write(&marker, b"the helper kept the initramfs root\n").unwrap(); + let ready = directory.path().join("ready"); + let gate = directory.path().join("gate"); + let output = directory.path().join("observed"); + let mut gate_file = std::fs::File::create(&gate).unwrap(); + let mut output_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&output) + .unwrap(); + let program = script( + directory.path(), + &format!( + "printf ready > '{}'\nwhile test ! -s '{}'; do sleep 0.01; done\ncat '{}' > '{}'\nexec sleep 30", + ready.display(), + gate.display(), + marker.display(), + output.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + runtime.system.raise_priority(None).unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + until(|| ready.is_file()); + assert_eq!(nice_of(child.process), -10); + assert_eq!( + std::fs::read_to_string(format!("/proc/{}/oom_score_adj", child.process.raw())) + .unwrap() + .trim(), + "-1000" + ); + nix::unistd::chroot(&root).unwrap(); + std::env::set_current_dir("/").unwrap(); + gate_file.write_all(b"continue\n").unwrap(); + let mut observed = String::new(); + until(|| { + output_file.seek(SeekFrom::Start(0)).unwrap(); + observed.clear(); + output_file.read_to_string(&mut observed).unwrap(); + observed == "the helper kept the initramfs root\n" + }); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + assert!(collect_failure(&mut runtime, &mut child).contains("the announcer")); + }); +} diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index 53a071d65..ad13b8437 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -4,8 +4,9 @@ use crate::guest::init::Runtime; use crate::guest::mappings::{Mapper, MappingTable, SourceRoot}; use crate::guest::sys::{self, Errno, FileDescriptor}; use crate::protocol::{ - self, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, - APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, + self, APP_ACTIVATE, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, + APP_READY, APP_STOP, APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, + UNSUPPORTED_OPERATION, }; use super::diagnostics; @@ -126,6 +127,7 @@ pub(crate) fn run( Err(error) }; } + let mut restored = None; loop { let payload = match session.next_event() { Ok(Event::Reset) => { @@ -146,7 +148,7 @@ pub(crate) fn run( // more specific stage. Its status is the error that ends the session, which // `control-session` reports too: `EPROTO` where C's `errno` was incidental, as after a // validation failure. - match handle_data(system, session, config, mappings, &payload) { + match handle_data(system, session, config, &mut restored, mappings, &payload) { Ok(Some(outcome)) => return Ok(outcome), Ok(None) => {} Err(_) if session.stopping() => return Ok(Outcome::Stopped), @@ -189,6 +191,7 @@ fn handle_data( system: &mut impl System, session: &mut Session, config: &AgentConfig, + restored: &mut Option, mappings: &mut HostMappings, payload: &[u8], ) -> Result, Errno> { @@ -199,7 +202,7 @@ fn handle_data( return Err(failure.into_error()); } }; - if session.rootless() && !matches!(request.kind, APP_CAPTURE | APP_STOP) { + if session.rootless() && !matches!(request.kind, APP_ACTIVATE | APP_CAPTURE | APP_STOP) { session.send_error( request.request_id, Errno::ENOTSUP as i32, @@ -207,7 +210,23 @@ fn handle_data( )?; return Ok(None); } + let config = restored.as_ref().unwrap_or(config); match request.kind { + APP_ACTIVATE if session.has_restored() => { + if let Some(handoff) = session.activate(request)? { + *mappings = HostMappings::layered(handoff.table, handoff.mapper, handoff.export)?; + *restored = Some(AgentConfig { + rootfs: c"-".to_owned(), + hostname: handoff.hostname, + uid: CString::new(handoff.workload.uid.to_string()).expect("decimal UID"), + gid: CString::new(handoff.workload.gid.to_string()).expect("decimal GID"), + user: handoff.user, + home: handoff.home, + direct: true, + entry: None, + }); + } + } APP_CAPTURE => { if session.queue_capture(request, false)? { return session.finish_capture(); @@ -245,6 +264,7 @@ fn handle_data( return Ok(None); } }; + session.start_activation()?; exec::run(system, session, config, request.request_id, &execution)?; if session.capture_pending() { return session.finish_capture(); diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index 9efb42f47..a94523509 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -440,6 +440,141 @@ fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { assert_eq!(system.spawn_calls, 0); } +#[test] +fn init_refuses_activation_without_a_restore_and_keeps_serving() { + for fresh in [true, false] { + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 0); + let mut system = TestSystem::new(Paths::default()); + let (host, guest) = UnixStream::pair().unwrap(); + let thread = thread::spawn(move || { + let mut session = Session::new(FileDescriptor::from_owned(guest.into())) + .with_capture(crate::guest::init::Capture::fixture(fresh)); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + for epoch in [1, 2] { + harness.reset(epoch, 7); + assert_eq!( + harness.request(APP_ACTIVATE, b"misrouted"), + response( + APP_ERROR, + 42, + Errno::ENOTSUP as i32, + b"unsupported-operation" + ) + ); + } + if !fresh { + assert_eq!( + harness.request(APP_PING, &[]), + response(APP_READY, 42, 0, b"") + ); + } + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.spawn_calls, 0); + } +} + +#[test] +fn restored_ready_maps_and_first_exec_share_the_existing_start_transition() -> TestResult { + use crate::guest::activation::{ActivationAction, ActivationState}; + use crate::guest::init::Capture; + if super::super::tests::isolate_process_test( + "NVX_RESTORED_DISPATCH_TEST", + "guest::managed_agent::dispatch::tests::restored_ready_maps_and_first_exec_share_the_existing_start_transition", + ) { + return Ok(()); + } + let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; + let (temporary, mut system) = TestSystem::with_host_export(); + system.direct_starter = Some(super::super::seam::tests::start_as_privileged); + let target = temporary.path().canonicalize()?.join("guest"); + let first = maps_payload( + 0, + &[(b"0/src", target.join("src").as_os_str().as_bytes(), 0)], + None, + ); + let second = maps_payload( + 1, + &[(b"1", target.join("out").as_os_str().as_bytes(), 0)], + None, + ); + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 2); + let (mut capture, payload) = Capture::activation_fixture(); + let activation = capture.restored.as_mut().unwrap(); + assert!(matches!( + activation.accept(&payload, std::time::Instant::now())?.0, + ActivationAction::Apply(_) + )); + let ready = activation + .activation + .complete(std::time::Instant::now(), Ok(()))? + .encode(); + let (host, guest) = UnixStream::pair()?; + let thread = thread::spawn(move || { + let mut session = + Session::new(FileDescriptor::from_owned(guest.into())).with_capture(capture); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + harness.reset(1, 7); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + let execution = workload_payload(&["/bin/sh", "-c", "printf restored"], None)?; + for maps in [&first, &second] { + assert_eq!( + harness.request(APP_EXEC, &execution), + response(APP_ERROR, 42, Errno::EAGAIN as i32, b"mappings-incomplete"), + ); + assert_eq!( + harness.request(APP_MAPS, maps), + response(APP_READY, 42, 0, b"") + ); + } + assert_eq!( + stdout_from(harness.request_all(43, APP_EXEC, &execution), 43, 0), + b"restored" + ); + harness.reset(2, 4); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + assert_eq!( + stdout_from(harness.request_all(44, APP_EXEC, &execution), 44, 0), + b"restored" + ); + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.kernel.moves().len(), 2); + Ok(()) +} + #[test] fn mapping_sequence_errors_are_reported_through_dispatch() { let (temporary, system) = TestSystem::with_host_export(); diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs index 2256e29fe..c4d618ed0 100644 --- a/nvx-rs/src/guest/managed_agent/session.rs +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -1,8 +1,10 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::process::ExitStatus; +use std::time::{Duration, Instant}; -use crate::guest::init::{self, Capture, CaptureStep, Runtime}; +use crate::guest::activation::{ActivationAction, ActivationState}; +use crate::guest::init::{self, Capture, CaptureStep, RestoreHandoff, Restored, Runtime}; use crate::guest::nvxs::{GuestConnection, GuestConnectionError}; use crate::guest::snapshot::{Captured, Disposition}; use crate::guest::sys::{self, Errno, FileDescriptor, Pollable}; @@ -28,9 +30,23 @@ struct Control<'r> { leader: Cell, status: Cell>, outcome: RefCell>, + /// Bounds partial I/O until the restored activation's Ready has been written. + deadline: Cell>, } impl Control<'_> { + fn remaining(&self) -> Result, Errno> { + self.deadline + .get() + .map(|deadline| { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(Errno::ETIMEDOUT) + }) + .transpose() + } + fn service_leader(&self) -> Result<(), Errno> { if self.service(|id, status| { // Keep the first leader status across partial I/O, not unrelated orphans. @@ -65,24 +81,32 @@ impl Read for Control<'_> { if bytes.is_empty() { return Read::read(&mut self.descriptor, bytes); } - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Read::read(&mut self.descriptor, bytes); - }; + } loop { - self.service_leader()?; - let signals = sys::poll( - [Pollable::File(&self.descriptor), runtime.borrow().wake()], - None, - )?[1]; - if signals.error { - return Err(Errno::EIO.into()); + if self.runtime.is_some() { + self.service_leader()?; } - if !signals.readable { - // A control-only wake includes HUP, which PollEvents does not expose. - match self.descriptor.read(bytes) { - Err(Errno::EAGAIN) => {} - result => return result.map_err(io::Error::from), + let timeout = self.remaining()?; + if let Some(runtime) = &self.runtime { + let signals = sys::poll( + [Pollable::File(&self.descriptor), runtime.borrow().wake()], + timeout, + )?[1]; + if signals.error { + return Err(Errno::EIO.into()); } + if signals.readable { + continue; + } + } else { + sys::poll([Pollable::File(&self.descriptor)], timeout)?; + } + self.remaining()?; + match self.descriptor.read(bytes) { + Err(Errno::EAGAIN) => {} + result => return result.map_err(io::Error::from), } } } @@ -90,31 +114,42 @@ impl Read for Control<'_> { impl Write for Control<'_> { fn write(&mut self, bytes: &[u8]) -> io::Result { - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Write::write(&mut self.descriptor, bytes); - }; + } if bytes.is_empty() { return Write::write(&mut self.descriptor, bytes); } loop { - self.service_leader()?; + self.remaining()?; + if self.runtime.is_some() { + self.service_leader()?; + } match self.descriptor.write(bytes) { - Err(Errno::EAGAIN) => loop { - let events = sys::poll( - [ - Pollable::Writable(&self.descriptor), - runtime.borrow().wake(), - ], - None, - )?; - if events[1].error { - return Err(Errno::EIO.into()); - } - self.service_leader()?; - if events[0].writable || !events[1].readable { - break; + Err(Errno::EAGAIN) => { + if let Some(runtime) = &self.runtime { + loop { + let events = sys::poll( + [ + Pollable::Writable(&self.descriptor), + runtime.borrow().wake(), + ], + self.remaining()?, + )?; + self.remaining()?; + if events[1].error { + return Err(Errno::EIO.into()); + } + self.service_leader()?; + if events[0].writable || !events[1].readable { + break; + } + } + } else { + sys::poll([Pollable::Writable(&self.descriptor)], self.remaining()?)?; + self.remaining()?; } - }, + } result => return result.map_err(io::Error::from), } } @@ -206,6 +241,7 @@ impl<'r> Session<'r> { leader: Cell::new(0), status: Cell::new(None), outcome: RefCell::new(Ok(false)), + deadline: Cell::new(None), }), capture: None, last_error: None, @@ -223,6 +259,12 @@ impl<'r> Session<'r> { self.capture.is_some() } + pub(crate) fn has_restored(&self) -> bool { + self.capture + .as_ref() + .is_some_and(|capture| capture.restored.is_some()) + } + /// Whether root-dependent requests must remain refused. pub(crate) fn rootless(&self) -> bool { self.capture.as_ref().is_some_and(Capture::rootless) @@ -314,8 +356,17 @@ impl<'r> Session<'r> { } Ok(Captured::Restored(repaired)) => { capture.restored(repaired); + let restored = capture.restored.as_mut().expect("the repair was retained"); + self.descriptor().set_nonblocking()?; + self.connection + .get_ref() + .deadline + .set(restored.activation.deadline()); match self.next_event() { - Ok(Event::Reset) => self.acknowledge()?, + Ok(Event::Reset) => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + } Ok(Event::Data(_)) => return Err(Errno::EPROTO), Err(failure) => { return Err(super::dispatch::report_failure_for_capture(failure)); @@ -342,6 +393,117 @@ impl<'r> Session<'r> { result } + /// Runs init's restored space setup or returns its cached activation result. + pub(crate) fn activate( + &mut self, + request: protocol::AppFrame<'_>, + ) -> Result, Errno> { + self.activate_with(request, |restored, session, record, workload| { + let runtime = session + .connection + .get_ref() + .runtime + .as_ref() + .ok_or_else(|| crate::Error::unsupported("activation needs the init runtime"))?; + restored.apply(record, workload, |publication, gateway| { + runtime.borrow_mut().restore_announcer(publication, gateway) + }) + }) + } + + /// Keeps one application and its result while replacement sessions join before Ready. + fn activate_with( + &mut self, + request: protocol::AppFrame<'_>, + apply: impl FnOnce( + &mut Restored, + &mut Self, + &crate::activation::ActivationRecord, + protocol::workload::WorkloadIdentity, + ) -> crate::Result, + ) -> Result, Errno> { + let mut capture = self.capture.take().ok_or(Errno::ENOTSUP)?; + let result = (|| { + let restored = capture.restored.as_mut().ok_or(Errno::EPROTO)?; + let (action, workload) = restored + .accept(request.payload, Instant::now()) + .map_err(activation_failure)?; + let (retained, ready, replies) = match action { + ActivationAction::Apply(record) => { + let retained = match apply(restored, self, &record, workload) { + Ok(retained) => retained, + Err(error) => { + init::report_activation(&error); + return Err(activation_failure( + restored + .activation + .complete(Instant::now(), Err(error.into())) + .expect_err("a failed application cannot become ready"), + )); + } + }; + let mut replies = vec![request.request_id]; + while sys::readable_or_hung_up(&[self.descriptor().borrowed()], 0)?[0] { + match self + .next_event() + .map_err(super::dispatch::report_failure_for_capture)? + { + Event::Reset => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + replies.clear(); + } + Event::Data(bytes) => { + let retry = self + .accept_request(&bytes) + .map_err(DataFailure::into_error)?; + if retry.kind != protocol::APP_ACTIVATE + || !matches!( + restored + .accept(retry.payload, Instant::now()) + .map_err(activation_failure)? + .0, + ActivationAction::Join + ) + { + return Err(Errno::EPROTO); + } + replies.push(retry.request_id); + } + } + } + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .map_err(activation_failure)?; + (Some(retained), ready, replies) + } + ActivationAction::Ready(ready) => (None, ready, vec![request.request_id]), + _ => return Err(Errno::EPROTO), + }; + for id in replies { + self.send_app(protocol::APP_READY, id, 0, &ready.encode())?; + } + self.connection.get_ref().deadline.set(None); + Ok(retained) + })(); + self.capture = Some(capture); + result + } + + /// Takes the first valid mapped EXEC as the start transition, never starting twice. + pub(crate) fn start_activation(&mut self) -> Result<(), Errno> { + if let Some(restored) = self + .capture + .as_mut() + .and_then(|capture| capture.restored.as_mut()) + && restored.activation.state() != ActivationState::Started + { + restored.activation.start().map_err(activation_failure)?; + } + Ok(()) + } + /// Borrows init's sole collector for the lifetime of this control session. pub(crate) fn with_runtime( descriptor: FileDescriptor, @@ -419,6 +581,11 @@ impl<'r> Session<'r> { Ok(GuestEvent::Reset(_)) => { if let Some(capture) = &mut self.capture { capture.reset(); + if let Some(restored) = &mut capture.restored { + restored + .reset(Instant::now()) + .map_err(|error| ReceiveFailure::Read(activation_failure(error)))?; + } } Ok(Event::Reset) } @@ -517,6 +684,11 @@ impl<'r> Session<'r> { } } +fn activation_failure(error: impl std::fmt::Display) -> Errno { + init::report_activation(error); + Errno::EPROTO +} + /// Returns the stage at which the C agent reported a record that the session refused, or `None` /// for one that it reported as a failure to read. /// diff --git a/nvx-rs/src/guest/managed_agent/session/tests.rs b/nvx-rs/src/guest/managed_agent/session/tests.rs index ee79358d8..d4147b2de 100644 --- a/nvx-rs/src/guest/managed_agent/session/tests.rs +++ b/nvx-rs/src/guest/managed_agent/session/tests.rs @@ -12,6 +12,16 @@ use crate::protocol::{ const INSTANCE: [u8; 16] = [7; 16]; +impl Session<'_> { + pub(crate) fn activation_state_for_test(&self) -> Option { + self.capture + .as_ref()? + .restored + .as_ref() + .map(|state| state.activation.state()) + } +} + struct Peer { stream: UnixStream, epoch: u64, @@ -20,6 +30,15 @@ struct Peer { } impl Peer { + fn reset(&mut self, epoch: u64, sequence: u64) { + self.stream + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, epoch, sequence, &[]).unwrap()) + .unwrap(); + self.epoch = epoch; + self.host_sequence = sequence + 1; + self.guest_sequence = 0; + } + fn read(&mut self) -> (OuterHeader, Vec) { let mut bytes = [0; OUTER_HEADER_LEN]; self.stream.read_exact(&mut bytes).unwrap(); @@ -95,6 +114,219 @@ fn connected(fresh: bool) -> (Session<'static>, Peer) { (session, peer) } +fn restored() -> (Session<'static>, Peer, Vec) { + let (mut session, peer) = connected(true); + let (capture, payload) = Capture::activation_fixture(); + let deadline = capture.restored.as_ref().unwrap().activation.deadline(); + session.capture = Some(capture); + session.descriptor().set_nonblocking().unwrap(); + session.connection.get_ref().deadline.set(deadline); + (session, peer, payload) +} + +fn activate_bytes(session: &mut Session<'_>, peer: &mut Peer, id: u64, payload: &[u8]) -> Vec { + peer.send(protocol::APP_ACTIVATE, id, payload); + let Event::Data(bytes) = session + .next_event() + .unwrap_or_else(|_| panic!("activate DATA")) + else { + panic!("the activate is DATA"); + }; + bytes +} + +#[test] +fn a_replacement_session_joins_one_activation_and_caches_ready_without_reapplying() { + let (mut session, mut peer, payload) = restored(); + let deadline = session.connection.get_ref().deadline.get(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let ready = crate::activation::ReadyActivation::of(&record).encode(); + let bytes = activate_bytes(&mut session, &mut peer, 61, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let retained = session + .activate_with(request, |restored, _, accepted, workload| { + assert_eq!(restored.activation.state(), ActivationState::Activating); + assert_eq!(restored.activation.deadline(), deadline); + assert_eq!(accepted, &record); + assert_eq!( + (workload.uid, workload.gid, workload.create_account), + (1000, 1001, true) + ); + peer.reset(2, 4); + peer.send(protocol::APP_ACTIVATE, 62, &payload); + Ok("one applied space") + }) + .unwrap(); + assert_eq!(retained, Some("one applied space")); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + peer.credit(); + peer.app(APP_READY, 62, 0, &ready); + assert!(!session.rootless()); + assert_eq!(session.connection.get_ref().deadline.get(), None); + let bytes = activate_bytes(&mut session, &mut peer, 63, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid retry")); + peer.credit(); + assert!( + session + .activate_with::<()>(request, |_, _, _, _| panic!( + "must not apply or switch twice" + )) + .unwrap() + .is_none() + ); + peer.app(APP_READY, 63, 0, &ready); + session.start_activation().unwrap(); + session.start_activation().unwrap(); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); +} + +#[test] +fn postactivation_capture_stays_refused_without_pending_cleanup() { + let (mut session, mut peer, payload) = restored(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + assert!(matches!( + state.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Apply(_) + )); + state.activation.complete(Instant::now(), Ok(())).unwrap(); + session.start_activation().unwrap(); + for kill in [0, 1] { + assert!(!queue(&mut session, &mut peer, 71, &[kill], false)); + assert!(!session.capture_pending()); + assert!(session.finish_capture().unwrap().is_none()); + peer.app( + APP_ERROR, + 71, + Errno::ENOTSUP as i32, + b"unsupported-operation", + ); + } +} + +#[test] +fn changed_record_or_workload_bytes_during_application_fail_without_ready() { + for changed_identity in [false, true] { + let (mut session, mut peer, payload) = restored(); + let mut changed = payload.clone(); + if changed_identity { + changed[0] ^= 1; + } else { + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.view.hostname = b"changed".to_vec(); + let record = + crate::activation::ActivationRecord::new(record.restore().copied(), configuration) + .unwrap(); + changed.truncate(9); + changed.extend(record.encode()); + } + let bytes = activate_bytes(&mut session, &mut peer, 64, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let result = session.activate_with(request, |_, _, _, _| { + peer.send(protocol::APP_ACTIVATE, 65, &changed); + Ok(()) + }); + assert_eq!(result.err(), Some(Errno::EPROTO)); + peer.credit(); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + assert!(session.rootless()); + } +} + +#[test] +fn failed_application_never_reports_ready_or_admits_root_dependent_work() { + let (mut session, mut peer, payload) = restored(); + let bytes = activate_bytes(&mut session, &mut peer, 66, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + assert_eq!( + session + .activate_with::<()>(request, |_, _, _, _| Err(crate::Error::io( + "runtime mounts failed" + ))) + .err(), + Some(Errno::EPROTO) + ); + assert!(session.rootless()); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Failed) + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +fn short_deadline(session: &mut Session<'_>) -> Instant { + let now = Instant::now(); + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + state.activation = crate::guest::activation::Activation::with_resources( + crate::activation::ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + Some(now - Duration::from_millis(100)), + Duration::from_millis(300), + now, + ); + state.reset(now).unwrap(); + let deadline = state.activation.deadline().unwrap(); + session.connection.get_ref().deadline.set(Some(deadline)); + deadline +} + +#[test] +fn reconnect_and_partial_input_wait_only_until_the_original_activation_deadline() { + let (mut session, mut peer, _) = restored(); + let deadline = short_deadline(&mut session); + peer.reset(2, 4); + assert!(matches!(session.next_event(), Ok(Event::Reset))); + session.acknowledge().unwrap(); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + assert_eq!(session.connection.get_ref().deadline.get(), Some(deadline)); + let partial = encode_outer(OUTER_DATA, &INSTANCE, 2, 5, &[0; 24]).unwrap(); + peer.stream.write_all(&partial[..11]).unwrap(); + assert!(matches!( + session.next_event(), + Err(ReceiveFailure::Read(Errno::ETIMEDOUT)) + )); + assert!(Instant::now() >= deadline); +} + +#[test] +fn an_unread_output_stream_cannot_extend_the_activation_deadline() { + let (mut session, _peer, _) = restored(); + let deadline = short_deadline(&mut session); + let payload = vec![0; OUTER_MAX_PAYLOAD - protocol::APP_HEADER_LEN]; + let error = loop { + if let Err(error) = session.send_app(APP_READY, 67, 0, &payload) { + break error; + } + }; + assert_eq!(error, Errno::ETIMEDOUT); + assert!(Instant::now() >= deadline); +} + fn queue( session: &mut Session<'_>, peer: &mut Peer, diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index 328797ec7..6e0d5e80d 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -147,6 +147,8 @@ pub(crate) use sock_diag::{ INET_DIAG_BUFFER_LENGTH, InetDiagMessage, decode_inet_diag_dump, dump_ipv6_tcp_sockets, }; pub(crate) use socket::{UdpSocket, connect_datagram}; +#[cfg(target_arch = "x86_64")] +pub(crate) use spawn::reset_signals; pub(crate) use spawn::{ChildSpec, PreparedChild, REPORT_LENGTH, enter_root}; pub(crate) use terminal::{configure_raw, drain}; pub(crate) use timer::Timer; diff --git a/nvx-rs/src/guest/sys/spawn.rs b/nvx-rs/src/guest/sys/spawn.rs index 00116653c..7cf0590f0 100644 --- a/nvx-rs/src/guest/sys/spawn.rs +++ b/nvx-rs/src/guest/sys/spawn.rs @@ -985,7 +985,7 @@ struct KernelSigaction { /// /// The dispositions come first, so that a signal that arrived while blocked takes its default /// action once unblocked, and never runs a handler of the caller's. -fn reset_signals() -> Result<(), Errno> { +pub(crate) fn reset_signals() -> Result<(), Errno> { let default = KernelSigaction::default(); for signal in 1..=SIGNALS { if signal == nix::libc::SIGKILL || signal == nix::libc::SIGSTOP { diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index a7e8bc29c..30107442a 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -7,10 +7,9 @@ //! `nvx.py run` with their names, defaults, types, and choices, and ends a usage error with //! status 2. //! -//! A cold boot attaches the guest's console, the boot console `hvc1`, to the terminal with -//! `--virtio-console console`, so that the terminal's input reaches the guest through it rather -//! than through the byte channel, `hvc0`, whose output, such as the kernel's early messages, goes -//! to standard error. A restore attaches the boot console as its snapshot had it: it reads the +//! A cold boot forwards the terminal through the VM's boot-console listener, `hvc1`, and the +//! VM's log to standard error. Its temporary private VM directory is removed after confirmed +//! exit. A restore attaches the boot console as its snapshot had it: it reads the //! snapshot's manifest, `manifest.bin` in the snapshot's directory, dry run or not, and passes //! `--virtio-console console` when the manifest's machine contract holds the attachment of the //! boot console, `console:microvm-virtio0`, with the reconnect policy @@ -31,41 +30,57 @@ //! new policy's DNS server and listener announcements before the host releases its input. //! //! Its shares differ from those of `sandbox`. `--mount-deny`, `--mount-allow`, and `--mount-write` -//! name host paths, which OpenVMM attributes to the share that holds them, and which must be -//! absolute with several shares or a shared file. One directory share uses `--mount`, several -//! shares or a file the children of an aggregate, whose `nvx_share=` tokens a cold boot adds to -//! the kernel command line, and a restore does not, since its guest keeps the binds that its boot -//! made. +//! name host paths, which must be absolute with several shares or a shared file. A cold boot +//! checks their ownership in the filesystem export plan, exports its numbered children, and adds +//! the plan's `nvx_share=` rows to the kernel command line. A restore keeps its existing lowering: +//! one directory uses `--mount`, several shares or a file use an aggregate, and no new share tokens +//! replace the binds that its saved guest already made. //! //! After OpenVMM exits with a status other than 0 from a cold boot, on a host whose CPU no built-in //! CPU profile serves, it prints the guidance of [`cpu_profile`] to standard error, which says //! what `--cpu-profile auto` does on that CPU and how to request a built-in profile for it. It //! never prints it after a dry run, a restore, a refused option, or Ctrl-C. +mod guest_tokens; + +use std::fs::{self, File}; +use std::io::{self, IsTerminal, Read}; use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; -use super::download; +use super::download::{self, Hold}; use super::network::{NativeRules, NetworkOptions}; use super::sandbox::{ - HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, error_with_cause, hypervisor, path_string, - path_text, run_openvmm, + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, copy_openvmm_outcome, error_with_cause, hypervisor, + path_string, path_text, run_openvmm, sandbox_network, validate_outcome_destination, }; +use super::session::Session; use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; -use crate::host::artifacts::{Guest, require_artifact}; +use crate::activation::{MachineIdMode, View}; +use crate::host::artifacts::{ArtifactDir, Guest, require_artifact}; +use crate::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; +use crate::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::platform::{self, Transport}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use crate::host::sandbox::mount::{ - AGGREGATE_MOUNT_TARGET, MountKind, MountOwner, POLICY_KINDS, PolicyKind, + AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, - aggregate_share_tokens, mount_argument, mount_kind, parse_mount_value, - require_mount_owner_supported, + aggregate_share_tokens, export_command_line_fragment, mount_argument, mount_kind, + parse_mount_value, require_mount_owner_supported, }; use crate::host::sandbox::options::validate_run_share_targets; use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator}; use crate::host::template::read_attachments; +use crate::host::vm::{self, VmDir}; /// The usage and help of the parser. pub(super) const SYNTAX: Syntax = Syntax { @@ -413,8 +428,12 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // nothing, though it reads the artifacts under the lock of downloads all the same. Their // arguments are built again once the artifacts are in place, as a share's kind depends on what // its host path holds, which a download may install. - let refused = terminal_console.as_ref().is_some_and(Result::is_err) - || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err(); + let refused = if options.restore_snapshot.is_some() { + terminal_console.as_ref().is_some_and(Result::is_err) + || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err() + } else { + cold_config(&options, &rules).is_err() + }; let located = locate(root)?; // A restore boots no kernel or initramfs: the snapshot holds the guest. let guest = options.restore_snapshot.is_none().then_some(options.guest); @@ -425,6 +444,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { }; let Located { dir, openvmm } = located; require_artifact(&openvmm, "OpenVMM release binary")?; + if options.restore_snapshot.is_none() { + return run_cold(dir, openvmm, &options, &rules, hold); + } let mut command = vec![ path_text(&path_string(&openvmm)?), "--single-process".to_owned(), @@ -449,21 +471,6 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { if terminal_console.transpose()? == Some(true) { push(&mut command, "--virtio-console", "console"); } - } else { - let kernel = dir.require_kernel()?; - let initrd = dir.require_initramfs(options.guest)?; - push( - &mut command, - "--memory", - format!("{}M", memory_mib(&options)), - ); - push(&mut command, "--kernel", path_string(&kernel)?); - push(&mut command, "--initrd", path_string(&initrd)?); - if let Some(capacity) = &options.memory_capacity_mib { - push(&mut command, "--memory-capacity", format!("{capacity}M")); - } - // The guest's console takes the terminal. - push(&mut command, "--virtio-console", "console"); } if let Some(profile) = &options.cpu_profile { push(&mut command, "--cpu-profile", profile.as_str()); @@ -478,19 +485,628 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { return Ok(()); } let status = run_openvmm(&command, &[], hold)?; - if status != 0 - && options.restore_snapshot.is_none() - && let Some(guidance) = cpu_profile::guidance( - options.cpu_profile.as_deref(), - HostCpu::current().as_ref(), - &format_command(&command[..1]), - hypervisor(options.hypervisor), - ) + Err(Exit::Status(status)) +} + +fn cold_config(options: &Options, rules: &NativeRules) -> Result { + let shares = options + .mounts + .iter() + .map(|value| parse_mount_value(value)) + .collect::>>()?; + validate_run_share_targets( + &shares + .iter() + .map(|(target, _, _)| target.as_str()) + .collect::>(), + )?; + if shares.is_empty() && (options.mount_owner.is_some() || !options.policy_paths.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + if options.mount_owner.is_some() { + "--mount-owner requires --mount" + } else { + "mount policy options require --mount" + }, + )); + } + let hosts: Vec<_> = shares + .iter() + .map(|(_, host, _)| Path::new(host).to_owned()) + .collect(); + let aggregate = + shares.len() > 1 || hosts.iter().any(|host| mount_kind(host) == MountKind::File); + let mut policies: [Vec; 3] = Default::default(); + for kind in POLICY_KINDS { + for (_, given) in options.policy_paths.iter().filter(|(of, _)| *of == kind) { + let given = path_text(given); + let path = Path::new(&given); + if aggregate && !path.is_absolute() { + return Err(Exit::error( + ErrorCode::Unsupported, + format!( + "with several --mount shares, or a shared file, {} {given} must be an absolute host path", + kind.option() + ), + )); + } + let path = if path.is_absolute() { + path.to_owned() + } else { + hosts[0].join(path) + }; + policies[match kind { + PolicyKind::Denied => 0, + PolicyKind::Allowed => 1, + PolicyKind::Writable => 2, + }] + .push(path); + } + } + let mappings: Vec<_> = shares + .iter() + .zip(&hosts) + .map(|((target, _, access), host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let filesystem = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if options.mount_owner == Some(MountOwner::Caller) { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + }) + .map_err(error_with_cause)?; + let network = sandbox_network(&options.network, rules)? + .map(|network| -> Result { + Ok(NetworkConfig { + guest_network: Some( + crate::net::Ipv4Cidr::parse(network.guest_network()) + .map_err(|error| Exit::error(ErrorCode::Unsupported, error.to_string()))?, + ), + policy: network.policy().clone(), + }) + }) + .transpose()?; + let config = VmConfig { + processors: options.processors, + memory_mib: memory_mib(options), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: guest_tokens::render(&options.cmdline, filesystem.as_ref()) + .map_err(error_with_cause)?, + memory_capacity_mib: options.memory_capacity_mib, + capture: None, + }), + filesystem, + }; + if let Some(export) = &config.filesystem { + let line = config.kernel_command_line().map_err(error_with_cause)?; + if line.len() + export_command_line_fragment(export).len() + 1 + > SANDBOX_COMMAND_LINE_MAX_SIZE + { + return Err(Exit::error( + ErrorCode::Unsupported, + "the --mount shares and --cmdline exceed the 1024-byte budget of the kernel command \ + line; attach fewer shares or use shorter targets", + )); + } + } + config.check_command_line().map_err(error_with_cause)?; + Ok(config) +} + +fn remove_vm_directory(dir: &VmDir) -> Result { + for name in [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + ] { + match fs::remove_file(dir.path().join(name)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + fs::remove_dir(dir.path()).map_err(Exit::from_os_error) +} + +fn connect_running( + launched: &mut vm::Launched, + endpoint: &str, +) -> Result>> { + let pid = launched.process.reference().pid; + connect_before_exit( + endpoint, + || platform::connect_endpoint(endpoint, pid, Duration::from_millis(250)), + |timeout| launched.wait_status(timeout), + ) +} + +/// Connects to the verified endpoint, or yields to the owned child's confirmed exit. +fn connect_before_exit( + endpoint: &str, + mut connect: impl FnMut() -> io::Result, + mut wait_status: impl FnMut(Duration) -> io::Result>, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + check_interrupt()?; + if wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + let error = match connect() { + Ok(transport) => return Ok(Some(transport)), + Err(error) => error, + }; + let retry = matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ); + // Peer authentication may observe exit just before the child status becomes available. + let exit_wait = if retry { + Duration::ZERO + } else { + Duration::from_millis(250).min(deadline.saturating_duration_since(Instant::now())) + }; + if wait_status(exit_wait) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + if !retry { + return Err(Exit::from_os_error(error)); + } + if Instant::now() >= deadline { + return Err(Exit::error( + ErrorCode::TimedOut, + format!("VM console endpoint did not become available: {endpoint}"), + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +struct TerminalGuard(bool); + +impl TerminalGuard { + fn new() -> Result { + let terminal = io::stdin().is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; + } + Ok(Self(terminal)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if self.0 + && let Err(error) = crossterm::terminal::disable_raw_mode() + { + output::print_stderr(&format!("cannot restore terminal mode: {error}")); + } + } +} + +type InputCompletion = mpsc::Receiver>; + +fn console_input(console: Box) -> Result<(Box, InputCompletion)> { + #[cfg(any(target_os = "linux", windows))] + { + let duplicate = || { + console + .try_clone_handle() + .map_err(Exit::from_os_error)? + .ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + "the VM console cannot be duplicated", + ) + }) + }; + let reader = duplicate()?; + let writer = duplicate()?; + drop(console); + let reader = platform::transport_from_handle(reader).map_err(Exit::from_os_error)?; + let mut writer = platform::transport_from_handle(writer).map_err(Exit::from_os_error)?; + let (sender, receiver) = mpsc::sync_channel(1); + thread::Builder::new() + .name("nvx-run-input".to_owned()) + .spawn(move || { + let result = (|| { + let mut stdin = io::stdin().lock(); + let mut buffer = [0; 8192]; + loop { + let count = stdin.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + match writer.write_all(&buffer[..count], None) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::WriteZero + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + })(); + let _ = sender.send(result); + }) + .map_err(Exit::from_os_error)?; + Ok((reader, receiver)) + } + #[cfg(not(any(target_os = "linux", windows)))] { + drop(console); + Err(Exit::error( + ErrorCode::Unsupported, + "foreground console I/O requires a supported NVX host", + )) + } +} + +struct ConsoleOutput { + #[cfg(windows)] + console: bool, + #[cfg(windows)] + pending: [u8; 3], + #[cfg(windows)] + pending_len: usize, +} + +impl ConsoleOutput { + fn new() -> Self { + Self { + #[cfg(windows)] + console: io::stdout().is_terminal(), + #[cfg(windows)] + pending: [0; 3], + #[cfg(windows)] + pending_len: 0, + } + } + + fn write_all(&mut self, data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + if self.console { + let combined; + let bytes = if self.pending_len == 0 { + data + } else { + combined = [&self.pending[..self.pending_len], data].concat(); + combined.as_slice() + }; + // Only a final incomplete sequence crosses reads; other invalid bytes render now. + let incomplete = + bytes.utf8_chunks().last().map_or(0, |chunk| { + match std::str::from_utf8(chunk.invalid()) { + Err(error) if error.error_len().is_none() => chunk.invalid().len(), + _ => 0, + } + }); + let ready = bytes.len() - incomplete; + output::write_stdout(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; + self.pending[..incomplete].copy_from_slice(&bytes[ready..]); + self.pending_len = incomplete; + return Ok(()); + } + output::write_stdout(data) + } + + fn finish(&mut self) -> io::Result<()> { + #[cfg(windows)] + if self.pending_len != 0 { + output::write_stdout("\u{fffd}".as_bytes())?; + self.pending_len = 0; + } + Ok(()) + } +} + +fn foreground( + launched: &mut vm::Launched, + log: &mut File, + managed: bool, + control: &mut Option, +) -> Result { + let _terminal = TerminalGuard::new()?; + let endpoint = launched + .outputs + .console + .clone() + .expect("a cold run requests a console listener"); + let (mut console, input) = if let Some(console) = connect_running(launched, &endpoint)? { + let (console, input) = console_input(console)?; + (Some(console), Some(input)) + } else { + (None, None) + }; + let mut attach_closed = None; + if managed && console.is_some() { + let endpoint = launched.outputs.endpoint.clone(); + let capability = *launched.capability.as_bytes(); + match Session::connect_launched( + &endpoint, + launched.child_mut().map_err(Exit::from_os_error)?, + &capability, + 60.0, + ) { + Ok(session) => *control = Some(session), + Err(Exit::EndpointClosed) => { + attach_closed = Some(Instant::now() + Duration::from_secs(60)); + } + Err(error) => return Err(error), + } + } + let mut console_output = ConsoleOutput::new(); + let mut buffer = [0; 8192]; + loop { check_interrupt()?; - output::print_stderr(&guidance); + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + let mut progressed = count != 0; + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + let status = launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)?; + if let Some(input) = &input + && status.is_none() + && let Ok(result) = input.try_recv() + { + result.map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } + Ok(count) => { + console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?; + progressed = true; + } + Err(error) if error.kind() == io::ErrorKind::TimedOut => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset + ) => + { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + if let Some(status) = status { + loop { + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + loop { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => break, + Ok(count) => console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => + { + break; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + } + console_output.finish().map_err(Exit::from_os_error)?; + return Ok(status); + } + if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(Exit::EndpointClosed); + } + if !progressed { + thread::sleep(Duration::from_millis(10)); + } } - Err(Exit::Status(status)) +} + +fn run_cold( + dir: ArtifactDir, + openvmm: std::path::PathBuf, + options: &Options, + rules: &NativeRules, + hold: Hold, +) -> Result { + let kernel = dir.require_kernel()?; + let initramfs = dir.require_initramfs(options.guest)?; + let config = cold_config(options, rules)?; + if !options.dry_run + && let Some(report) = &options.outcome_report + { + validate_outcome_destination(report)?; + } + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + // Cold CLI runs neither capture nor reattach; this per-invocation identity is not persisted. + let identity = ArtifactIdentity::new(format!("unrecorded-run:{name}"))?; + let runtime = Runtime { + openvmm, + kernel, + initramfs, + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let path = std::env::temp_dir().join(format!("nvx-run-{name}")); + if path.try_exists().map_err(Exit::from_os_error)? { + return Err(Exit::error( + ErrorCode::Io, + "the temporary VM directory already exists", + )); + } + vm::create_private_dir(&path)?; + let vm_dir = VmDir::open(&path)?; + let mut cleanup = true; + let result = (|| { + let prepared = vm::create(&runtime, &config, None, &vm_dir) + .map_err(error_with_cause)? + .for_foreground(); + let command: Vec<_> = std::iter::once(&runtime.openvmm) + .map(|path| path_string(path)) + .chain(prepared.arguments().iter().map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error(ErrorCode::Unsupported, "an OpenVMM argument is not Unicode") + }) + })) + .collect::>()?; + output::print_stdout(&format!(">> {}", format_command(&command))) + .map_err(Exit::from_os_error)?; + if options.dry_run { + return Ok(()); + } + let mut log = File::open(&prepared.outputs().log).map_err(Exit::from_os_error)?; + if platform::opened_file_id(&log).map_err(Exit::from_os_error)? + != platform::opened_file_id(prepared.log()).map_err(Exit::from_os_error)? + { + return Err(Exit::error( + ErrorCode::Io, + "the VM log was replaced before its reader opened", + )); + } + cleanup = false; + let mut launched = vm::spawn(prepared).map_err(error_with_cause)?; + drop(hold); + let mut control = None; + let managed = config.boot.as_ref().is_some_and(|boot| { + boot.command_line + .split_ascii_whitespace() + .any(|token| token == "nvx_mode=managed") + }); + let status = foreground(&mut launched, &mut log, managed, &mut control); + let now = Instant::now(); + let mut graceful_error = None; + let stopped = vm::stop( + &launched.process, + &launched.outputs, + |deadline| { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return false; + } + control + .as_mut() + .is_some_and(|session| match session.stop(timeout.as_secs_f64()) { + Ok(()) => true, + Err(error) => { + graceful_error = Some(error); + false + } + }) + }, + now + Duration::from_millis(250), + now + Duration::from_secs(10), + ) + .map_err(error_with_cause)?; + cleanup = true; + if let Some(error) = graceful_error { + output::print_stderr(&format!("VM graceful stop failed: {error:?}")); + } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } + check_interrupt()?; + let status = status?; + if let Err(error) = stopped.report { + output::print_stderr(&format!( + "error[io]: {}", + error_with_cause(error) + .message() + .unwrap_or("cannot read VM outcome") + )); + } + if status != 0 + && let Some(guidance) = cpu_profile::guidance( + options.cpu_profile.as_deref(), + HostCpu::current().as_ref(), + &format_command(&command[..1]), + hypervisor(options.hypervisor), + ) + { + check_interrupt()?; + output::print_stderr(&guidance); + } + Err(Exit::Status(status)) + })(); + if cleanup && let Err(error) = remove_vm_directory(&vm_dir) { + output::print_stderr(&format!( + "cannot remove the temporary VM directory {}: {error:?}", + vm_dir.path().display() + )); + if result.is_ok() || matches!(result, Err(Exit::Status(0))) { + return Err(error); + } + } + result } #[cfg(test)] diff --git a/nvx-rs/src/host/cli/run/guest_tokens.rs b/nvx-rs/src/host/cli/run/guest_tokens.rs new file mode 100644 index 000000000..9659e568c --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens.rs @@ -0,0 +1,86 @@ +//! The blockless guest settings owned by the CLI, beside the library's root and view tokens. + +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::{has_kernel_separator, parse_workload_identity}; +use crate::{Error, Result}; + +/// Renders both blockless modes and the planned boot binds, keeping other caller tokens. +pub(super) fn render(command_line: &str, export: Option<&ExportPlan>) -> Result { + let mut mode = "one-shot"; + let (mut uid, mut gid, mut identity) = (None, None, None); + let mut caller = String::new(); + let mut tokens = Vec::new(); + for part in command_line.split_inclusive(|ch: char| ch.is_ascii_whitespace()) { + let token = part.trim_ascii_end(); + let (name, value) = token.split_once('=').unwrap_or((token, "")); + match name { + "nvx_mode" | "nvx_lifecycle" => mode = value, + "nvx_workload_uid" => uid = Some(value), + "nvx_workload_gid" => gid = Some(value), + "nvx_identity" => identity = Some(value.to_owned()), + _ => { + caller.push_str(part); + continue; + } + } + caller.push_str(&part[token.len()..]); + } + if !matches!(mode, "one-shot" | "managed") { + return Err(Error::unsupported( + "blockless mode must be one-shot or managed", + )); + } + if uid.is_some() || gid.is_some() { + let (Some(uid), Some(gid)) = (uid, gid) else { + return Err(Error::unsupported("a workload needs both its UID and GID")); + }; + if identity.is_some() { + return Err(Error::unsupported( + "a workload identity may be specified only once", + )); + } + identity = Some(format!("{uid}:{gid}")); + } + tokens.push(format!("nvx_mode={mode}")); + if let Some(identity) = identity { + let (uid, gid) = parse_workload_identity(&identity)?; + tokens.push(format!("nvx_identity={uid}:{gid}")); + } + if let Some(export) = export { + for (row, bind) in export.guest_table().into_iter().zip(&export.binds) { + if [&row.source, &row.target] + .into_iter() + .any(|text| has_kernel_separator(text) || text.contains([',', '\\', '='])) + { + return Err(Error::unsupported( + "a planned boot share's source and target must each fit one kernel token", + )); + } + let child = &export.children[bind.child]; + let file = if bind.source.is_empty() { + child.file + } else { + let path = child.root.join(&bind.source); + std::fs::metadata(&path) + .map_err(|error| { + Error::io(format!("cannot inspect boot share {}", path.display())) + .with_source(error) + })? + .is_file() + }; + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if file { ",file" } else { "" }; + tokens.push(format!( + "nvx_share={},{},{access}{kind}", + row.source, row.target + )); + } + } + if !caller.is_empty() { + tokens.insert(0, caller); + } + Ok(tokens.join(" ")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/run/guest_tokens/tests.rs b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs new file mode 100644 index 000000000..ed46ea5f1 --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::host::filesystem::{ExportRequest, Mapping, ReservedTargets, Target}; + +#[test] +fn blockless_modes_and_workloads_use_cli_owned_names() { + for mode in ["one-shot", "managed"] { + let line = render( + &format!( + "nvx_lifecycle={mode} nvx_workload_uid=1000 nvx_workload_gid=1001 \ + nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help nvx_workload_account=create" + ), + None, + ) + .unwrap(); + assert!(line.contains(&format!("nvx_mode={mode}"))); + assert!(line.contains("nvx_identity=1000:1001")); + assert!(line.contains("nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help")); + assert!(line.contains("nvx_workload_account=create")); + assert!(!line.contains("nvx_lifecycle=")); + assert!(!line.contains("nvx_workload_uid=")); + assert!(!line.contains("nvx_workload_gid=")); + } + assert_eq!( + render("quiet nvx_exec=/sbin/nvx-exit", None).unwrap(), + "quiet nvx_exec=/sbin/nvx-exit nvx_mode=one-shot" + ); + let caller = " quiet\tloglevel=3\nnvx_arg='x' "; + assert_eq!( + render(caller, None).unwrap(), + format!("{caller} nvx_mode=one-shot") + ); +} + +#[test] +fn partial_or_root_workload_identities_and_unknown_modes_are_refused() { + for line in [ + "nvx_workload_uid=1000", + "nvx_identity=0:1000", + "nvx_identity=1000:4294967295", + "nvx_mode=other", + ] { + assert!(render(line, None).is_err(), "{line}"); + } +} + +#[test] +fn planned_boot_shares_keep_table_sources_targets_access_and_file_kind() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("share"); + std::fs::create_dir(&directory).unwrap(); + let file = directory.join("single.txt"); + std::fs::write(&file, "data").unwrap(); + let mappings = [ + Mapping { + name: "--mount", + path: &directory, + target: Target::Guest("/workspace"), + read_only: true, + }, + Mapping { + name: "--mount", + path: &file, + target: Target::Guest("/config/single.txt"), + read_only: true, + }, + ]; + let plan = crate::host::filesystem::plan(&ExportRequest::new( + &mappings, + ReservedTargets::BLOCKLESS_ROOT, + )) + .unwrap() + .unwrap(); + let table = plan.guest_table(); + assert_eq!(table[1].source, "0/single.txt"); + let line = render("", Some(&plan)).unwrap(); + assert!(line.contains("nvx_share=0,/workspace,ro")); + assert!(line.contains("nvx_share=0/single.txt,/config/single.txt,ro,file")); +} diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 2aa8f99b5..ede248caf 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -1,22 +1,19 @@ -//! The shares of `run`: their targets stay single kernel tokens, and OpenVMM, not option order, -//! attributes their policy paths. +//! Cold-run shares stay kernel tokens through the checked VM configuration; restore keeps its +//! existing policy-path lowering. use proptest::prelude::*; use super::*; -use crate::host::sandbox::mount::MountAccess; use crate::protocol::cmdline::{self, parameters, virtio_mmio_devices}; -/// Returns the kernel command line that OpenVMM composes from `cmdline`, the `--cmdline` that -/// `run` gives it for the cold boot of an aggregate of read-write shares, as its -/// `build_microvm_command_line` and `append_microvm_virtio_discovery` do: its console parameters, -/// `cmdline`, the virtio-mmio devices of its virtio-fs slot and of its boot virtio-console, and -/// the aggregate's bootstrap tokens. -fn effective_command_line(cmdline: &str) -> String { +/// Models the kernel's discovery of the filesystem, control console, and boot console around the +/// command line rendered by the actual cold VM configuration. +fn effective_command_line(config: &VmConfig) -> String { format!( - "earlycon=xe9 console=hvc1 reboot=t panic=-1 {cmdline} \ - virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18{}", - aggregate_command_line_fragment(&[MountAccess::ReadWrite]) + "earlycon=xe9 console=hvc1 reboot=t panic=-1 {} \ + virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18 \ + virtio_mmio.device=0x1000@0xd0003000:19", + config.kernel_command_line().unwrap() ) } @@ -49,10 +46,242 @@ fn single_share_targets_must_remain_one_kernel_token() { ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + assert!(cold_config(&options, &rules).is_err()); } } +#[test] +fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir(&first).unwrap(); + std::fs::create_dir(&second).unwrap(); + let mut options = Options { + mounts: vec![ + format!("/a,{},ro", first.display()), + format!("/b,{},rw", second.display()), + ], + ..Options::default() + }; + let rules = options.network.egress_rules().unwrap(); + let base = cold_config(&options, &rules).unwrap(); + let fragment = export_command_line_fragment(base.filesystem.as_ref().unwrap()); + let room = SANDBOX_COMMAND_LINE_MAX_SIZE + - base.kernel_command_line().unwrap().len() + - fragment.len() + - 2; + for unicode in [false, true] { + options.cmdline = if unicode { + "\u{e9}".repeat(room / 2) + &"x".repeat(room % 2) + } else { + "x".repeat(room) + }; + let fitting = cold_config(&options, &rules).unwrap(); + assert_eq!( + fitting.kernel_command_line().unwrap().len() + fragment.len() + 1, + SANDBOX_COMMAND_LINE_MAX_SIZE + ); + options.cmdline.push('x'); + assert!(cold_config(&options, &rules).is_err()); + } +} + +#[test] +fn a_console_peer_exit_waits_for_the_owned_child_status() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "peer exited", + )) + }, + |timeout| Ok((!timeout.is_zero()).then_some(7)), + ); + assert!(matches!(result, Ok(None)), "{result:?}"); +} + +#[test] +fn an_unverified_console_is_refused_while_the_owned_child_runs() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong peer", + )) + }, + |_| Ok(None), + ); + assert!( + matches!(&result, Err(Exit::Error { code: ErrorCode::Io, message }) if message == "wrong peer"), + "{result:?}" + ); +} + +#[cfg(windows)] +const REDIRECTED_CONSOLE_BYTES: &[u8] = b"nvx-raw:\xff\x80\xc3"; + +#[cfg(windows)] +#[test] +fn windows_console_output_handles_non_utf8_code_pages() { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; + + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "host::cli::run::tests::windows_console_output_helper", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("NVX_TEST_PRIVATE_CONSOLE", "cp437") + .creation_flags(CREATE_NEW_CONSOLE) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!( + output + .stdout + .windows(REDIRECTED_CONSOLE_BYTES.len()) + .any(|bytes| bytes == REDIRECTED_CONSOLE_BYTES), + "{output:?}" + ); +} + +/// Runs only as the parent's isolated-console helper, never in the shared test process. +#[cfg(windows)] +#[test] +#[ignore] +#[allow(unsafe_code)] +fn windows_console_output_helper() { + use std::io::{Read, Seek, Write}; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::{ + GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + use windows_sys::Win32::System::Console::{ + CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleOutputCP, + GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_OUTPUT_HANDLE, + SetConsoleOutputCP, SetStdHandle, + }; + + /// Restores only the helper's private console and its original redirected stdout. + struct RestoreConsole { + /// The original borrowed standard-output handle. + stdout: HANDLE, + /// The private console's original output code page. + code_page: u32, + } + impl Drop for RestoreConsole { + fn drop(&mut self) { + // SAFETY: both settings belong to this isolated helper, and stdout stays open. + unsafe { + assert_ne!(SetStdHandle(STD_OUTPUT_HANDLE, self.stdout), 0); + assert_ne!(SetConsoleOutputCP(self.code_page), 0); + } + } + } + + assert_eq!( + std::env::var("NVX_TEST_PRIVATE_CONSOLE").as_deref(), + Ok("cp437") + ); + let mut members = [0; 4]; + // SAFETY: the array is writable, and only this helper may belong to its new console. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + // SAFETY: a private, inactive screen buffer has no inherited security attributes. + let raw = unsafe { + CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + CONSOLE_TEXTMODE_BUFFER, + std::ptr::null_mut(), + ) + }; + assert_ne!(raw, INVALID_HANDLE_VALUE); + // SAFETY: CreateConsoleScreenBuffer returned this newly owned handle. + let buffer = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + let mut file = tempfile::tempfile().unwrap(); + io::stdout().flush().unwrap(); + // SAFETY: these getters return the helper's own current console settings. + let _restore = RestoreConsole { + stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, + code_page: unsafe { GetConsoleOutputCP() }, + }; + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + // SAFETY: the file is owned and remains open until stdout has been restored. + unsafe { + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, file.as_raw_handle().cast()), + 0 + ); + } + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + file.rewind().unwrap(); + let mut redirected_bytes = Vec::new(); + file.read_to_end(&mut redirected_bytes).unwrap(); + assert_eq!(redirected_bytes, REDIRECTED_CONSOLE_BYTES); + // SAFETY: only this helper is attached, and the owned buffer remains open until restoration. + unsafe { + assert_ne!(SetConsoleOutputCP(437), 0); + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, buffer.as_raw_handle().cast()), + 0 + ); + } + let mut console = ConsoleOutput::new(); + for chunk in [ + b"caf\xc3".as_slice(), + b"\xa9 \xe2".as_slice(), + b"\x82\xac|A\xff\x80|".as_slice(), + b"\xc3".as_slice(), + b"A".as_slice(), + ] { + console.write_all(chunk).unwrap(); + } + console.finish().unwrap(); + for incomplete in [b"\xc3".as_slice(), b"\xe2\x82", b"\xf0\x9f\x92"] { + let mut console = ConsoleOutput::new(); + console.write_all(b"|").unwrap(); + console.write_all(incomplete).unwrap(); + console.finish().unwrap(); + } + let expected: Vec = + "caf\u{e9} \u{20ac}|A\u{fffd}\u{fffd}|\u{fffd}A|\u{fffd}|\u{fffd}|\u{fffd}" + .encode_utf16() + .collect(); + let mut actual = vec![0; expected.len()]; + let mut read = 0; + // SAFETY: the buffer is owned, and actual has room for the requested UTF-16 characters. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + buffer.as_raw_handle().cast(), + actual.as_mut_ptr(), + actual.len() as u32, + COORD { X: 0, Y: 0 }, + &mut read, + ) + }, + 0 + ); + assert_eq!(read as usize, expected.len()); + assert_eq!(actual, expected); +} + proptest! { /// A target that `run` accepts stays inside its share's `nvx_share=` token, which the kernel /// reads as one parameter. Linux splits a parameter at a byte that it reads as whitespace, @@ -63,24 +292,27 @@ proptest! { fn share_tokens_hide_no_virtio_mmio_device( components in prop::collection::vec(component(), 2..5), ) { + let directory = tempfile::tempdir().unwrap(); + let roots: Vec<_> = (0..components.len()).map(|index| directory.path().join(format!("host-{index}"))).collect(); + for root in &roots { std::fs::create_dir(root).unwrap(); } let options = Options { mounts: components .iter() .enumerate() - .map(|(index, component)| format!("/share-{index}/{component},host-{index},rw")) + .map(|(index, component)| format!("/share-{index}/{component},{},rw", roots[index].display())) .collect(), ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - let result = extend_shares_and_network(&mut Vec::new(), &options, &rules); + let result = cold_config(&options, &rules); let separated = components.iter().any(|component| { component .bytes() .any(|byte| matches!(byte, b'\0' | b'"') || cmdline::is_space(byte)) }); prop_assert_eq!(result.is_err(), separated); - if let Ok(user) = result { - let line = effective_command_line(&user); + if let Ok(config) = result { + let line = effective_command_line(&config); let read: Vec = parameters(line.as_bytes()) .map(|parameter| { let mut text = parameter.name.to_vec(); @@ -92,12 +324,12 @@ proptest! { }) .collect(); prop_assert_eq!(read, line.split(' ').collect::>()); - prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 2); + prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 3); } } #[test] - fn policy_paths_attach_to_their_shares( + fn restore_policy_paths_attach_to_their_shares( events in prop::collection::vec((any::(), 0_u8..3, any::()), 0..40), ) { let directory = tempfile::tempdir().unwrap(); @@ -152,7 +384,7 @@ proptest! { ].map(str::to_owned); let (options, _) = parse(&arguments).unwrap(); let rules = options.network.egress_rules().unwrap(); - prop_assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + prop_assert!(cold_config(&options, &rules).is_err()); } } } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index 10a08c8ad..8f582f44e 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -77,7 +77,9 @@ use crate::host::supervisor::{self, Reattached, Vmm}; use crate::protocol::{CAPABILITY_LEN, GuestFeatures}; pub(crate) use self::directory::{create_state_directory, prepare_state_directory}; -pub(crate) use self::outcome::{validate_outcome_destination, write_exec_outcome}; +pub(crate) use self::outcome::{ + copy_openvmm_outcome, validate_outcome_destination, write_exec_outcome, +}; pub(crate) use self::start::{exec_workload, start}; pub(crate) use self::stop::{deprovision, stop}; diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs index a50cee787..d473b2c9d 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs @@ -1,7 +1,7 @@ //! Publication and reading of managed-workload and OpenVMM outcome reports. -use std::fs::OpenOptions; -use std::io::Write; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -39,11 +39,15 @@ fn outcome_destination(path: &str) -> Result { let resolved = paths::resolve(parent) .map_err(Exit::from_os_error)? .join(name); - if std::fs::symlink_metadata(&resolved).is_ok() { - return Err(Exit::error( - ErrorCode::OutcomeReportExists, - format!("outcome report already exists: {}", resolved.display()), - )); + match std::fs::symlink_metadata(&resolved) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::OutcomeReportExists, + format!("outcome report already exists: {}", resolved.display()), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(os_error(&error, &resolved)), } Ok(resolved) } @@ -57,6 +61,24 @@ pub(crate) fn validate_outcome_destination(path: &str) -> Result { /// Writes the JSON `text` to a new file at `path`: through a temporary file of mode 0600, synced, /// then linked to its name, which must not exist, with `\n` line ends on every host. fn write_new_json(path: &str, text: &str) -> Result { + let text = format!("{text}\n"); + write_new_file(path, |file| file.write_all(text.as_bytes())) +} + +/// Publishes an OpenVMM report's exact bytes without replacing an existing destination. +pub(crate) fn copy_openvmm_outcome(source: &Path, path: &str) -> Result { + let mut report = File::open(source).map_err(|error| os_error(&error, source))?; + let permissions = report + .metadata() + .map_err(|error| os_error(&error, source))? + .permissions(); + write_new_file(path, |file| { + io::copy(&mut report, file)?; + file.set_permissions(permissions) + }) +} + +fn write_new_file(path: &str, write: impl FnOnce(&mut File) -> io::Result<()>) -> Result { let resolved = outcome_destination(path)?; let temporary = temporary_path(&resolved)?; let mut options = OpenOptions::new(); @@ -74,8 +96,7 @@ fn write_new_json(path: &str, text: &str) -> Result { Err(error) => return Err(os_error(&error, &temporary)), }; let written = (|| -> Result { - let text = format!("{text}\n"); - file.write_all(text.as_bytes()) + write(&mut file) .and_then(|()| file.sync_all()) .map_err(Exit::from_os_error)?; match std::fs::hard_link(&temporary, &resolved) { @@ -84,10 +105,7 @@ fn write_new_json(path: &str, text: &str) -> Result { ErrorCode::OutcomeReportExists, format!("outcome report already exists: {}", resolved.display()), )), - Err(_) => Err(Exit::error( - ErrorCode::Io, - format!("failed to publish outcome report: {}", resolved.display()), - )), + Err(error) => Err(os_error(&error, &resolved)), } })(); drop(file); diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 8ab296e7f..60100036e 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -43,8 +43,9 @@ use crate::host::store::RuntimeIdentity; use crate::protocol::root::RootUpper; pub(crate) use self::lifecycle::ProvisionConfig; +pub(super) use self::lifecycle::{copy_openvmm_outcome, validate_outcome_destination}; pub(super) use self::one_shot::run_openvmm; -use self::options::sandbox_network; +pub(super) use self::options::sandbox_network; pub(super) use self::options::{ HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; @@ -202,7 +203,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // `provision` records its binding, which `start` checks without reading the layer again. let binding = validate_sandbox_systemd_policy(&launch)?; // The network's policy is checked before anything launches or is recorded. - let launch = launch.with_network(sandbox_network(&options, &rules)?); + let launch = launch.with_network(sandbox_network(&options.network, &rules)?); Some((launch, binding)) } else { None diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs index 33f041d17..48fc36e55 100644 --- a/nvx-rs/src/host/cli/sandbox/options.rs +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -363,7 +363,7 @@ pub(crate) fn path_string(path: &Path) -> Result { }) } -/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// Returns the network that `network` describes, with `rules`, the egress /// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of /// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which /// no other network option may then accompany. @@ -373,11 +373,10 @@ pub(crate) fn path_string(path: &Path) -> Result { /// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, /// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must /// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -pub(super) fn sandbox_network( - options: &Options, +pub(in crate::host::cli) fn sandbox_network( + network: &NetworkOptions, rules: &NativeRules, ) -> Result> { - let network = &options.network; let Some(net) = &network.net else { let network_options = network.network_egress.is_some() || network.network_ingress.is_some() diff --git a/nvx-rs/src/host/cli/store/launch.rs b/nvx-rs/src/host/cli/store/launch.rs index d0a5c04e6..d3cdb0100 100644 --- a/nvx-rs/src/host/cli/store/launch.rs +++ b/nvx-rs/src/host/cli/store/launch.rs @@ -1,13 +1,17 @@ -//! Locked launch transitions, without spawning, recovery, or process termination. +//! Locked launch transitions and bounded recovery of an interrupted launch. + +use std::thread; +use std::time::{Duration, Instant}; use super::StateFile; use super::files; use super::locked::LockedState; use super::record::{LaunchJournal, RuntimeRecord}; -use crate::host::process::ProcessRef; +use crate::host::cli::deadline::RETRY_INTERVAL; +use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm}; -use crate::{Error, Result}; +use crate::{Error, ErrorKind, Result}; /// A launch whose lock stays borrowed until commit or until its journal is left behind. #[derive(Debug)] @@ -17,6 +21,77 @@ pub(crate) struct Launch<'a> { } impl LockedState { + /// Resolves only an uncommitted journal, before the next command can mutate state. + pub(crate) fn recover(&mut self, deadline: Instant) -> Result<()> { + if self.runtime()?.is_some() { + return Ok(()); + } + let Some(journal) = self.journal()? else { + return Ok(()); + }; + if journal.vm_dir != self.dir() || journal.log != self.path(StateFile::Log) { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + match journal.process() { + Some(reference) => self.end_recorded(&journal, reference, deadline)?, + None if !self.released_claim(&journal, deadline)? => { + let process = self.endpoint_owner(&journal)?; + end_process(&process, deadline)?; + } + None => {} + } + if self.capability()?.is_some() { + files::remove(self, StateFile::Capability)?; + } + // Keep the journal until cleanup has finished, including after a partial removal. + files::remove(self, StateFile::Journal) + } + + /// Ends only the recorded process, or accepts an independently proved release of its claim. + fn end_recorded( + &self, + journal: &LaunchJournal, + reference: &ProcessRef, + deadline: Instant, + ) -> Result<()> { + let boot = BootId::current().map_err(|error| { + Error::io("cannot identify the current host boot").with_source(error) + })?; + if boot != reference.boot { + return Ok(()); + } + let unheld = match Process::open(reference) { + Ok(Opened::Running(process)) => return end_process(&process, deadline), + Ok(Opened::Exited(_)) => Error::new( + ErrorKind::StillRunning, + "the recorded VM exited, but a process may still hold its launch log", + ), + Err(error) => Error::io("cannot verify the recorded VM process").with_source(error), + }; + if self.released_claim(journal, deadline)? { + Ok(()) + } else { + Err(unheld) + } + } + + /// Rechecks transient fork-inherited descriptors for at most one second. + fn released_claim(&self, journal: &LaunchJournal, deadline: Instant) -> Result { + let until = deadline.min(Instant::now() + Duration::from_secs(1)); + loop { + if !self.claim_held(journal)? { + return Ok(true); + } + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Ok(false); + } + thread::sleep(left.min(RETRY_INTERVAL)); + } + } + /// Durably publishes the capability and complete prepared journal before the caller spawns. pub(crate) fn begin_launch( &mut self, @@ -38,6 +113,48 @@ impl LockedState { } } +/// Ends a held, verified process and requires confirmation of its exit before the deadline. +fn end_process(process: &Process, deadline: Instant) -> Result<()> { + let failed = |message: &str| { + Error::new( + ErrorKind::StillRunning, + format!( + "cannot confirm exit of VM process {}: {message}", + process.reference().pid + ), + ) + }; + if process + .wait(Instant::now()) + .map_err(|error| failed("the exit check failed").with_source(error))? + .is_some() + { + return Ok(()); + } + let until = deadline.min(Instant::now() + KILL_TIMEOUT); + if until <= Instant::now() { + return Err(failed("the recovery deadline passed")); + } + let kill_error = process.kill().err(); + let exited = process.wait(until).map_err(|error| { + let mut failure = failed("the exit wait failed"); + if let Some(kill_error) = &kill_error { + failure = failed(&format!( + "termination failed: {kill_error}; the exit wait failed" + )); + } + failure.with_source(error) + })?; + if exited.is_some() { + return Ok(()); + } + let failure = failed("the verified process has not exited"); + Err(match kill_error { + Some(error) => failure.with_source(error), + None => failure, + }) +} + impl Launch<'_> { /// Records only the caller's spawned process, durably, before changing the in-memory state. pub(crate) fn record_process(&mut self, process: &ProcessRef) -> Result<()> { diff --git a/nvx-rs/src/host/cli/store/liveness.rs b/nvx-rs/src/host/cli/store/liveness.rs new file mode 100644 index 000000000..4b584f836 --- /dev/null +++ b/nvx-rs/src/host/cli/store/liveness.rs @@ -0,0 +1,86 @@ +//! Bounded launch proofs, without recovery, record removal, or process termination. + +use std::io; + +use super::StateFile; +use super::locked::LockedState; +use super::record::LaunchJournal; +use crate::host::platform; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; +use crate::{Error, Result}; + +impl LockedState { + /// Returns whether a launch process may still run. Only a released claim proves exit; + /// failure to examine it proves neither, and preserves the original error. + pub(crate) fn claim_held(&self, journal: &LaunchJournal) -> Result { + let path = self.path(StateFile::Log); + if journal.vm_dir != self.dir() || journal.log != path { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + platform::log_claim_held(&path, journal.log_identity) + .map_err(|error| Error::io("cannot check the launch log claim").with_source(error)) + } + + /// Holds the server of the recorded endpoint, rechecks its ownership, and on Linux proves + /// that this very process also holds the launch's log. An error is not proof of exit. + pub(crate) fn endpoint_owner(&self, journal: &LaunchJournal) -> Result { + if !self.claim_held(journal)? { + return Err(Error::io("the launch log claim is released")); + } + let process = + hold_endpoint_server(|| platform::endpoint_server_pid(&journal.endpoint), find)?; + let reference = process.reference(); + let matches = + platform::serves_launch(reference.pid, reference.start_time, journal.log_identity) + .map_err(|error| { + Error::io("cannot verify the endpoint owner's log").with_source(error) + })?; + if !matches { + return Err(Error::io( + "the endpoint owner does not hold the launch log as its standard output", + )); + } + Ok(process) + } +} + +/// Opens only the process whose start time was just read, never another that reused its ID. +fn find(pid: u32) -> io::Result> { + let Some(start_time) = platform::process_start_time(pid)? else { + return Ok(None); + }; + Ok( + match Process::open(&ProcessRef { + pid, + start_time, + boot: BootId::current()?, + })? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }, + ) +} + +/// Adapted from the old store's owner check: the second endpoint lookup happens only after +/// the process is held. The callbacks keep replacement races testable without test-only hooks. +pub(super) fn hold_endpoint_server( + mut server_pid: impl FnMut() -> io::Result>, + hold: impl FnOnce(u32) -> io::Result>, +) -> Result { + let pid = server_pid() + .map_err(|error| Error::io("cannot examine the control endpoint").with_source(error))? + .ok_or_else(|| Error::io("no process serves the control endpoint"))?; + let process = hold(pid) + .map_err(|error| Error::io("cannot hold the control endpoint owner").with_source(error))? + .ok_or_else(|| Error::io("the control endpoint owner exited before it could be held"))?; + let again = server_pid() + .map_err(|error| Error::io("cannot recheck the control endpoint").with_source(error))?; + if again != Some(pid) { + return Err(Error::io( + "the control endpoint changed while its owner was held", + )); + } + Ok(process) +} diff --git a/nvx-rs/src/host/cli/store/locked.rs b/nvx-rs/src/host/cli/store/locked.rs index 68737e290..35bf46ffb 100644 --- a/nvx-rs/src/host/cli/store/locked.rs +++ b/nvx-rs/src/host/cli/store/locked.rs @@ -54,6 +54,9 @@ impl StateDir { /// access, who could hold its lock. A lock file that another transition removed or replaced /// after this opened it locks nothing, so the lock is taken again: at once the first time, /// and after a pause, until the deadline, when that happens again right after. + /// + /// Once held, the lock recovers an interrupted launch before returning. Recovery can end a + /// verified VM, but retains indeterminate records and reports the cause of its refusal. pub(crate) fn lock(&self, deadline: Deadline) -> Result { let path = self.path(StateFile::Lock); let mut retried_at_once = false; @@ -71,10 +74,14 @@ impl StateDir { } } if names_locked_file(&path, &file)? { - return Ok(LockedState { + let mut locked = LockedState { state: self.clone(), lock: file, - }); + }; + locked + .recover(deadline.instant()) + .map_err(crate::host::cli::sandbox::error_with_cause)?; + return Ok(locked); } if retried_at_once && !deadline.pause(RETRY_INTERVAL)? { return Err(busy()); diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 16bcb2432..43f2197df 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -30,6 +30,12 @@ //! A locked launch writes its prepared capability and outputs before spawn, records the spawned //! process durably, and publishes the caller's exact saved VM only at commit. Dropping an //! uncommitted launch keeps its journal; these transitions do not recover or signal a process. +//! Its journal also identifies the inherited log claim: held means a launch process may run, +//! and released proves exit. An endpoint owner is held and rechecked before it is returned; +//! Linux additionally verifies that this process holds that launch's log. +//! Taking the next lock recovers an interrupted, uncommitted launch before allowing mutation: +//! it ends only a verified process, removes its journal and capability only after proved exit, +//! and refuses indeterminate recovery without changing that evidence. #![cfg_attr( not(test), expect( @@ -40,6 +46,7 @@ mod files; mod launch; +mod liveness; mod locked; mod names; mod record; diff --git a/nvx-rs/src/host/cli/store/record.rs b/nvx-rs/src/host/cli/store/record.rs index d84718801..fa85fdf18 100644 --- a/nvx-rs/src/host/cli/store/record.rs +++ b/nvx-rs/src/host/cli/store/record.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use super::StateFile; use super::files; use super::locked::LockedState; +use crate::host::platform; use crate::host::process::ProcessRef; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm, VmDir, VmOutputs}; @@ -21,6 +22,7 @@ pub(crate) struct LaunchJournal { pub(super) vm_dir: PathBuf, pub(super) capability: [u8; CAPABILITY_LEN], pub(super) log: PathBuf, + pub(super) log_identity: (u64, u64), pub(super) outcome: PathBuf, pub(super) endpoint: String, pub(super) console: Option, @@ -34,6 +36,7 @@ impl fmt::Debug for LaunchJournal { .debug_struct("LaunchJournal") .field("vm_dir", &self.vm_dir) .field("log", &self.log) + .field("log_identity", &self.log_identity) .field("outcome", &self.outcome) .field("endpoint", &self.endpoint) .field("console", &self.console) @@ -64,10 +67,15 @@ impl LaunchJournal { "the prepared launch belongs to another sandbox directory", )); } + platform::claim_log(prepared.log()) + .map_err(|error| Error::io("cannot claim the prepared VM log").with_source(error))?; + let log_identity = platform::opened_file_identity(prepared.log()) + .map_err(|error| Error::io("cannot identify the prepared VM log").with_source(error))?; Ok(Self { vm_dir: vm_dir.path().to_path_buf(), capability: *prepared.capability().as_bytes(), log: log.clone(), + log_identity, outcome: outcome.clone(), endpoint: endpoint.clone(), console: console.clone(), diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index f28a33df9..4dabc42a6 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -9,6 +9,8 @@ use crate::host::cli::deadline::Deadline; use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; +mod liveness; +mod recovery; /// How long a test waits for a lock that it released to be free. /// diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index aff660e14..80b4f4ca2 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -11,7 +11,10 @@ use crate::host::vm::{self, Prepared, SavedVm, VmDir}; use crate::hypervisor::Hypervisor; use crate::protocol::CAPABILITY_LEN; -fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeIdentity) { +pub(super) fn prepared( + state: &StateDir, + console: ConsoleAttachment, +) -> (Prepared, RuntimeIdentity) { let identity = |text| ArtifactIdentity::new(text).unwrap(); let runtime = Runtime { openvmm: "missing-openvmm".into(), @@ -47,6 +50,7 @@ fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeI console, boot: Some(Boot { command_line: "nvx_entrypoint=/bin/sh".to_owned(), + memory_capacity_mib: None, capture: None, }), }; @@ -172,16 +176,19 @@ fn an_interruption_keeps_the_journal_and_capability_without_publishing_runtime() launch.record_process(&saved.process).unwrap(); } } + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + assert!(locked.begin_launch(&prepared, &runtime).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); drop(locked); - let mut reopened = state.lock(Deadline::after(10.0)).unwrap(); - let journal = reopened.journal().unwrap().unwrap(); + let journal: record::LaunchJournal = + serde_json::from_slice(&fs::read(state.path(StateFile::Journal)).unwrap()).unwrap(); assert_eq!(journal.process(), spawned.then_some(&saved.process)); assert_eq!(journal.capability, saved.capability); - assert_eq!(reopened.capability().unwrap(), Some(saved.capability)); - assert!(reopened.runtime().unwrap().is_none()); - let before = fs::read(state.path(StateFile::Journal)).unwrap(); - assert!(reopened.begin_launch(&prepared, &runtime).is_err()); - assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + saved.capability + ); + assert!(!state.path(StateFile::Runtime).exists()); } } diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs new file mode 100644 index 000000000..8f7c6a56f --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -0,0 +1,272 @@ +use std::error::Error as _; +use std::io; +#[cfg(any(target_os = "linux", windows))] +use std::process::{Child, Command, Stdio}; + +use super::launch::prepared; +use super::*; +use crate::host::cli::store::liveness::hold_endpoint_server; +use crate::host::config::ConsoleAttachment; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; +use crate::host::spawn::{self, VmmCommand}; + +const HELPER: &str = "host::cli::store::tests::liveness::log_holder_helper"; + +fn own_process() -> Process { + let pid = std::process::id(); + let Opened::Running(process) = Process::open(&ProcessRef { + pid, + start_time: platform::process_start_time(pid).unwrap().unwrap(), + boot: BootId::current().unwrap(), + }) + .unwrap() else { + panic!("this process runs"); + }; + process +} + +struct Running(Option); + +impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } +} + +#[cfg(any(target_os = "linux", windows))] +struct Other(Child); + +#[cfg(any(target_os = "linux", windows))] +impl Drop for Other { + fn drop(&mut self) { + self.0.kill().unwrap(); + self.0.wait().unwrap(); + } +} + +#[cfg(any(target_os = "linux", windows))] +fn helper_command(state: &StateDir) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()); + command +} + +#[test] +fn a_claim_identifies_the_opened_log_and_last_close_proves_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let identity = platform::opened_file_identity(prepared.log()).unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + assert_eq!(journal.log_identity, identity); + assert!(locked.claim_held(&journal).unwrap()); + let copy = prepared.log().try_clone().unwrap(); + drop(prepared); + assert!(locked.claim_held(&journal).unwrap()); + drop(copy); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); +} + +#[test] +fn replacing_the_log_or_sandbox_never_proves_the_launch_exited() { + let (_directory, state) = state(); + let (_other_directory, other) = super::state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let elsewhere = other.lock(Deadline::after(10.0)).unwrap(); + assert!(elsewhere.claim_held(&journal).is_err()); + drop(prepared); + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another launch").unwrap(); + let error = locked.claim_held(&journal).unwrap_err(); + assert!(error.source().is_some()); + assert!( + error + .to_string() + .contains("cannot check the launch log claim") + ); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(locked.journal().unwrap().is_some()); +} + +#[test] +fn the_spawned_process_inherits_the_preparations_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + false, + ) + .unwrap(), + )); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + #[cfg(target_os = "linux")] + { + let owner = locked.endpoint_owner(&journal).unwrap(); + assert_eq!(owner.reference().pid, child.0.as_ref().unwrap().id()); + assert!(!owner.has_exited().unwrap()); + } + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_conflicting_lock_prevents_publishing_a_launch() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let other = fs::File::open(prepared.outputs().log.clone()).unwrap(); + other.try_lock().unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let error = locked.begin_launch(&prepared, &runtime).unwrap_err(); + assert!(error.source().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_endpoint_owner_without_the_claimed_log_is_rejected_without_signaling_it() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut other = Other( + helper_command(&state) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + let error = locked.endpoint_owner(&journal).err().unwrap(); + assert!(error.to_string().contains("does not hold the launch log")); + assert!(other.0.try_wait().unwrap().is_none()); + drop(prepared); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(other.0.try_wait().unwrap().is_none()); +} + +#[cfg(windows)] +#[test] +fn without_a_log_job_inherited_handles_remain_the_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut command = helper_command(&state); + command + .stdout(Stdio::from(prepared.log().try_clone().unwrap())) + .stderr(Stdio::from(prepared.log().try_clone().unwrap())); + let child = Other(spawn_locked(&mut command).unwrap()); + drop(command); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[test] +fn endpoint_ownership_is_rechecked_only_after_the_server_is_held() { + let pid = std::process::id(); + let held = std::cell::Cell::new(false); + let mut lookups = 0; + let owner = hold_endpoint_server( + || { + lookups += 1; + assert_eq!(held.get(), lookups == 2); + Ok(Some(pid)) + }, + |found| { + assert_eq!(found, pid); + held.set(true); + Ok(Some(own_process())) + }, + ) + .unwrap(); + assert_eq!(owner.reference().pid, pid); + assert_eq!(lookups, 2); + for replacement in [None, Some(pid.wrapping_add(1))] { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + Ok(if lookups == 1 { Some(pid) } else { replacement }) + }, + |_| Ok(Some(own_process())), + ) + .err() + .unwrap(); + assert!(error.to_string().contains("endpoint changed")); + assert_eq!(lookups, 2); + } +} + +#[test] +fn missing_owners_and_lookup_or_hold_errors_are_not_exit_proofs() { + assert!(hold_endpoint_server(|| Ok(None), |_| panic!("no owner was found")).is_err()); + assert!(hold_endpoint_server(|| Ok(Some(1)), |_| Ok(None)).is_err()); + for phase in 0..3 { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + if (phase == 0 && lookups == 1) || (phase == 2 && lookups == 2) { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(std::process::id())) + } + }, + |_| { + if phase == 1 { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(own_process())) + } + }, + ) + .err() + .unwrap(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + assert_eq!(source.kind(), io::ErrorKind::PermissionDenied); + } +} + +#[test] +#[ignore = "invoked as an owned subprocess by the launch-claim tests"] +fn log_holder_helper() { + #[cfg(target_os = "linux")] + let _listener = std::os::unix::net::UnixListener::bind("control.sock").unwrap(); + fs::write("holder-ready", b"").unwrap(); + thread::sleep(Duration::from_secs(30)); +} diff --git a/nvx-rs/src/host/cli/store/tests/recovery.rs b/nvx-rs/src/host/cli/store/tests/recovery.rs new file mode 100644 index 000000000..82600dbe8 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/recovery.rs @@ -0,0 +1,363 @@ +use super::launch::prepared; +use super::*; +use crate::host::cli::store::files; +use crate::host::config::ConsoleAttachment; + +#[test] +fn the_next_lock_cleans_a_pre_spawn_journal_only_after_its_claim_is_released() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(locked); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + let started = Instant::now(); + assert!(state.lock(Deadline::after(0.05)).is_err()); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(prepared); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + assert!(state.path(StateFile::Log).exists()); +} + +#[test] +fn a_transient_inherited_log_descriptor_is_rechecked_before_refusing_recovery() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let inherited = prepared.log().try_clone().unwrap(); + drop(prepared); + drop(locked); + let release = thread::spawn(move || { + thread::sleep(Duration::from_millis(80)); + drop(inherited); + }); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + release.join().unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[test] +fn an_interrupted_cleanup_with_no_capability_can_finish_idempotently() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + files::remove(&locked, StateFile::Capability).unwrap(); + drop(prepared); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(locked.journal().unwrap().is_none()); + locked.recover(Instant::now()).unwrap(); +} + +#[test] +fn replaced_logs_and_foreign_journal_paths_keep_all_evidence() { + for foreign in [false, true] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(prepared); + if foreign { + let mut journal = locked.journal().unwrap().unwrap(); + journal.vm_dir = state.dir().join("another-vm"); + locked.write_record(StateFile::Journal, &journal).unwrap(); + } else { + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another log").unwrap(); + } + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!(locked.recover(Instant::now()).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(locked); + let (_, message) = error(state.lock(Deadline::after(0.0)).unwrap_err()); + let cause = if foreign { + "another sandbox directory" + } else { + "is not the claimed log" + }; + assert!(message.contains(cause), "{message}"); + } +} + +#[cfg(any(target_os = "linux", windows))] +mod process { + #[cfg(target_os = "linux")] + use std::process::{Child, Command, Stdio}; + + use super::*; + use crate::host::process::{BootId, Process, ProcessRef}; + use crate::host::spawn::{self, VmmCommand}; + use crate::host::vm::{Prepared, SavedVm, VmDir}; + + const HELPER: &str = "host::cli::store::tests::recovery::process::launch_helper"; + + struct Running(Option); + + impl Running { + fn process(&self) -> Process { + self.0.as_ref().unwrap().process().unwrap() + } + } + + impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } + } + + #[cfg(target_os = "linux")] + struct Other(Child); + + #[cfg(target_os = "linux")] + impl Drop for Other { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } + } + + fn running(state: &StateDir, prepared: &Prepared) -> Running { + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + false, + ) + .unwrap(), + )); + assert!(released(|| state.dir().join("recovery-ready").exists())); + child + } + + #[test] + fn an_unrecorded_vm_requires_both_endpoint_and_log_proofs_before_it_is_ended() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let child = running(&state, &prepared); + let process = child.process(); + drop(prepared); + drop(locked); + assert!(!process.has_exited().unwrap()); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn an_endpoint_owner_without_the_log_is_not_signaled_and_its_journal_stays() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut other = Other(crate::host::tests::spawn_locked(&mut command).unwrap()); + assert!(released(|| state.dir().join("recovery-ready").exists())); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(other.0.try_wait().unwrap().is_none()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + + #[test] + fn a_recorded_process_is_ended_only_after_a_matching_identity_and_confirmed_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + drop(launch); + drop(prepared); + assert!(locked.recover(Instant::now()).is_err()); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn incorrect_recorded_ids_and_start_times_never_signal_a_live_process_or_drop_its_claim() { + for mismatch in 0..3 { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut reference = process.reference().clone(); + match mismatch { + 0 => reference.pid = 0, + 1 => { + reference.pid = std::process::id(); + reference.start_time = platform::process_start_time(reference.pid) + .unwrap() + .unwrap() + + 1; + } + 2 => reference.start_time += 1, + _ => unreachable!(), + } + launch.record_process(&reference).unwrap(); + drop(launch); + drop(prepared); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + } + + #[test] + fn an_earlier_boot_proves_exit_without_signaling_a_process_from_this_boot() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut boot = *BootId::current().unwrap().as_bytes(); + boot[0] ^= 1; + launch + .record_process(&ProcessRef { + boot: BootId::from_bytes(boot), + ..process.reference().clone() + }) + .unwrap(); + drop(launch); + drop(prepared); + locked.recover(Instant::now()).unwrap(); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn a_committed_runtime_is_not_recovered_through_its_leftover_journal() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + launch + .commit(&SavedVm { + vm_dir: VmDir::open(state.dir()).unwrap(), + endpoint: prepared.outputs().endpoint.clone(), + capability: *prepared.capability().as_bytes(), + process: process.reference().clone(), + runtime, + }) + .unwrap(); + files::replace(&locked, StateFile::Journal, &journal).unwrap(); + let before = fs::read(state.path(StateFile::Runtime)).unwrap(); + drop(prepared); + drop(locked); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Runtime)).unwrap(), before); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert!(locked.capability().unwrap().is_some()); + } + + #[test] + #[ignore = "invoked as an owned subprocess by interrupted-launch recovery tests"] + fn launch_helper() { + let endpoint = fs::read_to_string("recovery-endpoint").unwrap(); + #[cfg(target_os = "linux")] + let _server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); + #[cfg(windows)] + let server = platform::tests::control_server(&endpoint); + fs::write("recovery-ready", b"").unwrap(); + #[cfg(target_os = "linux")] + thread::sleep(Duration::from_secs(30)); + #[cfg(windows)] + { + use std::io::Read; + + loop { + platform::tests::accept_control(&server); + match (&server).read(&mut [0]) { + Ok(0) => {} + Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {} + result => panic!("unexpected endpoint probe: {result:?}"), + } + platform::tests::disconnect_control(&server); + } + } + } +} diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1e0e25b6a..b600a7d22 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -161,6 +161,9 @@ pub struct Boot { /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. pub command_line: String, + /// Memory capacity of a cold VM, in MiB, if it reserves more than its boot memory. + /// It must be at least [`VmConfig::memory_mib`] and fit a byte count. + pub memory_capacity_mib: Option, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, } @@ -615,6 +618,9 @@ impl VmConfig { "--processors".into(), self.processors.to_string().into(), ]; + if let Some(capacity) = self.boot.as_ref().and_then(|boot| boot.memory_capacity_mib) { + arguments.extend(["--memory-capacity".into(), format!("{capacity}M").into()]); + } if let RootConfig::Layered(root) = &self.root { let scratch = match &root.upper { UpperConfig::Scratch { path, .. } => Some(path), @@ -714,6 +720,13 @@ impl VmConfig { capture.check()?; } if let Some(boot) = &self.boot { + if let Some(capacity) = boot.memory_capacity_mib + && (capacity < self.memory_mib || capacity > u64::MAX >> 20) + { + return Err(Error::unsupported( + "a cold boot's memory capacity must be at least its memory and fit a byte count", + )); + } let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { name: token.name, value: token.value, diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index ac8f140c8..6256c3e8c 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -23,6 +23,29 @@ fn missing(name: &str) -> PathBuf { std::env::temp_dir().join("nvx-config-missing").join(name) } +#[test] +fn cold_memory_capacity_is_checked_and_lowered_without_a_capture() { + let mut config = cold(blockless()); + config.boot.as_mut().unwrap().memory_capacity_mib = Some(1024); + let arguments = config + .openvmm_arguments( + &runtime(), + "/control", + Path::new("/report"), + Some("/console"), + ) + .unwrap(); + assert_eq!(values(&arguments, "--memory"), [OsStr::new("512M")]); + assert_eq!( + values(&arguments, "--memory-capacity"), + [OsStr::new("1024M")] + ); + for capacity in [511, u64::MAX] { + config.boot.as_mut().unwrap().memory_capacity_mib = Some(capacity); + assert_eq!(config.check().unwrap_err().kind(), ErrorKind::Unsupported); + } +} + /// Returns a blockless configuration of one processor and 512 MiB. fn blockless() -> VmConfig { VmConfig { @@ -132,10 +155,12 @@ fn configurations_pass_with_or_without_a_nic_address_and_a_boot() { let boots = [ None, Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), @@ -550,6 +575,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { let mut config = VmConfig { filesystem: filesystem.clone(), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -614,6 +640,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { fn the_check_of_a_configuration_checks_its_boots_capture() { let booting = |capture: Option| VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture, }), @@ -662,6 +689,7 @@ fn captured() -> VmConfig { policy: NetworkPolicy::new(Access::Deny), }), boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet".to_owned(), capture: Some(capture()), }), @@ -896,6 +924,7 @@ fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -919,6 +948,7 @@ fn only_a_checked_cold_boot_has_a_kernel_command_line() { let config = VmConfig { processors: 0, boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -975,6 +1005,7 @@ fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { for config in [blockless(), layered(erofs())] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: command_line.clone(), capture: None, }), @@ -1008,6 +1039,7 @@ fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { ] { let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1046,6 +1078,7 @@ fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { let mut config = VmConfig { filesystem: Some(plan.clone()), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1104,6 +1137,7 @@ fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { fn cold(config: VmConfig) -> VmConfig { VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), capture: None, }), @@ -1159,6 +1193,7 @@ fn cold_boot_command_lines_refuse_native_control_console_syntax() { ] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: caller.to_owned(), capture: None, }), diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index dc29d19f6..50328a0bc 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -268,7 +268,14 @@ pub(crate) fn connect_endpoint( )); } check_original_peer(&stream)?; - Ok(Box::new(UnixTransport { stream })) + transport_from_handle(stream.into()) +} + +/// Adapts an owned duplicate of an already verified connection without changing its blocking mode. +pub(crate) fn transport_from_handle(handle: TransportHandle) -> io::Result> { + Ok(Box::new(UnixTransport { + stream: UnixStream::from(handle), + })) } /// Returns the process that listens on a control endpoint, or `None` if nothing listens. diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 516d09b16..d50af4900 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -14,6 +14,8 @@ use crate::hypervisor::Hypervisor; #[cfg(feature = "cli")] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index be5b7967a..37392e1ba 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -2,6 +2,8 @@ pub(crate) use super::linux::tests::{ check_identity, owner_only_problem, start_time_from_stat, store_dir_problem, }; +#[cfg(all(windows, feature = "cli"))] +pub(crate) use super::windows::tests::disconnect_control; #[cfg(windows)] pub(crate) use super::windows::tests::set_access; #[cfg(windows)] diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 5ade3b93c..7358e0454 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -234,6 +234,11 @@ pub(crate) fn connect_endpoint( ), )); } + transport_from_handle(pipe) +} + +/// Gives an already verified pipe duplicate its own overlapped-operation event. +pub(crate) fn transport_from_handle(pipe: TransportHandle) -> io::Result> { // SAFETY: creates an unnamed manual-reset event with default security. let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) }) .ok_or_else(io::Error::last_os_error)?; diff --git a/nvx-rs/src/host/platform/windows/endpoint/tests.rs b/nvx-rs/src/host/platform/windows/endpoint/tests.rs index 42e283d69..4d882574a 100644 --- a/nvx-rs/src/host/platform/windows/endpoint/tests.rs +++ b/nvx-rs/src/host/platform/windows/endpoint/tests.rs @@ -36,6 +36,19 @@ pub(crate) fn accept_control(server: &File) -> i32 { connected } +#[cfg(feature = "cli")] +pub(crate) fn disconnect_control(server: &File) { + use std::os::windows::io::{AsHandle, AsRawHandle}; + + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + // SAFETY: the owned synchronous pipe finished its previous client. + assert_ne!( + unsafe { DisconnectNamedPipe(server.as_handle().as_raw_handle()) }, + 0 + ); +} + #[test] fn a_duplicated_transport_keeps_the_verified_connection_without_reconnecting() { let endpoint = format!( diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index eef623a5c..f8633541e 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -22,6 +22,8 @@ use windows_sys::Win32::System::Threading::OpenProcess; use crate::hypervisor::Hypervisor; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; @@ -44,7 +46,7 @@ pub(crate) use self::process::{ }; #[cfg(feature = "cli")] pub(crate) use self::spawn::spawn_foreground; -pub(crate) use self::spawn::{LaunchedProcess, spawn_detached}; +pub(crate) use self::spawn::{LaunchedProcess, SpawnMode, spawn_with_stdio}; /// Whether this host can run NVX microVMs. pub const SUPPORTED: bool = true; diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e89348406..ccb82122c 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -252,19 +252,27 @@ impl Drop for InheritableForSpawn { /// [`SpawnLock`]: crate::host::platform::inherit::SpawnLock /// [`spawn_command`]: crate::host::platform::inherit::spawn_command /// [`log_claim_held`]: crate::host::platform::windows::log_claim_held -pub(crate) fn spawn_detached( +pub(crate) fn spawn_with_stdio( program: &Path, arguments: &[OsString], working_dir: &Path, stdio: [OwnedHandle; 3], inherit: &[Inherited<'_>], - breakaway_from_job: bool, + mode: SpawnMode, job: Option, ) -> io::Result { - let mut flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; - if breakaway_from_job { - flags |= CREATE_BREAKAWAY_FROM_JOB; - } + let flags = match mode { + SpawnMode::Foreground => 0, + SpawnMode::Detached { breakaway_from_job } => { + CREATE_NEW_PROCESS_GROUP + | CREATE_NO_WINDOW + | if breakaway_from_job { + CREATE_BREAKAWAY_FROM_JOB + } else { + 0 + } + } + }; spawn_listed( program, arguments, @@ -276,12 +284,23 @@ pub(crate) fn spawn_detached( ) } +/// Whether the child stays in the caller's console and job. +pub(crate) enum SpawnMode { + /// Keeps the caller's console, process group, and job membership. + Foreground, + /// Starts without a console, in a new process group. + Detached { + /// Leaves the caller's job when that job permits breakaway. + breakaway_from_job: bool, + }, +} + /// Starts OpenVMM in the caller's console, process group, and working directory, as /// `subprocess.run` starts a child, with copies of the caller's standard handles as its own. /// /// Unlike `std::process::Command`, which lets the child inherit every inheritable handle of the /// caller, it passes the child no other handle of the caller than the handles `inherit`, which -/// keep their values in the child, through the handle list of [`spawn_detached`], with the same +/// keep their values in the child, through the handle list of [`spawn_with_stdio`], with the same /// window, under the same lock, for processes that the caller spawns concurrently without one. /// Unlike `std::process::Command` too, which escapes the arguments of a batch file for `cmd.exe`, /// it fails with [`io::ErrorKind::InvalidInput`] for a batch file, as [`refuse_batch_file`] says. @@ -331,7 +350,7 @@ fn standard_handle(which: STD_HANDLE) -> io::Result> { /// Starts `program` with `arguments` in `working_dir`, or in the caller's working directory, with /// the creation flags `flags`, the handles `stdio` as its standard handles, a missing one null, /// and the caller's handles `inherit`, and no other handle of the caller, in the job object -/// `job`, if any, as [`spawn_detached`] describes. Fails with [`io::ErrorKind::InvalidInput`] for +/// `job`, if any, as [`spawn_with_stdio`] describes. Fails with [`io::ErrorKind::InvalidInput`] for /// a working directory beyond Windows' limit or for a batch file, as [`refuse_batch_file`] says. fn spawn_listed( program: &Path, diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..57f5a18ff 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -20,6 +20,8 @@ use windows_sys::Win32::System::Threading::{ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, TerminateProcess, }; +#[cfg(feature = "cli")] +pub(crate) use super::endpoint::tests::disconnect_control; pub(crate) use super::endpoint::tests::{accept_control, control_server}; use super::identity::status_error; use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; @@ -228,13 +230,15 @@ fn children_inherit_only_their_standard_handles() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let child = spawn_detached( + let child = spawn_with_stdio( &cmd, &[OsString::from("/c"), OsString::from("ping -n 30 127.0.0.1")], &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -313,13 +317,15 @@ fn a_child_loses_only_the_inherited_handle_that_is_closed_in_it() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let ping = Path::new(&system).join("System32").join("PING.EXE"); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&closed), Inherited::new(&kept)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -371,13 +377,15 @@ fn children_inherit_the_allowed_handles_while_they_are_created() { Inherited::new(&kept), Inherited::new(&allowed), ]; - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -457,13 +465,15 @@ fn batch_files_are_refused_before_cmd_could_read_their_arguments() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - refused(spawn_detached( + refused(spawn_with_stdio( &program, &arguments, directory.path(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, )); } @@ -492,13 +502,15 @@ fn concurrent_spawns_that_allow_one_handle_all_inherit_it_and_restore_it() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&shared)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -524,13 +536,15 @@ fn a_spawn_that_panics_in_its_window_restores_the_handles_and_lets_other_spawns_ let (stdin, _stdin_writer) = io::pipe()?; let (_stdout_reader, stdout) = io::pipe()?; let stderr = stdout.try_clone()?; - spawn_detached( + spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) } @@ -649,13 +663,15 @@ impl HeldSpawn { }))); let (stdin, _stdin_writer) = io::pipe()?; let stderr = file.try_clone()?; - spawn_detached( + spawn_with_stdio( &program, &arguments, &std::env::temp_dir(), [stdin.into(), file.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, job, ) }); @@ -1122,13 +1138,15 @@ fn a_thread_that_holds_the_spawn_lock_spawns_under_it_until_its_last_lock_is_dro let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let vmm = spawn_detached( + let vmm = spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index f163d92da..918e04ae3 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -1,6 +1,8 @@ #![cfg(any(target_os = "linux", windows))] #![cfg_attr(windows, allow(unsafe_code))] +mod live_init; + use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; use std::time::Instant; diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs new file mode 100644 index 000000000..9b958ede6 --- /dev/null +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -0,0 +1,522 @@ +use std::error::Error; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::Hypervisor; +use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Export, ExportKind, Image, MachineIdMode, + Network, OverlayMode, Publication, ReadyActivation, RestoreSection, TransactionId, Upper, View, +}; +use crate::host::instance::generate_capability; +use crate::host::nvxs::HostConnection; +use crate::host::session; +use crate::host::supervisor::{Launched, VmmCommand, spawn}; +use crate::host::template::TemplateManifest; +use crate::protocol::{ + APP_ERROR, APP_EXEC, APP_EXIT, APP_MAPS, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, + APP_STOPPED, HostMap, Workload, WorkloadEnvironment, decode_app, encode_app, + encode_exec_payload, encode_maps_payloads, +}; + +type AnyError = Box; +const ACTIVATE: u8 = 8; +const CAPTURE: u8 = 7; + +struct OwnedVm(Option); +impl Drop for OwnedVm { + fn drop(&mut self) { + if let Some(vm) = self.0.take() { + if vm + .process() + .and_then(|process| process.has_exited()) + .is_ok_and(|done| done) + { + vm.detach(); + } else if !vm.kill_and_wait() { + eprintln!("OWNED_RESTORE_TEARDOWN_FAILED"); + } + } + } +} + +fn require(ok: bool, message: impl Into) -> Result<(), AnyError> { + if ok { + Ok(()) + } else { + Err(message.into().into()) + } +} + +fn backend() -> Hypervisor { + if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + } +} + +fn private_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(windows)] + { + fs::create_dir(path) + } +} + +fn ready( + connection: &mut HostConnection, + id: u64, + expected: &[u8], + by: Instant, +) -> Result<(), AnyError> { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + (frame.kind, frame.request_id, frame.status, frame.payload) == (APP_READY, id, 0, expected), + format!("unexpected Ready: {frame:?}"), + ) +} + +fn workload(script: &str) -> Result, AnyError> { + Ok(encode_exec_payload(&Workload { + argv: vec!["/bin/sh".into(), "-eu".into(), "-c".into(), script.into()], + timeout_ms: 10_000, + cwd: Some("/"), + environment: WorkloadEnvironment::Default, + })?) +} + +fn execute( + connection: &mut HostConnection, + id: u64, + script: &str, + by: Instant, +) -> Result { + connection.send(&encode_app(APP_EXEC, id, 0, &workload(script)?), Some(by))?; + let mut output = Vec::new(); + loop { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.request_id == id, + format!("wrong execution response: {frame:?}"), + )?; + match frame.kind { + APP_STDOUT => output.extend_from_slice(frame.payload), + APP_STDERR => eprint!("{}", String::from_utf8_lossy(frame.payload)), + APP_EXIT => { + require( + frame.status == 0 && frame.payload == b"exit", + format!("execution failed: {frame:?}"), + )?; + return Ok(String::from_utf8(output)?); + } + _ => return Err(format!("unexpected execution frame: {frame:?}").into()), + } + } +} + +fn run() -> Result<(), AnyError> { + let grant = std::env::var("NVX_SL77B_LIVE_GRANT")?; + require( + !grant.is_empty(), + "live fixture requires explicit named host admission", + )?; + let mut args = vec![OsString::from("nvx-live-restored-init")]; + for key in [ + "NVX_SL77B_LIVE_NATIVE", + "NVX_SL77B_LIVE_SNAPSHOT", + "NVX_SL77B_LIVE_DISTRO", + "NVX_SL77B_LIVE_CONSOLE", + "NVX_SL77B_LIVE_ROOT", + "NVX_SL77B_LIVE_RESULT", + "NVX_SL77B_LIVE_CASE", + ] { + args.push( + std::env::var_os(key).ok_or_else(|| format!("missing live fixture input {key}"))?, + ); + } + require( + args.len() == 8, + "usage: restore VMM SNAPSHOT DISTRO ORIGINAL_CONSOLE ROOT RESULT CASE", + )?; + let vmm = PathBuf::from(&args[1]); + let snapshot = PathBuf::from(&args[2]); + let distro = PathBuf::from(&args[3]); + let original_console = PathBuf::from(&args[4]); + let root = PathBuf::from(&args[5]); + let result = PathBuf::from(&args[6]); + let case = args[7].to_str().ok_or("non-Unicode case")?; + require( + matches!(case, "published" | "unpublished" | "changed" | "deadline"), + "invalid fixture case", + )?; + for path in [&vmm, &snapshot, &distro, &original_console, &root, &result] { + require(path.is_absolute(), "all fixture paths must be absolute")?; + } + let manifest = TemplateManifest::read(&snapshot)?; + require( + manifest.hypervisor() == backend() && manifest.memory_len() == 128 << 20, + "wrong backend/memory snapshot", + )?; + require( + !root.exists() && !result.exists(), + "preserve existing fixture effects", + )?; + private_directory(&root)?; + let export = root.join("export"); + fs::create_dir(&export)?; + fs::write(export.join("proof"), b"CURRENT_573F_HOST_MAP\n")?; + let endpoint = if cfg!(windows) { + format!( + "//./pipe/openvmm-microvm-sl77b-live-{}-{case}", + std::process::id() + ) + } else { + snapshot + .parent() + .ok_or("snapshot has no private namespace parent")? + .join(format!("nvxs-{case}")) + .to_str() + .ok_or("non-Unicode endpoint")? + .to_owned() + }; + let address: SocketAddr = fs::read_to_string(&original_console)?.trim().parse()?; + require(address.ip().is_loopback(), "console must remain loopback")?; + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let console = root.join("restored-console.log"); + let console_output = console.clone(); + let reader = thread::spawn(move || -> io::Result<()> { + let by = Instant::now() + Duration::from_secs(30); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < by => { + thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(error), + } + }; + stream.set_nonblocking(false)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(console_output)?; + io::copy(&mut stream, &mut output)?; + Ok(()) + }); + let report = root.join("outcome.json"); + let (_temporary, directory) = super::vm_directory(); + let mut listener = super::ReadyListener::bind(&directory)?; + let acknowledgement = listener.endpoint().to_owned(); + let log_file = root.join("openvmm.log"); + let log = OpenOptions::new() + .write(true) + .create_new(true) + .open(&log_file)?; + let options: Vec = vec![ + "--machine".into(), + "microvm".into(), + "--microvm-sandbox-block".into(), + format!("distro:file:{},ro", distro.display()).into(), + "--restore-absent-scratch".into(), + "--single-process".into(), + "--hypervisor".into(), + if cfg!(windows) { "whp" } else { "mshv" }.into(), + "--cpu-profile".into(), + "auto".into(), + "--processors".into(), + "1".into(), + "--restore-snapshot".into(), + snapshot.as_os_str().to_owned(), + "--restore-memory".into(), + "128M".into(), + "--restore-ready-path".into(), + acknowledgement.into(), + "--restore-network-policy".into(), + "--network-profile".into(), + "portable".into(), + "--network-egress".into(), + "deny".into(), + "--network-ingress".into(), + "deny".into(), + "--host-loopback".into(), + "deny".into(), + "--mount-aggregate".into(), + "/run/nvx/hostfs/root".into(), + "--mount-child".into(), + format!("0,{},ro", export.display()).into(), + "--virtio-console".into(), + format!("connect=tcp:{address}").into(), + "--microvm-control-console".into(), + format!("listen={endpoint}").into(), + "--microvm-control-auth-stdin".into(), + "--microvm-report".into(), + report.as_os_str().to_owned(), + ]; + let capability = generate_capability()?; + let command = VmmCommand::new(vmm, options, &root); + let mut owned = OwnedVm(Some(spawn(&command, &capability, log)?)); + let running = owned.0.as_ref().ok_or("missing owned VM")?; + let process = running.process()?; + let pid = running.id(); + fs::write( + root.join("process.txt"), + format!( + "pid={pid}\nreference={:?}\ncase={case}\n", + process.reference() + ), + )?; + println!( + "OWNED_RESTORE_PID={pid} REFERENCE={:?} CASE={case}", + process.reference() + ); + let by = Instant::now() + Duration::from_secs(90); + let expected_event = b"OPENVMM_RESTORE_READY_V1\n"; + let mut event = Vec::new(); + while event.len() < expected_event.len() { + require(Instant::now() < by, "native readiness deadline expired")?; + let open = listener.receive(&process, &mut event, 64, Duration::from_millis(50))?; + require( + open || event.len() == expected_event.len(), + "native readiness closed before its event", + )?; + } + require( + event == expected_event, + "native restore-ready event differs", + )?; + super::parse_ready_event(&event)?; + fs::write(root.join("native-ready.receipt"), &event)?; + println!("NATIVE_READY_AUTHENTICATED_PID={pid}"); + let native_ready = Instant::now(); + if case == "deadline" { + thread::sleep(Duration::from_secs(8)); + } + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + let mut connection = HostConnection::attach(transport, &capability, by)?; + println!( + "RESTORED_HOST_SESSION_EPOCH={}", + connection.identity().epoch + ); + let attached = Instant::now(); + let expected_status; + if case == "deadline" { + require( + process + .wait(Instant::now() + Duration::from_secs(40))? + .is_some(), + "activation deadline did not end VM", + )?; + require( + native_ready.elapsed() >= Duration::from_secs(27) + && native_ready.elapsed() < Duration::from_secs(38) + && attached.elapsed() < Duration::from_secs(27), + "activation deadline renewed at the deliberately delayed host attachment", + )?; + println!( + "ORIGINAL_ACK_DEADLINE_ELAPSED_MS={}", + native_ready.elapsed().as_millis() + ); + expected_status = 125; + } else { + let published = case != "unpublished"; + let transaction = TransactionId::from_bytes([0x77; 16]); + let record = ActivationRecord::new( + Some(RestoreSection { transaction }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 50, + }, + overlay: OverlayMode::UntrustedLowers, + export: Some(Export { + kind: ExportKind::Aggregate, + }), + mappings: 1, + network: Some(Network { + dns: Some("10.88.0.53".parse()?), + publication: if published { + Publication::Published + } else { + Publication::Unpublished + }, + proxy: None, + }), + view: View { + hostname: b"restored-573f".to_vec(), + machine_id: MachineIdMode::Generated, + }, + resources: ExpectedResources { + cpus: 1, + memory: 128 << 20, + }, + }, + )?; + let mut activation = Vec::new(); + activation.extend_from_slice(&1000u32.to_le_bytes()); + activation.extend_from_slice(&1001u32.to_le_bytes()); + activation.push(1); + activation.extend(record.encode()); + fs::write(root.join("activate.bin"), &activation)?; + let expected_ready = ReadyActivation { + transaction: Some(transaction), + } + .encode(); + connection.send(&encode_app(ACTIVATE, 100, 0, &activation), Some(by))?; + ready(&mut connection, 100, &expected_ready, by)?; + println!("ACTIVATION_READY_PUBLICATION={published} OWN_DNS=10.88.0.53"); + if case == "changed" { + activation[0] ^= 1; + connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; + require( + process + .wait(Instant::now() + Duration::from_secs(15))? + .is_some(), + "changed identity replay did not end VM", + )?; + expected_status = 125; + println!("CHANGED_IDENTITY_REPLAY_FATAL"); + } else { + connection.send(&encode_app(APP_EXEC, 102, 0, &workload("true")?), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR + && frame.status == 11 + && frame.payload == b"mappings-incomplete", + format!("EXEC bypassed MAPS gate: {frame:?}"), + )?; + let maps = [HostMap { + source: "0".into(), + target: "/mnt/restore-map".into(), + read_only: true, + }]; + for payload in encode_maps_payloads(&maps, 32768)? { + connection.send(&encode_app(APP_MAPS, 103, 0, &payload), Some(by))?; + ready(&mut connection, 103, &[], by)?; + } + let script = format!( + r#" +test "$(id -u)" = 1000 +test "$(id -g)" = 1001 +test "$(hostname)" = restored-573f +grep -q 'nameserver 10.88.0.53' /etc/resolv.conf +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +test ! -e /sbin/nvx-ingress +count=0 +for p in /proc/[0-9]*; do + test "$(cat "$p/comm" 2>/dev/null || true)" = nvx-ingress || continue + count=$((count + 1)) + grep -q '^PPid:[[:space:]]*1$' "$p/status" + test "$(cat "$p/oom_score_adj")" = -1000 + test "$(tr '\000' ' ' <"$p/cmdline")" = 'nvx-ingress 10.88.0.1 ' + test "$(awk '{{print $19}}' "$p/stat")" = -10 + printf 'REAL_RESTORED_ANNOUNCER pid=%s argv=' "${{p##*/}}" + tr '\000' ' ' <"$p/cmdline" + printf '\n' +done +test "$count" = {} +printf 'PRESERVED_573F_UPPER\n' >/tmp/restore-sentinel +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'REAL_READY_MAPS_EXEC_OK\n' +"#, + u8::from(published) + ); + let first = execute(&mut connection, 104, &script, by)?; + print!("{first}"); + let machine = first + .lines() + .find(|line| line.starts_with("MACHINE_ID=")) + .ok_or("missing generated machine ID")? + .to_owned(); + for kill in [0, 1] { + connection.send( + &encode_app(CAPTURE, 105 + u64::from(kill), 0, &[kill]), + Some(by), + )?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR && frame.status == 95, + "activated restore accepted capture", + )?; + } + drop(connection); + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + connection = HostConnection::attach(transport, &capability, by)?; + connection.send(&encode_app(ACTIVATE, 107, 0, &activation), Some(by))?; + ready(&mut connection, 107, &expected_ready, by)?; + let replay = execute( + &mut connection, + 108, + r#" +test "$(cat /tmp/restore-sentinel)" = PRESERVED_573F_UPPER +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' +"#, + by, + )?; + require( + replay.lines().any(|line| line == machine), + "retry regenerated the machine ID", + )?; + print!("{replay}"); + connection.send(&encode_app(APP_STOP, 109, 0, &[]), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_STOPPED && frame.status == 0, + "STOP not acknowledged", + )?; + require( + process + .wait(Instant::now() + Duration::from_secs(30))? + .is_some(), + "STOP did not tear down restore", + )?; + expected_status = 0; + } + } + drop(connection); + reader.join().map_err(|_| "console reader panicked")??; + let outcome = crate::host::report::read(&report)?.ok_or("missing native outcome")?; + require( + outcome.status_code == Some(expected_status) && outcome.teardown_complete == Some(true), + format!("wrong restore outcome: {outcome:?}"), + )?; + let logs = fs::read_to_string(&console)?; + let switches = logs.matches("NVX-INIT-ROOT: switched").count(); + require( + switches == usize::from(case != "deadline"), + format!("unexpected root switch count {switches}"), + )?; + let text = format!( + "RESTORE_OK case={case} pid={pid} status={expected_status} switches={switches} teardown=true\n" + ); + fs::write(result, &text)?; + print!("{text}"); + owned.0.take().ok_or("missing owned restore")?.detach(); + Ok(()) +} + +#[test] +#[ignore = "requires explicit named host admission and current-source live restore fixture inputs"] +fn live_restored_init_activation() -> Result<(), AnyError> { + run() +} diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index fd71827ab..f9009867f 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -1,4 +1,4 @@ -//! The detached spawn of a VMM, with an explicit allowlist of the descriptors or handles of the +//! The spawn of a VMM, with an explicit allowlist of the descriptors or handles of the //! caller that it inherits, and the child that the spawn returns until its caller releases it. use std::ffi::OsString; @@ -107,7 +107,7 @@ pub fn spawn( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { - spawn_inner(command, capability, log, false) + spawn_inner(command, capability, log, false, false) } /// Starts a library VM only with a trusted log-named job on Windows. @@ -115,8 +115,9 @@ pub(crate) fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, + foreground: bool, ) -> io::Result { - spawn_inner(command, capability, log, true) + spawn_inner(command, capability, log, true, foreground) } /// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. @@ -125,6 +126,7 @@ fn spawn_inner( capability: &[u8; CAPABILITY_LEN], log: File, require_log_job: bool, + foreground: bool, ) -> io::Result { #[cfg(not(windows))] let _ = require_log_job; @@ -141,13 +143,20 @@ fn spawn_inner( "the VM log has no trusted log-named job", )); } - platform::spawn_detached( + let mode = if foreground { + platform::SpawnMode::Foreground + } else { + platform::SpawnMode::Detached { + breakaway_from_job: command.breakaway_from_job, + } + }; + platform::spawn_with_stdio( &command.program, &command.arguments, &command.working_dir, [stdin.into(), log.into(), stderr.into()], &command.inherit, - command.breakaway_from_job, + mode, job, ) .map(|process| Launched { process }) @@ -164,7 +173,17 @@ fn spawn_inner( .stdin(Stdio::from(stdin)) .stdout(Stdio::from(log)) .stderr(Stdio::from(stderr)); - platform::detach(&mut child, &command.inherit)?; + #[cfg(feature = "cli")] + if foreground { + platform::inherit_only(&mut child, &command.inherit)?; + } else { + platform::detach(&mut child, &command.inherit)?; + } + #[cfg(not(feature = "cli"))] + { + let _ = foreground; + platform::detach(&mut child, &command.inherit)?; + } child.spawn().map(|child| Launched { child, reaped: false, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 142ca7c0c..9c0fbd585 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,8 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +#[cfg(feature = "cli")] +use std::time::Duration; use std::time::Instant; use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; @@ -307,6 +309,8 @@ pub struct Prepared { working_dir: PathBuf, /// Whether the child leaves the caller's Windows job. windows_breakaway: bool, + /// Whether this CLI-owned child keeps the caller's session, group, and console. + foreground: bool, /// The control credential, never written to disk. capability: Capability, /// The launch's fixed file paths and chosen endpoints. @@ -320,6 +324,13 @@ pub struct Prepared { } impl Prepared { + /// Keeps this CLI launch in its caller's session, process group, and console. + #[cfg(feature = "cli")] + pub(crate) fn for_foreground(mut self) -> Self { + self.foreground = true; + self + } + /// Returns the credential to journal before a spawn or use when attaching a session. pub fn capability(&self) -> &Capability { &self.capability @@ -471,6 +482,7 @@ pub fn create( program: runtime.openvmm.clone(), working_dir: dir.path().to_owned(), windows_breakaway: runtime.windows_breakaway, + foreground: false, capability, outputs, log, @@ -507,6 +519,42 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + /// Retains only a foreground caller's child for status and control attachment. + #[cfg(feature = "cli")] + child: ForegroundChild, +} + +/// Reaps a foreground child on release without adding `Drop` to the public launch value. +#[cfg(feature = "cli")] +#[derive(Debug)] +struct ForegroundChild(Option); + +#[cfg(feature = "cli")] +impl Drop for ForegroundChild { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.detach(); + } + } +} + +#[cfg(feature = "cli")] +impl Launched { + /// Polls the owned child independently of its optional outcome report. + pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { + Ok(self + .child_mut()? + .wait_timeout(timeout)? + .map(|status| status as i32)) + } + + /// Gives the CLI connector the same owned child whose exit it observes. + pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { + self.child + .0 + .as_mut() + .ok_or_else(|| io::Error::other("the VM has no child owned by this caller")) + } } /// Starts a prepared cold launch and returns its verified, detached process. @@ -612,6 +660,7 @@ fn spawn_verified( program, working_dir, windows_breakaway, + foreground, capability, outputs, log, @@ -625,7 +674,7 @@ fn spawn_verified( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = spawn::spawn_vm(&command, capability.as_bytes(), log) + let child = spawn::spawn_vm(&command, capability.as_bytes(), log, foreground) .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; let process = match verify(&child) { Ok(process) => process, @@ -640,11 +689,21 @@ fn spawn_verified( return Err(Error::io(message).with_source(error)); } }; + #[cfg(feature = "cli")] + let child = if foreground { + Some(child) + } else { + child.detach(); + None + }; + #[cfg(not(feature = "cli"))] child.detach(); Ok(Launched { process, outputs, capability, + #[cfg(feature = "cli")] + child: ForegroundChild(child), }) } diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 97b4b5ca6..56b719cee 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -430,6 +430,7 @@ fn cold_config() -> VmConfig { filesystem: None, console: ConsoleAttachment::Listener, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), capture: None, }), @@ -1085,10 +1086,51 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), capability: Capability([0; CAPABILITY_LEN]), + #[cfg(feature = "cli")] + child: ForegroundChild(None), }); assert!(!launched.0.process.has_exited().unwrap()); } +#[cfg(all(feature = "cli", any(target_os = "linux", windows)))] +#[test] +fn an_owned_child_status_is_kept_when_its_outcome_is_missing_or_malformed() { + for malformed in [false, true] { + let (_directory, dir) = vm_dir(); + let mut prepared = stand_in(&dir).for_foreground(); + #[cfg(target_os = "linux")] + { + prepared.program = PathBuf::from("/bin/sh"); + prepared.arguments = ["-c", "exit 37"].map(OsString::from).to_vec(); + } + #[cfg(windows)] + { + prepared.program = PathBuf::from(std::env::var_os("ComSpec").unwrap()); + prepared.arguments = ["/d", "/c", "exit 37"].map(OsString::from).to_vec(); + } + let mut launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!( + launched.0.wait_status(Duration::from_secs(10)).unwrap(), + Some(37) + ); + if malformed { + fs::write(&launched.0.outputs.outcome, b"not-json").unwrap(); + } + let now = Instant::now(); + let stopped = stop( + &launched.0.process, + &launched.0.outputs, + |_| false, + now, + now, + ) + .unwrap(); + assert!(stopped.self_exited); + assert_eq!(stopped.report.is_err(), malformed); + assert_eq!(launched.0.wait_status(Duration::ZERO).unwrap(), Some(37)); + } +} + #[cfg(any(target_os = "linux", windows))] #[test] #[ignore = "invoked in a separate caller by the caller-survival test"] @@ -1230,6 +1272,7 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let vm = VmDir::open(&path).unwrap(); let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index c6645486f..2feb2c3ff 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -62,7 +62,10 @@ pub const APP_MAPS: u8 = 6; pub(crate) const APP_CAPTURE: u8 = 7; /// Request that nvx-init activate a restored VM with its record and workload identity. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect( dead_code, reason = "SL4.3 sends activation requests to nvx-init in SL7.7b" @@ -204,7 +207,10 @@ pub(crate) fn encode_activate_payload( /// Decodes the persistent workload identity and the activation record that follows it. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7b makes nvx-init decode activation requests") )] pub(crate) fn decode_activate_payload( diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 907ff555c..c66dfd348 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -61,16 +61,21 @@ pub mod view; all(feature = "guest", target_os = "linux", target_arch = "x86_64") ))] pub(crate) use app::APP_CAPTURE; +#[cfg(feature = "host")] +pub(crate) use app::encode_capture_payload; +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::workload; #[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] -pub(crate) use app::decode_capture_payload; +pub(crate) use app::{APP_ACTIVATE, decode_activate_payload, decode_capture_payload}; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, BUSY, CWD_FAILED, ExitCategory, INVALID_REQUEST, LAUNCH_FAILED, MAPPING_FAILED, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; -#[cfg(feature = "host")] -pub(crate) use app::{encode_capture_payload, workload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index c92651949..90ac917a3 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -50,6 +50,8 @@ //! `fake_boot_delay_ms=MS` delays the control endpoint, `fake_crash_after_ms=MS` makes the VM //! die, `fake_ignore_stop=1` ignores stop requests, `fake_exit_delay_ms=MS` delays the exit after //! an acknowledged stop, `fake_ignore_cancel=1` ignores cancellation, +//! `fake_console_bytes=N` emits up to 16 MiB of `x` bytes on a run's console, and +//! `fake_report_before_console=1` writes a run's report before console output or its boot delay. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -83,7 +85,9 @@ //! or with `STATUS` if the kernel command line has `fake_exit_status=STATUS`. //! //! The fake also runs the microVMs of `nvx-rs run`, whose command lines have -//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal. Such a command +//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal or a boot-console +//! listener. A cold library launch also supplies the authenticated control endpoint and +//! `--guest-shutdown-action exit`, without selecting a lifecycle through an OpenVMM flag. Such a command //! line takes `--processors` and at most one `--cpu-profile`, shares through one `--mount` or the //! children of an aggregate at `/run/nvx/shares`, whose relative policy paths name paths in the //! only share, as OpenVMM resolves them, and either boots a guest, with `--memory`, an optional @@ -353,6 +357,8 @@ struct Options { restore: Option, /// The `--virtio-console` of a run's restore, which the restore checks against its snapshot. restore_console: Option, + /// The listener attachment of a cold library launch's boot console. + boot_console: Option, /// Control endpoint at which the fake accepts host sessions; empty for a one-shot run. endpoint: String, /// Path to the synthetic OpenVMM outcome report, which a one-shot run may omit. @@ -377,6 +383,12 @@ struct Options { exit_status: Option, /// Artificial delay before the fake guest becomes ready. boot_delay: Duration, + /// Whether the boot console emits the binary-output regression fixture. + binary_console: bool, + /// Number of bytes in the sustained-console regression fixture. + console_bytes: usize, + /// Whether a run publishes its regression report before console output or boot delay. + report_before_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -494,23 +506,59 @@ pub fn main() -> ExitCode { return ExitCode::from(2); } }; + let launch_capability = if options.boot_console.is_some() { + match read_capability() { + Ok(capability) => Some(capability), + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + } + } else { + None + }; + let mut console = match boot_console(&options) { + Ok(console) => console, + Err(error) => { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } + }; + if options.report_before_console + && matches!(options.lifecycle, Lifecycle::OneShot | Lifecycle::Run) + { + let status = options.exit_status.unwrap_or(0); + write_report( + &options, + "run", + if status == 0 { "success" } else { "guest-exit" }, + status, + ); + } let ended = match options.lifecycle { - Lifecycle::OneShot => Some(run_one_shot(&options)), - Lifecycle::Run => Some(run_microvm(&options)), + Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), + Lifecycle::Run => Some(run_microvm(&options, &mut console)), Lifecycle::Capture => Some(run_capture(&options)), Lifecycle::Managed => None, }; if let Some(status) = ended { + if let Err(error) = console.flush() { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } let _ = io::stdout().flush(); let _ = io::stderr().flush(); return exit_code(status); } - let capability = match read_capability() { - Ok(capability) => capability, - Err(message) => { - eprintln!("{program}: {message}"); - return ExitCode::from(2); - } + let capability = match launch_capability { + Some(capability) => capability, + None => match read_capability() { + Ok(capability) => capability, + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + }, }; thread::sleep(options.boot_delay); if let Some(after) = options.crash_after { @@ -720,7 +768,7 @@ fn writers_of(_reader: &fs::File) -> Option { /// The entrypoint is `/bin/echo`, which prints its arguments, `/bin/sleep`, which sleeps for its /// argument in seconds, `/bin/true` or `/bin/false`, or any other program, which is not found; /// the `fake_exit_status=STATUS` token replaces the status. -fn run_one_shot(options: &Options) -> i32 { +fn run_one_shot(options: &Options, console: &mut dyn Write) -> i32 { let (program, arguments) = options .workload .split_first() @@ -729,7 +777,10 @@ fn run_one_shot(options: &Options) -> i32 { }); let status = match program { "/bin/echo" => { - println!("{}", arguments.join(" ")); + if let Err(error) = writeln!(console, "{}", arguments.join(" ")) { + eprintln!("{}: {error}", options.program); + return 1; + } 0 } "/bin/sleep" => { @@ -767,9 +818,9 @@ fn run_one_shot(options: &Options) -> i32 { /// of the `fake_exit_status=STATUS` token. A restore without its snapshot directory fails with 1, /// as OpenVMM fails a restore that it cannot read, and so does one whose console /// [`check_restored_console`] refuses; neither writes a report. -fn run_microvm(options: &Options) -> i32 { +fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { thread::sleep(options.boot_delay); - match &options.restore { + let message = match &options.restore { Some(snapshot) if !snapshot.is_dir() => { eprintln!( "{}: cannot restore {}: no such snapshot directory", @@ -785,9 +836,25 @@ fn run_microvm(options: &Options) -> i32 { eprintln!("{}: {message}", options.program); return 1; } - println!("{}: restored the microVM", options.program); + format!("{}: restored the microVM", options.program) } - None => println!("{}: booted the microVM", options.program), + None => format!("{}: booted the microVM", options.program), + }; + let written = if options.binary_console { + console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else if options.console_bytes != 0 { + let buffer = [b'x'; 8192]; + (0..options.console_bytes) + .step_by(buffer.len()) + .try_for_each(|offset| { + console.write_all(&buffer[..buffer.len().min(options.console_bytes - offset)]) + }) + } else { + writeln!(console, "{message}") + }; + if let Err(error) = written { + eprintln!("{}: {error}", options.program); + return 1; } let status = options.exit_status.unwrap_or(0); let (category, status_code) = if status == 0 { @@ -1203,7 +1270,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--host-loopback-forward", ]; // The options that only the microVMs of `nvx-rs run` and of captures take. - const RUN_VALUED: [&str; 7] = [ + const RUN_VALUED: [&str; 8] = [ "--processors", "--cpu-profile", "--memory-capacity", @@ -1211,6 +1278,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--restore-processors", "--restore-memory", "--restore-ready-path", + "--guest-shutdown-action", ]; // The options that only the VMs of templates' captures take. const CAPTURE_VALUED: [&str; 4] = [ @@ -1297,9 +1365,24 @@ fn parse(program: &str, arguments: &[String]) -> Result { if !flags.contains("--single-process") { return Err("--single-process is required".to_owned()); } - let (endpoint, report) = match lifecycle { - Lifecycle::Managed | Lifecycle::Capture => { - expect("--virtio-console", "none")?; + let boot_console = if run && values.contains_key("--guest-shutdown-action") { + if values.contains_key("--restore-snapshot") { + return Err("--guest-shutdown-action is only for a cold boot".to_owned()); + } + expect("--guest-shutdown-action", "exit")?; + let endpoint = single("--virtio-console")? + .strip_prefix("listen=") + .filter(|endpoint| !endpoint.is_empty()) + .ok_or("--virtio-console must name a listen= endpoint")?; + Some(endpoint.to_owned()) + } else { + None + }; + let (endpoint, report) = + if matches!(lifecycle, Lifecycle::Managed | Lifecycle::Capture) || boot_console.is_some() { + if boot_console.is_none() { + expect("--virtio-console", "none")?; + } if !flags.contains("--microvm-control-auth-stdin") { return Err("--microvm-control-auth-stdin is required".to_owned()); } @@ -1313,8 +1396,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { ); } (endpoint, Some(PathBuf::from(single("--microvm-report")?))) - } - Lifecycle::OneShot | Lifecycle::Run => { + } else { let kind = if run { "a microVM run" } else { @@ -1346,8 +1428,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { Some(_) => return Err("--microvm-report must be given at most once".to_owned()), }; (String::new(), report) - } - }; + }; let hypervisor = single("--hypervisor")?; if !["kvm", "mshv", "whp"].contains(&hypervisor) { return Err(format!("unsupported hypervisor {hypervisor}")); @@ -1508,7 +1589,12 @@ fn parse(program: &str, arguments: &[String]) -> Result { // CLI's tokens may name a one-shot workload and place the children of an aggregate. The // guest of a workload of either mode has the targets of its directory shares, as a // one-shot sandbox's has: a direct share's, and those that the tokens place. - let (children, mut share_targets) = sandbox_shares(&values)?; + let (children, mut share_targets) = if boot_console.is_some() { + let maps = mapping_count(&token_values(MAPS_TOKEN))?; + (mapped_children(&values, maps)?, Vec::new()) + } else { + sandbox_shares(&values)? + }; match lifecycle { Lifecycle::Run => share_targets.clear(), _ => share_targets.extend(bound_targets(&token_values("nvx_share="))?), @@ -1649,6 +1735,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { run, restore, restore_console, + boot_console, endpoint, args_dump: anchor.with_file_name(format!("fake-openvmm-{token}.json")), maps_dump: anchor.with_file_name(format!("fake-openvmm-{token}-maps.json")), @@ -1661,6 +1748,18 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_on_start: knob("fake_exit_on_start").and_then(|value| value.parse().ok()), exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), + binary_console: knob("fake_binary_console") == Some("1"), + console_bytes: match knob("fake_console_bytes") { + None => 0, + Some(value) => value + .parse::() + .ok() + .filter(|&count| count <= 16 * 1024 * 1024) + .ok_or_else(|| { + "fake_console_bytes must be an integer from 0 through 16777216".to_owned() + })?, + }, + report_before_console: knob("fake_report_before_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2850,6 +2949,46 @@ fn record_session(options: &Options) { } } +fn boot_console(options: &Options) -> io::Result> { + let Some(endpoint) = &options.boot_console else { + return Ok(Box::new(io::stdout())); + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let listener = UnixListener::bind(endpoint)?; + fs::set_permissions(endpoint, fs::Permissions::from_mode(0o600))?; + Ok(Box::new(listener.accept()?.0)) + } + #[cfg(windows)] + { + let pipe = pipe_listener(endpoint)?; + accept_pipe(&pipe)?; + Ok(Box::new(PipeConsole(pipe))) + } + #[cfg(not(any(unix, windows)))] + Err(io::Error::new( + io::ErrorKind::Unsupported, + "no boot-console listener on this host", + )) +} + +#[cfg(windows)] +struct PipeConsole(fs::File); + +#[cfg(windows)] +impl Write for PipeConsole { + fn write(&mut self, data: &[u8]) -> io::Result { + self.0.write(data) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.sync_all() + } +} + #[cfg(unix)] /// Serves the fake control endpoint as a Unix-domain socket until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { @@ -2873,24 +3012,20 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> } #[cfg(windows)] -/// Serves the fake control endpoint as a Windows named pipe until a stop request. -fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { +fn pipe_listener(endpoint: &str) -> io::Result { use std::fs::File; use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; - use windows_sys::Win32::Foundation::{ - ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, INVALID_HANDLE_VALUE, - }; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX, }; use windows_sys::Win32::System::Pipes::{ - ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, - PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, + CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, }; - let name: Vec = std::ffi::OsStr::new(&options.endpoint.replace('/', "\\")) + let name: Vec = std::ffi::OsStr::new(&endpoint.replace('/', "\\")) .encode_wide() .chain([0]) .collect(); @@ -2913,16 +3048,26 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> return Err(io::Error::last_os_error()); } // SAFETY: CreateNamedPipeW returned a fresh handle that nothing else owns. - let mut file = File::from(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }); + Ok(File::from(unsafe { + OwnedHandle::from_raw_handle(handle as RawHandle) + })) +} + +#[cfg(windows)] +fn accept_pipe(file: &fs::File) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ + ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, + }; + use windows_sys::Win32::System::Pipes::{ConnectNamedPipe, DisconnectNamedPipe}; + let pipe = file.as_raw_handle() as HANDLE; - let mut guest = Guest::new(options); - let mut epoch = 0; loop { // SAFETY: the handle is a pipe server instance; a null OVERLAPPED waits synchronously. if unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) } == 0 { // SAFETY: GetLastError has no preconditions. match unsafe { GetLastError() } { - ERROR_PIPE_CONNECTED => {} + ERROR_PIPE_CONNECTED => return Ok(()), // The client left before the connection was accepted. ERROR_NO_DATA => { // SAFETY: the handle is a pipe server instance. @@ -2932,6 +3077,23 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> error => return Err(io::Error::from_raw_os_error(error as i32)), } } + return Ok(()); + } +} + +#[cfg(windows)] +/// Serves the fake control endpoint as a Windows named pipe until a stop request. +fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + let mut file = pipe_listener(&options.endpoint)?; + let pipe = file.as_raw_handle() as HANDLE; + let mut guest = Guest::new(options); + let mut epoch = 0; + loop { + accept_pipe(&file)?; epoch += 1; record_session(options); let flow = serve_client(&mut file, options, capability, epoch, &mut guest); diff --git a/nvx-rs/src/testing/fake_openvmm/tests.rs b/nvx-rs/src/testing/fake_openvmm/tests.rs index 068ac0d36..39d2ec29f 100644 --- a/nvx-rs/src/testing/fake_openvmm/tests.rs +++ b/nvx-rs/src/testing/fake_openvmm/tests.rs @@ -648,7 +648,7 @@ fn restores_of_runs_resume_their_saved_guest_whatever_the_cli_tokens() { assert_eq!(options.lifecycle, Lifecycle::Run, "{snapshot}"); assert!(options.workload.is_empty(), "{snapshot}"); if snapshot.ends_with("missing") { - assert_eq!(run_microvm(&options), 1); + assert_eq!(run_microvm(&options, &mut io::sink()), 1); } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 0c32142f2..157ae9784 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2541,7 +2541,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { let dry_run = text(&run(&root, &arguments(&["--dry-run"])).stdout); let (openvmm, _) = dry_run .strip_prefix(">> ") - .and_then(|command| command.split_once(" --single-process ")) + .and_then(|command| command.split_once(" --machine ")) .unwrap(); let default = if cfg!(windows) { "whp" } else { "kvm" }; // OpenVMM fails the cold boot, or refuses its command line with the status of a usage error; @@ -2562,13 +2562,6 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { unserved, default, ), - ( - &["--host-loopback-forward", "tcp:8080:80"][..], - 2, - "openvmm: --host-loopback-forward requires explicit --host-loopback allow\n", - unserved, - default, - ), ( &[ "--cpu-profile", @@ -2659,6 +2652,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ), (&["--restore-snapshot", "missing"][..], 1), (&["--net", "10.0.0.2/24"][..], 1), + (&["--host-loopback-forward", "tcp:8080:80"][..], 1), ( &["--mount", "/data", "--cmdline", "fake_exit_status=1"][..], 1, @@ -2669,6 +2663,13 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ] { let output = run(&root, &arguments(options)); assert_eq!(output.status.code(), Some(status), "{options:?}"); + if options.first() == Some(&"--host-loopback-forward") { + assert!(output.stdout.is_empty(), "{options:?}"); + assert!( + text(&output.stderr).starts_with("error[unsupported]:"), + "{options:?}" + ); + } assert!( !text(&output.stderr).contains("nvx: "), "{options:?}: {}", @@ -2705,9 +2706,8 @@ fn run_boots_each_guest_with_its_default_memory() { #[test] fn run_shares_relative_roots_with_absolute_policy_paths() { - // nvx-rs run passes the host path of each share as given, so the children of an aggregate may - // be relative to the working directory, while their policy paths must be absolute: OpenVMM - // attributes each one to the share whose root contains it once it has resolved both. + // The cold path resolves each relative root in its checked export plan, while an aggregate's + // policy paths must be absolute. let root = Root::new(); let path = |parts: &[&str]| { parts @@ -2731,9 +2731,11 @@ fn run_shares_relative_roots_with_absolute_policy_paths() { let output = run(&root, &arguments); assert!(output.status.success(), "{}", text(&output.stderr)); let stdout = text(&output.stdout); - let child = nvx::host::sandbox::mount::aggregate_child_name(0, "/data"); + let canonical = fs::canonicalize(root.path.join("share")).unwrap(); + let child = canonical.to_str().unwrap(); + let child = child.strip_prefix(r"\\?\").unwrap_or(child); assert!( - stdout.contains(&format!(" --mount-child {child},share,rw ")), + stdout.contains(&format!(" --mount-child 0,{child},rw ")), "{stdout}" ); assert!( diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index f8d796528..104482699 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -41,6 +41,8 @@ struct DryRun { arguments: Vec, /// The options of the OpenVMM command line that it prints, with placeholders. options: Options, + /// The specific unsupported configuration this case expects instead of a command. + refusal: Option<&'static str>, } /// Returns the dry run `name` of `arguments`, which prints an OpenVMM command line of `options`, @@ -61,10 +63,16 @@ fn dry_run( .map(|argument| argument.to_string()) .collect(), options, + refusal: None, } } impl DryRun { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, relative: &'static str, content: Content) -> Self { self.files.push((relative, content)); @@ -412,10 +420,13 @@ fn run_dry_runs() -> Vec { let many_children: Vec = (0..20) .map(|index| mount_child(index, &format!("/share/{index:02}"), "{root/share}", "ro")) .collect(); - let long = "x".repeat(1100); - let fits = "x".repeat(841); + let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), + dry_run("run: no install metadata", run(&[]), cold_boot()) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), dry_run( "run: Ubuntu default", run(&["--guest", "ubuntu"]), @@ -721,7 +732,8 @@ fn run_dry_runs() -> Vec { cold_boot() .with("--mount-deny", ["{root/share/hidden}"]) .with("--mount-allow", ["x"]), - ), + ) + .refusing("mount policy options require --mount"), dry_run( "run: several shares with policy paths", run(&[ @@ -778,7 +790,8 @@ fn run_dry_runs() -> Vec { )], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), - ), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), dry_run( "run: one relative shared file", run(&["--mount", "/etc/single.txt,single.txt"]), @@ -789,6 +802,29 @@ fn run_dry_runs() -> Vec { [mount_child(0, "/etc/single.txt", "single.txt", "ro,file")], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), + dry_run( + "run: one shared file outside system paths", + run(&["--mount", "/config/single.txt,{root/single.txt},ro"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "{root/single.txt}", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), + ), + dry_run( + "run: one relative shared file outside system paths", + run(&["--mount", "/config/single.txt,single.txt"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "single.txt", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), ), dry_run( "run: a directory and a file", @@ -860,12 +896,14 @@ fn run_dry_runs() -> Vec { "run: host path ending in a mode", run(&["--mount", "/data,{root/share},rw,ro"]), cold_boot().with("--mount", ["/data,{root/share},rw,ro"]), - ), + ) + .with_file("share,rw/data.txt", Content::Text("data\n")), dry_run( "run: host path ending in a comma", run(&["--mount", "/data,{root/share},,rw"]), cold_boot().with("--mount", ["/data,{root/share},,rw"]), - ), + ) + .with_file("share,/data.txt", Content::Text("data\n")), dry_run( "run: vmm owner", run(&["--mount", "/data,{root/share}", "--mount-owner", "vmm"]), @@ -934,7 +972,7 @@ fn run_dry_runs() -> Vec { ) .with("--cmdline", ["quiet"]), ), - // `nvx-rs run` passes the network options to OpenVMM as they are. + // A proxy is exclusive of direct egress allow rules. dry_run( "run: network", run(&[ @@ -974,12 +1012,69 @@ fn run_dry_runs() -> Vec { .with("--host-loopback", ["allow"]) .with("--network-proxy", ["10.0.0.1:3128"]) .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ) + .refusing("a network proxy takes no --network-egress-allow rules"), + dry_run( + "run: direct network rules and forwards", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "allow", + "--network-egress-allow", + "10.0.0.0/8:tcp:443", + "--network-egress-allow", + "2001:db8::/32", + "--network-egress-deny", + "10.1.0.0/16", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:8080:80", + "--host-loopback-forward", + "udp:5353:53", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]) + .with("--network-ingress", ["allow"]) + .with("--network-egress-allow", ["10.0.0.0/8:tcp:443", "2001:db8::/32"]) + .with("--network-egress-deny", ["10.1.0.0/16"]) + .with("--host-loopback", ["allow"]) + .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ), + dry_run( + "run: proxy-only network", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:3128", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["deny"]) + .with("--host-loopback", ["deny"]) + .with("--network-proxy", ["10.0.0.1:3128"]), ), dry_run( "run: egress default", run(&["--network-egress", "deny"]), cold_boot().with("--network-egress", ["deny"]), - ), + ) + .refusing("network policy options require --net"), // A restore takes the addressing of its NIC from the snapshot, so it passes the profile // and the policy without `--net`. dry_run( @@ -1072,16 +1167,23 @@ fn run_dry_runs() -> Vec { "run: host loopback forward", run(&["--host-loopback-forward", "tcp:8080:80"]), cold_boot().with("--host-loopback-forward", ["tcp:8080:80"]), - ), + ) + .refusing("network policy options require --net"), dry_run( "run: egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "deny", "--network-egress-policy-file", "{root/policy.json}", ]), cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) .with("--network-egress", ["deny"]) .with("--network-egress-allow", COMPILED_ALLOW) .with("--network-egress-deny", COMPILED_DENY), @@ -1090,12 +1192,19 @@ fn run_dry_runs() -> Vec { dry_run( "run: empty egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "allow", "--network-egress-policy-file", "{root/./policy.json}", ]), - cold_boot().with("--network-egress", ["allow"]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]), ) .with_file("policy.json", Content::Text("{}")), dry_run( @@ -1107,6 +1216,27 @@ fn run_dry_runs() -> Vec { "run: quoted command line", run(&["--cmdline", "a=\"b c\" d"]), cold_boot().with("--cmdline", ["a=\"b c\" d"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: standalone command line delimiter", + run(&["--cmdline", "quiet -- debug"]), + cold_boot().with("--cmdline", ["quiet -- debug"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: restore retains quoted command line and delimiter", + run(&[ + "--restore-snapshot", + "{root/snapshot}", + "--cmdline", + "a=\"b c\" -- d", + ]), + restore().with("--cmdline", ["a=\"b c\" -- d"]), ), dry_run( "run: command line starting with a dash", @@ -1133,14 +1263,6 @@ fn run_dry_runs() -> Vec { run(&["--cmdline", "a", "--cmdline", "b"]), cold_boot().with("--cmdline", ["b"]), ), - // One directory takes no boot token, so no budget limits the command line. - dry_run( - "run: long command line of one directory share", - run(&["--mount", "/data,{root/share}", "--cmdline", &long]), - cold_boot() - .with("--mount", ["/data,{root/share},ro"]) - .with("--cmdline", [long.as_str()]), - ), dry_run( "run: restore without the budget", run(&many), @@ -1202,8 +1324,35 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { } let before = root.snapshot(); let arguments: Vec<&str> = case.arguments.iter().map(String::as_str).collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); let outcome = root.nvx_rs(&arguments); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before, "{}", case.name); + return; + } + let expected = if cold { + case.options + .cold_run_expected(&root, "one-shot") + .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", case.name)) + } else { + case.options.expanded(&root) + }; outcome.assert_success(); let (command, rest) = outcome.command(); assert!(rest.is_empty(), "{}: {}", case.name, outcome.describe()); @@ -1214,8 +1363,13 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { "{}", case.name ); - case.options - .assert_matches(&root, &Options::parse(arguments), case.name); + let actual = Options::parse(arguments); + let actual = if cold { + actual.normalized_cold_outputs(true) + } else { + actual + }; + expected.assert_matches(&root, &actual, case.name); // The run launched no OpenVMM, which records its command line once it has accepted it. assert_eq!(root.snapshot(), before, "{}", case.name); }); @@ -1238,8 +1392,7 @@ fn command_lines() -> impl Strategy { #[test] fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { - // `nvx-rs run` passes its kernel command line to OpenVMM as it is, so the `>> ` line, split as - // this host splits command lines, must give it back whatever characters it holds: with a + // Accepted caller text survives the `>> ` line: with a // shell-word splitter on POSIX, and with `CommandLineToArgvW` on Windows. let root = Root::new(); let openvmm = root.below(OPENVMM).to_str().unwrap().to_owned(); @@ -1257,16 +1410,27 @@ fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { ]; let output = root.command(&arguments).output().unwrap(); let outcome = Outcome::new(arguments, output); + if command_line.contains('"') + || command_line.split_ascii_whitespace().any(|token| token == "--") + { + prop_assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + prop_assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + prop_assert_eq!( + outcome.stderr(), + format!("error[unsupported]: boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console{}", crate::harness::LINE_END) + ); + return Ok(()); + } + let expected = cold_boot() + .with("--cmdline", [command_line.clone()]) + .cold_run_expected(&root, "one-shot") + .unwrap(); outcome.assert_success(); let (command, rest) = outcome.command(); prop_assert!(rest.is_empty(), "{}", outcome.describe()); prop_assert_eq!(&command[0], &openvmm); - let mut expected = cold_boot().expanded(&root); - if !command_line.is_empty() { - expected = expected.with("--cmdline", [command_line.clone()]); - } prop_assert_eq!( - Options::parse(&command[1..]), + Options::parse(&command[1..]).normalized_cold_outputs(true), expected, "{}", outcome.describe() diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index cef3c22a5..3de2633d0 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -49,6 +49,8 @@ struct Launch { stderr: &'static str, /// The outcome report that OpenVMM writes, if the run asks for one. report: Option, + /// A specific checked-configuration refusal, with no OpenVMM launch. + refusal: Option<&'static str>, } /// Returns the launch `name` of `arguments`, which starts OpenVMM with `options` and exits with @@ -75,10 +77,17 @@ fn launch( stdout, stderr: "", report: None, + refusal: None, } } impl Launch { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self.status = 1; + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, (relative, content): (&'static str, Content)) -> Self { self.files.push((relative, content)); @@ -642,7 +651,16 @@ fn run_launches() -> Vec { "", ) .with_stderr("openvmm: --host-loopback-forward requires explicit --host-loopback allow\n") - .recorded_in(None), + .recorded_in(None) + .refusing("network policy options require --net"), + launch( + "run: OpenVMM exits with a usage status", + run(&["--cmdline", "fake_exit_on_start=2"]), + cold_boot().with("--cmdline", ["fake_exit_on_start=2"]), + 2, + "", + ) + .with_stderr("openvmm: failing the launch as requested\n"), // A relative NVX_OPENVMM is relative to the working directory, and the command names it // by its absolute path. launch( @@ -652,7 +670,10 @@ fn run_launches() -> Vec { 0, booted, ) - .with_variable("NVX_OPENVMM", native(OPENVMM)), + .with_variable("NVX_OPENVMM", native(OPENVMM)) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), ] } @@ -678,8 +699,30 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { .iter() .map(|(name, value)| (*name, value.as_str())) .collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); + let before = case.refusal.map(|_| root.snapshot()); let outcome = root.nvx_rs_with(&arguments, &environment); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(Some(root.snapshot()), before); + return; + } + let expected_cold = cold.then(|| case.options.cold_run_expected(&root, "one-shot")); assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); let (command, rest) = outcome.command(); // The workload's output follows the command byte for byte, as does OpenVMM's. @@ -705,11 +748,28 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { case.name ); // The `>> ` line splits back into the arguments that OpenVMM received. - if let Some(record) = case.record { - assert_eq!(root.openvmm_arguments(record), arguments, "{}", case.name); - } let mut options = Options::parse(arguments); - let mut expected = case.options.clone(); + let record = case.record.map(|record| { + if cold { + let report = std::path::Path::new(options.value("--microvm-report")); + let token = report + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap(); + format!("build/fake-openvmm-{token}.json") + } else { + record.to_owned() + } + }); + if let Some(record) = record { + assert_eq!(root.openvmm_arguments(&record), arguments, "{}", case.name); + } + let mut expected = expected_cold + .map(|expected| expected.unwrap()) + .unwrap_or_else(|| case.options.clone()); // A sandbox's OpenVMM opens the images through the files that the run checked: the distro // layer alone for an upper in RAM, and the scratch image too for one on scratch. let blocks = expected.take("--microvm-sandbox-block"); @@ -728,6 +788,9 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { assert_opened_images(&root, token); } } + if cold { + options = options.normalized_cold_outputs(true); + } expected.assert_matches(&root, &options, case.name); if let Some(report) = &case.report { assert_outcome_report( @@ -740,6 +803,471 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { }); } +#[test] +fn both_cold_modes_keep_their_foreground_output_and_owned_exit_status() { + for (mode, caller, status, stdout) in [ + ( + "one-shot", + "fake_exit_status=7", + 7, + "openvmm: booted the microVM\n", + ), + ("managed", "fake_crash_after_ms=100", 3, ""), + ] { + let root = Root::new(); + let identity = if mode == "managed" { + " nvx_identity=1001:1001" + } else { + "" + }; + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + &format!("{caller} nvx_mode={mode}{identity}"), + ]); + assert_eq!(outcome.status, Some(status), "{}", outcome.describe()); + let (command, output) = outcome.command(); + assert_eq!(output, stdout.as_bytes(), "{}", outcome.describe()); + assert_eq!(command[0], root.below(OPENVMM).to_str().unwrap()); + let mut expected = cold_boot() + .with( + "--cmdline", + [format!( + "{caller}{}", + if identity.is_empty() { " " } else { " " } + )], + ) + .cold_run_expected(&root, mode) + .unwrap(); + if !identity.is_empty() { + let line = format!("{}{identity}", expected.value("--cmdline")); + expected = expected.with("--cmdline", [line]); + } + let actual = Options::parse(&command[1..]).normalized_cold_outputs(true); + expected.assert_matches(&root, &actual, mode); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_terminal_runs_forward_raw_output_and_restore_the_callers_terminal() { + use std::fs::File; + use std::io::{self, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, OutputFlags, tcgetattr}; + + for status in [0, 7] { + let root = Root::new(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("fake_boot_delay_ms=250 fake_exit_status={status}"), + ]) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut observed_raw = false; + while process.try_wait().unwrap().is_none() { + let current = tcgetattr(&terminal.slave).unwrap(); + observed_raw |= !current + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + && !current.output_flags.contains(OutputFlags::OPOST); + if Instant::now() >= deadline { + process.kill().unwrap(); + process.wait().unwrap(); + panic!("terminal run did not exit within its deadline"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let outcome = process.wait_with_output().unwrap(); + assert_eq!( + outcome.status.code(), + Some(status), + "{}", + String::from_utf8_lossy(&outcome.stderr) + ); + assert!(observed_raw, "cold run left its terminal cooked"); + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + fcntl(&terminal.master, FcntlArg::F_SETFL(OFlag::O_NONBLOCK)).unwrap(); + let mut master = File::from(terminal.master); + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + match master.read(&mut buffer) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("cannot read terminal output: {error}"), + } + } + assert!( + output.ends_with(b"openvmm: booted the microVM\n"), + "{output:?}" + ); + } +} + +#[test] +fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { + let root = Root::new(); + root.write("reports/existing.json", "keep\n"); + for (path, message) in [ + ( + "{root/reports/existing.json}", + "error[outcome-report-exists]:", + ), + ( + "{root/missing/outcome.json}", + "outcome report parent is not a plain directory", + ), + ] { + let before = root.snapshot(); + let outcome = root.nvx_rs(&["run", "--outcome-report", path]); + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before); + } +} + +#[test] +fn missing_cold_reports_do_not_replace_the_owned_child_status() { + for line in [ + "fake_exit_on_start=3", + "fake_crash_after_ms=100 nvx_mode=managed nvx_identity=1001:1001", + ] { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + line, + "--outcome-report", + "{root/reports/outcome.json}", + ]); + assert_eq!(outcome.status, Some(3), "{}", outcome.describe()); + assert!( + outcome.stderr().contains("outcome.json"), + "{}", + outcome.describe() + ); + assert!(!root.below("reports/outcome.json").exists()); + } +} + +#[test] +fn cold_report_publication_never_clobbers_a_destination_created_during_boot() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + for symlink in [false, true] + .into_iter() + .filter(|symlink| !symlink || cfg!(unix)) + { + let root = Root::new(); + let destination = root.below("reports/outcome.json"); + root.write("reports/victim.txt", "keep\n"); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=250 fake_exit_status=7", + "--outcome-report", + destination.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + if symlink { + #[cfg(unix)] + std::os::unix::fs::symlink(root.below("reports/victim.txt"), &destination).unwrap(); + } else { + root.write("reports/outcome.json", "keep\n"); + } + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("outcome report already exists"), + "the publication failure must be reported" + ); + assert_eq!(std::fs::read(&destination).unwrap(), b"keep\n"); + assert_eq!( + std::fs::read(root.below("reports/victim.txt")).unwrap(), + b"keep\n" + ); + if symlink { + assert!( + std::fs::symlink_metadata(&destination) + .unwrap() + .is_symlink() + ); + } + } +} + +#[test] +fn cold_console_backpressure_does_not_block_output_or_replace_the_child_status() { + use std::io::{self, Write}; + use std::process::Stdio; + + let root = Root::new(); + let arguments = [ + "run", + "--cmdline", + "fake_boot_delay_ms=2500 fake_exit_status=7", + ]; + let mut process = root + .command(&arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = process.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(&vec![b'x'; 16 * 1024 * 1024])); + let output = process.wait_with_output().unwrap(); + if let Err(error) = writer.join().unwrap() { + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe, "{error}"); + } + let outcome = crate::harness::Outcome::new( + arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + output, + ); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + b"openvmm: booted the microVM\n", + "{}", + outcome.describe() + ); +} + +#[test] +fn cold_console_output_preserves_non_utf8_bytes() { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_binary_console=1 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + &[b'A', 0xff, 0x80, b'\n'], + "{}", + outcome.describe() + ); +} + +#[test] +fn a_ready_console_stream_is_not_throttled_by_idle_sleeps() { + use std::time::{Duration, Instant}; + + let root = Root::new(); + let started = Instant::now(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_console_bytes=8388608 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "the bulk-console run failed"); + let output = outcome.command().1; + assert_eq!(output.len(), 8 * 1024 * 1024); + assert!(output.iter().all(|&byte| byte == b'x')); + assert!( + started.elapsed() < Duration::from_secs(8), + "the ready console stream was throttled: {:?}", + started.elapsed() + ); +} + +#[test] +fn a_foreground_output_error_keeps_the_requested_native_report() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + let root = Root::new(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=100 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + drop(stdout); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("error[io]:"), + "{output:?}" + ); + assert_outcome_report( + &root.json("reports/outcome.json"), + crate::harness::HYPERVISOR, + ("run", "success", 0), + NO_NETWORK, + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_interrupted_cold_run_keeps_its_already_written_native_report() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + let root = Root::new(); + let temporary = tempfile::Builder::new().prefix("nvx-c-").tempdir().unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=60000 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .env("TMPDIR", temporary.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let original = loop { + let report = std::fs::read_dir(temporary.path()) + .unwrap() + .map(|entry| entry.unwrap().path().join("outcome.json")) + .find(|path| path.is_file()); + if let Some(report) = report { + let bytes = std::fs::read(report).unwrap(); + if bytes.ends_with(b"\n") && serde_json::from_slice::(&bytes).is_ok() + { + break bytes; + } + } + if process.try_wait().unwrap().is_some() { + panic!( + "the cold run exited before writing its report: {:?}", + process.wait_with_output() + ); + } + if Instant::now() >= deadline { + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + panic!("the fake did not write its early report: {output:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + }; + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + assert_eq!( + std::fs::read(root.below("reports/outcome.json")).unwrap(), + original + ); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::unistd::{Pid, getpgid, getsid}; + + let root = Root::new(); + let process = root + .command(&["run", "--cmdline", "fake_boot_delay_ms=500"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let caller = Pid::from_raw(process.id() as i32); + let deadline = Instant::now() + Duration::from_secs(10); + let child = loop { + let children = std::fs::read_to_string(format!( + "/proc/{}/task/{}/children", + caller.as_raw(), + caller.as_raw() + )) + .unwrap(); + let native = children.split_ascii_whitespace().find_map(|pid| { + let arguments = match std::fs::read(format!("/proc/{pid}/cmdline")) { + Ok(arguments) => arguments, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("cannot inspect the owned child {pid}: {error}"), + }; + arguments + .split(|byte| *byte == 0) + .any(|argument| argument == b"--microvm-control-console") + .then(|| Pid::from_raw(pid.parse().unwrap())) + }); + if let Some(child) = native { + break child; + } + assert!( + Instant::now() < deadline, + "the owned native child did not appear" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + let caller_session = getsid(Some(caller)).unwrap(); + let caller_group = getpgid(Some(caller)).unwrap(); + let child_session = getsid(Some(child)).unwrap(); + let child_group = getpgid(Some(child)).unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!(child_session, caller_session); + assert_eq!(child_group, caller_group); +} + /// A run leaves an outcome report that the caller can read whatever its umask, even one that /// clears the owner's own permissions, and whose group's and others' permissions the caller's /// umask narrows: OpenVMM starts with the caller's umask without the owner's bits. diff --git a/nvx-rs/tests/cli/openvmm.rs b/nvx-rs/tests/cli/openvmm.rs index b25d02c20..48360f4cf 100644 --- a/nvx-rs/tests/cli/openvmm.rs +++ b/nvx-rs/tests/cli/openvmm.rs @@ -6,11 +6,27 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; use serde_json::{Value, json}; -use crate::harness::{Content, HYPERVISOR, Root, is_hex}; +use crate::harness::{Content, HYPERVISOR, OPENVMM, Root, is_hex}; + +/// Caller bytes left by the two-directory fixture, its native bootstrap, and a terminating NUL. +pub(crate) fn two_share_command_line_room() -> usize { + use nvx::host::filesystem::GUEST_EXPORT; + use nvx::host::sandbox::mount::{ + AGGREGATE_MOUNT_TARGET, MountAccess, aggregate_command_line_fragment, + }; + + let kernel = "nvx_hostname=6e7678 nvx_machine_id=image nvx_maps=2 nvx_mode=one-shot \ + nvx_share=0,/a,ro nvx_share=1,/b,rw"; + let fragment = + aggregate_command_line_fragment(&[MountAccess::ReadOnly, MountAccess::ReadWrite]) + .replace(AGGREGATE_MOUNT_TARGET, GUEST_EXPORT); + nvx::host::config::COMMAND_LINE_BUDGET - kernel.len() - fragment.len() - 2 +} /// The options of OpenVMM that take no value. const FLAGS: [&str; 3] = [ @@ -169,6 +185,338 @@ impl Options { } panic!("{context}: the OpenVMM options differ:\n{differences}"); } + + /// Projects an old cold-run fixture onto the checked public VM contract, independently of + /// the CLI parser and its private token renderer. Restore and sandbox fixtures stay old. + pub(crate) fn cold_run_expected(&self, root: &Root, mode: &str) -> nvx::Result { + use nvx::activation::{MachineIdMode, View}; + use nvx::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; + use nvx::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, + }; + use nvx::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, + NetworkProxy, + }; + use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; + use nvx::host::sandbox::mount::{MountAccess, parse_mount_value}; + use nvx::host::vm::{self, VmDir}; + + use nvx::host::network::{ForwardProtocol, Protocol}; + + let old = self.expanded(root); + let mut caller = old.values("--cmdline").first().cloned().unwrap_or_default(); + let mut shares = Vec::new(); + let mut files = BTreeSet::new(); + for value in old.values("--mount") { + let (target, host, access) = parse_mount_value(value)?; + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if host.is_file() { + files.insert(target.clone()); + } + shares.push((target, host, access)); + } + let tokens: Vec<_> = caller.split_ascii_whitespace().map(str::to_owned).collect(); + let mut generated = Vec::new(); + for value in old.values("--mount-child") { + let child = value.strip_suffix(",file").unwrap_or(value); + let (name, host, access) = parse_mount_value(child)?; + let token = tokens + .iter() + .find(|token| { + token + .strip_prefix("nvx_share=") + .is_some_and(|share| share.starts_with(&format!("{name},"))) + }) + .expect("the aggregate fixture names every child's target"); + let parts: Vec<_> = token + .strip_prefix("nvx_share=") + .unwrap() + .split(',') + .collect(); + let target = parts[1].to_owned(); + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if value.ends_with(",file") { + files.insert(target.clone()); + } + shares.push((target, host, access)); + generated.push(token.clone()); + } + for token in generated.iter().rev() { + caller = if caller == *token { + String::new() + } else { + caller + .strip_suffix(&format!(" {token}")) + .expect("fixture boot shares follow caller text") + .to_owned() + }; + } + let aggregate = shares.len() > 1 || !files.is_empty(); + let mut policies: [Vec; 3] = Default::default(); + for (index, option) in ["--mount-deny", "--mount-allow", "--mount-write"] + .into_iter() + .enumerate() + { + for value in old.values(option) { + let path = PathBuf::from(value); + if shares.is_empty() || (aggregate && !path.is_absolute()) { + return Err(nvx::Error::unsupported( + "boot share policies need a mapped path and absolute aggregate paths", + )); + } + policies[index].push(if path.is_absolute() { + path + } else { + shares[0].1.join(path) + }); + } + } + let mappings: Vec<_> = shares + .iter() + .map(|(target, host, access)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let export = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if old + .values("--mount-owner") + .first() + .is_some_and(|value| value == "caller") + { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + })?; + let mut boot_tokens = vec![format!("nvx_mode={mode}")]; + if let Some(export) = &export { + boot_tokens.extend(export.guest_table().into_iter().map(|row| { + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if files.contains(&row.target) { + ",file" + } else { + "" + }; + format!("nvx_share={},{},{access}{kind}", row.source, row.target) + })); + } + if !caller.is_empty() { + boot_tokens.insert(0, caller); + } + if old.values("--net").is_empty() + && [ + "--network-egress", + "--network-ingress", + "--network-egress-allow", + "--network-egress-deny", + "--host-loopback", + "--host-loopback-forward", + "--network-proxy", + ] + .into_iter() + .any(|option| !old.values(option).is_empty()) + { + return Err(nvx::Error::unsupported( + "network policy options require --net", + )); + } + let network = if let Some(net) = old.values("--net").first() { + let access = |option, default| match old.values(option).first().map(String::as_str) { + Some("allow") => Access::Allow, + Some("deny") => Access::Deny, + _ => default, + }; + let mut policy = NetworkPolicy::new(access("--network-egress", Access::Allow)); + for (option, destination) in [ + ("--network-egress-allow", &mut policy.allow), + ("--network-egress-deny", &mut policy.deny), + ] { + *destination = EgressRules { + name: option.to_owned(), + rules: old + .values(option) + .iter() + .map(|value| { + for (suffix, protocol) in [ + (":tcp", Protocol::Tcp), + (":udp", Protocol::Udp), + (":icmp", Protocol::Icmp), + ] { + if let Some((destination, ports)) = value.split_once(suffix) { + let rule = EgressRule::to(destination); + return if ports.is_empty() { + rule.on_protocol(protocol) + } else { + let ports = ports.strip_prefix(':').unwrap(); + if let Some((first, last)) = ports.split_once('-') { + rule.on_port_range( + protocol, + first.parse().unwrap(), + last.parse().unwrap(), + ) + } else { + rule.on_port(protocol, ports.parse().unwrap()) + } + }; + } + } + EgressRule::to(value) + }) + .collect(), + }; + } + policy.ingress = access("--network-ingress", Access::Deny); + policy.host_loopback = match old.values("--host-loopback").first().map(String::as_str) { + Some("allow") => HostLoopback::Allow, + Some("deny") => HostLoopback::Deny, + _ => HostLoopback::Outbound, + }; + policy.host_loopback_forwards = old + .values("--host-loopback-forward") + .iter() + .map(|value| { + let fields: Vec<_> = value.split(':').collect(); + LoopbackForward { + protocol: match fields[0] { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => panic!("a fixture names tcp or udp"), + }, + host_port: fields[1].parse().unwrap(), + guest_port: fields[2].parse().unwrap(), + } + }) + .collect(); + if let Some(proxy) = old.values("--network-proxy").first() { + let (gateway, port) = proxy.rsplit_once(':').unwrap(); + assert_eq!(gateway, nvx::net::guest_gateway(net).unwrap().to_string()); + policy.proxy = Some(NetworkProxy::new(port.parse().unwrap())); + } + Some(NetworkConfig { + guest_network: Some(nvx::net::Ipv4Cidr::parse(net).unwrap()), + policy, + }) + } else { + None + }; + let config = VmConfig { + processors: old.value("--processors").parse().unwrap(), + memory_mib: old.value("--memory").trim_end_matches('M').parse().unwrap(), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + filesystem: export, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: boot_tokens.join(" "), + memory_capacity_mib: old + .values("--memory-capacity") + .first() + .map(|value| value.trim_end_matches('M').parse().unwrap()), + capture: None, + }), + }; + config.check_command_line()?; + let identity = ArtifactIdentity::new("test-fixture").unwrap(); + let runtime = Runtime { + openvmm: root.below(OPENVMM), + kernel: PathBuf::from(old.value("--kernel")), + initramfs: PathBuf::from(old.value("--initrd")), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: old.value("--hypervisor").parse().unwrap(), + cpu_profile: old.values("--cpu-profile").first().cloned(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("expected-vm"); + vm::create_private_dir(&path)?; + let prepared = vm::create(&runtime, &config, None, &VmDir::open(&path)?)?; + let arguments: Vec<_> = prepared + .arguments() + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect(); + let expected = Self::parse(&arguments).normalized_cold_outputs(false); + drop(prepared); + Ok(expected) + } + + /// Checks fresh output names rather than making random endpoint values an assertion escape. + pub(crate) fn normalized_cold_outputs(&self, removed: bool) -> Self { + let mut options = self.clone(); + let report = PathBuf::from(options.value("--microvm-report")); + assert!(report.is_absolute()); + assert_eq!(report.file_name().unwrap(), "outcome.json"); + let parent = report.parent().unwrap(); + if removed { + let name = parent.file_name().unwrap().to_str().unwrap(); + let suffix = name + .strip_prefix("nvx-run-") + .expect("a cold CLI run owns a fresh VM directory"); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!( + !parent.exists(), + "the foreground run left its VM directory: {}", + parent.display() + ); + } + let console = options + .value("--virtio-console") + .strip_prefix("listen=") + .unwrap(); + let control = options + .value("--microvm-control-console") + .strip_prefix("listen=") + .unwrap(); + assert_ne!(console, control); + for (endpoint, name) in [(console, "console.sock"), (control, "control.sock")] { + if cfg!(windows) { + let suffix = endpoint.strip_prefix("//./pipe/openvmm-microvm-").unwrap(); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + } else { + assert_eq!(Path::new(endpoint), parent.join(name)); + } + } + options = options + .with("--virtio-console", ["listen="]) + .with("--microvm-control-console", ["listen="]) + .with("--microvm-report", [""]); + options + } } /// Returns the options with which a one-shot run of the minimal sandbox, `sandbox run --layer diff --git a/nvx-rs/tests/cli/validation.rs b/nvx-rs/tests/cli/validation.rs index 441e54fc9..7cb9f24ff 100644 --- a/nvx-rs/tests/cli/validation.rs +++ b/nvx-rs/tests/cli/validation.rs @@ -729,6 +729,7 @@ fn sandbox_refusals() -> Vec { fn run_refusals() -> Vec { let targets = "with several shares, or a shared file"; let budget = &["--mount", "--cmdline", "1024"][..]; + let overflow = crate::openvmm::two_share_command_line_room() + 1; let two_shares = |cmdline: &str| { run_dry(&[ "--mount", @@ -740,7 +741,7 @@ fn run_refusals() -> Vec { ]) }; let mut many = vec!["run".to_owned()]; - many.extend(shares(20)); + many.extend(shares(42)); many.push("--dry-run".to_owned()); vec![ refusal( @@ -1230,13 +1231,12 @@ fn run_refusals() -> Vec { ]), &["--mount target", "\"/voil\u{e0}\"", targets], ), - // The target of one directory share reaches the kernel command line in OpenVMM's - // virtfs_dir= token. + // A single directory is now checked for the same planned nvx_share= row as an aggregate. refusal( "run: quoted target of one share", "unsupported", run_dry(&["--mount", "/a\"b,{root/share}"]), - &["--mount target", "\"/a\\\"b\"", "one kernel token"], + &["--mount target", "\"/a\\\"b\"", "quotes"], ), refusal( "run: target with a dot component", @@ -1331,14 +1331,27 @@ fn run_refusals() -> Vec { refusal( "run: budget exceeded by a byte", "unsupported", - two_shares(&"x".repeat(842)), + two_shares(&"x".repeat(overflow)), budget, ), // The budget is of bytes: each of these characters takes two. refusal( "run: budget in UTF-8 bytes", "unsupported", - two_shares(&"\u{e9}".repeat(421)), + two_shares(&("\u{e9}".repeat(overflow / 2) + &"x".repeat(overflow % 2))), + budget, + ), + // A single directory also has a planned boot bind now; keep the former unbounded + // positive case as a checked refusal rather than dropping its input. + refusal( + "run: long command line of one directory share", + "unsupported", + run_dry(&[ + "--mount", + "/data,{root/share}", + "--cmdline", + &"x".repeat(1100), + ]), budget, ), refusal( @@ -1352,7 +1365,7 @@ fn run_refusals() -> Vec { "run: caller owner on Windows", "unsupported", run_dry(&["--mount", "/data,{root/share}", "--mount-owner", "caller"]), - &["--mount-owner caller", "Linux"], + &["caller owner", "Linux"], ) .on(Hosts::Windows), refusal( diff --git a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in index 8468e6a6a..96aee1e1c 100644 --- a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in @@ -16,14 +16,10 @@ expect_read_only() { esac } -# A lone share is the root of the virtio-fs device, and several are the -# children of an aggregate, which the guest binds at their targets. -aggregate=@AGGREGATE@ -if [ "$aggregate" = 1 ]; then - grep -q 'virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=rw virtfs_aggregate=1' /proc/cmdline || fail 10 -elif grep -q 'virtfs_aggregate=' /proc/cmdline; then - fail 11 -fi +# The checked export plan always uses an aggregate, even for one bind. +aggregate=/run/nvx/hostfs/root +export_mode=@EXPORT_MODE@ +grep -q "virtfs_dir=$aggregate virtfs_tag=microvm virtfs_mode=$export_mode virtfs_aggregate=1" /proc/cmdline || fail 10 # Each TARGET:MODE:SEED names a share's guest target, its mode, and the # contents of its seed file. @@ -51,11 +47,10 @@ for share in @SHARES@; do expect_read_only "$error" $((code + 6)) # OpenVMM enforces the mode on the host, so the share refuses writes # even after guest root makes its mount read-write. - if [ "$aggregate" = 1 ]; then - mount -o remount,bind,rw "$target" || fail $((code + 7)) - else - mount -o remount,rw "$target" || fail $((code + 7)) + if [ "$export_mode" = ro ]; then + mount -o remount,rw "$aggregate" || fail $((code + 7)) fi + mount -o remount,bind,rw "$target" || fail $((code + 7)) grep -q "^microvm $target virtiofs rw," /proc/mounts || fail $((code + 8)) if error=$(touch "$target/from-guest" 2>&1); then fail $((code + 9)) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 41fec1495..e4dc5e1e8 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -9985,8 +9985,8 @@ def run_filesystem_comma_paths( ) -> None: """Share host directories whose paths contain commas through `nvx-rs run`: a read-write `alpha,beta` and a read-only `2026, final`, each with a - denied subdirectory, first each alone, as OpenVMM's `--mount`, and then - both, as the children of an aggregate. The guest must find each exact + denied subdirectory, first each alone and then both through the checked + export plan's aggregate and guest binds. The guest must find each exact directory, rather than the one beside it that the text before the last comma of its path names, with its mode, which OpenVMM enforces, and without its denied subdirectory.""" @@ -10031,7 +10031,11 @@ def run_filesystem_comma_paths( command, _render_script( "filesystem-comma-paths.sh.in", - AGGREGATE="1" if len(attached) > 1 else "0", + EXPORT_MODE=( + "rw" + if any(shares[share][1] == "rw" for share in attached) + else "ro" + ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), FILESYSTEM_COMMA_PATHS_MARKER, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 399c715f3..adf7c96c6 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 6, + "nvx-rs/src/guest/init.rs": 7, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index aeeb7f3a2..1ab994326 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -3909,7 +3909,11 @@ def test_scenario_shares_each_directory_alone_and_both_together(self): self.assertEqual((host.parent.name, host.name), (parent, name)) self.assertEqual(Path(deny), host / "secret") self.assertIn(f"{target}:{mode}:{seed}", script) - self.assertIn(f"\naggregate={int(len(shares) > 1)}\n", script) + export_mode = "rw" if any(share[3] == "rw" for share in shares) else "ro" + self.assertIn("\naggregate=/run/nvx/hostfs/root\n", script) + self.assertIn(f"\nexport_mode={export_mode}\n", script) + self.assertIn('mount -o remount,rw "$aggregate"', script) + self.assertIn('mount -o remount,bind,rw "$target"', script) self.assertNotIn("@", script) def test_scenario_rejects_a_write_to_the_read_only_directory(self): diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index c653e4a84..c984b57b1 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -18440,6 +18440,168 @@ def _shell_function(source: str, name: str) -> str: return source[start : source.index("\n}\n", start) + 3] +class GuestInitRoutingTests(unittest.TestCase): + INIT = Path(__file__).parents[1] / "guest" / "common" / "init" + + def setUp(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + self.shell = shell + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.pid = self.root / "daemon.pid" + self.announcer = self.root / "announcer.pid" + (self.root / "net" / "eth0").mkdir(parents=True) + (self.root / "os-release").write_text("ID=alpine\n", encoding="utf-8") + (self.root / "alpine-release").write_text("test\n", encoding="utf-8") + self.helpers: dict[str, Path] = {} + for name, script in { + "nvx-time": f"printf '4242\\n' >{shlex.quote(self.pid.as_posix())}\n", + "nvx-init": 'printf "new-init"; printf " <%s>" "$@"; echo\n', + "nvx-init-agent": 'printf "old-init"; printf " <%s>" "$@"; echo\n', + "nvx-ingress": ( + f'printf "%s\\n" "$$" >{shlex.quote(self.announcer.as_posix())}\n' + ), + "nvx-hostmount": "echo hostmount\n", + "shell": "echo blockless-shell\n", + }.items(): + path = self.root / name + path.write_text(f"#!/bin/sh\n{script}", encoding="utf-8") + path.chmod(0o755) + self.helpers[name] = path + + def _run(self, cmdline: str) -> subprocess.CompletedProcess[str]: + command_line = self.root / "cmdline" + command_line.write_text(cmdline, encoding="utf-8") + source = self.INIT.read_text(encoding="utf-8") + replacements = { + "/sbin/nvx-init-agent": self.helpers["nvx-init-agent"], + "/sbin/nvx-init": self.helpers["nvx-init"], + "/sbin/nvx-time": self.helpers["nvx-time"], + "/sbin/nvx-ingress": self.helpers["nvx-ingress"], + "/sbin/nvx-hostmount": self.helpers["nvx-hostmount"], + "/proc/cmdline": command_line, + "/proc/sys/kernel/modules_disabled": self.root / "modules_disabled", + "/sys/class/net": self.root / "net", + "/run/nvx/time/daemon.pid": self.pid, + "/etc/os-release": self.root / "os-release", + "/etc/alpine-release": self.root / "alpine-release", + "exec /bin/sh": self.helpers["shell"], + } + for original, replacement in replacements.items(): + target = shlex.quote(replacement.as_posix()) + if original == "exec /bin/sh": + target = f"exec {target}" + source = source.replace(original, target) + source = source.replace("/sbin/nvx-exit", "nvx_exit") + source = source.replace("/sbin/nvx-snapshot", "nvx_snapshot") + return subprocess.run( + [self.shell, "-s"], + input=( + "mount() { :; }\nifconfig() { :; }\nroute() { :; }\n" + 'nvx_exit() { exit "$1"; }\n' + "nvx_snapshot() { echo blockless-snapshot; }\n" + f"{source}" + ), + text=True, + capture_output=True, + check=False, + timeout=40, + ) + + def test_tokenless_layered_and_fresh_sandboxes_use_new_init(self): + for tokens in ( + "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs", + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_mode=managed", + ): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 nvx_sandbox=1 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_old_lifecycle_tokens_keep_the_old_layered_init(self): + for lifecycle in ("managed", "one-shot", ""): + with self.subTest(lifecycle=lifecycle): + result = self._run( + "nvx_sandbox=1 nvx_layer=distro,0xd0003000,erofs " + f"nvx_overlay_upper=ramfs nvx_lifecycle={lifecycle}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + self.assertNotIn("new-init", result.stdout) + + def test_exec_override_cannot_bypass_the_new_sandbox_init(self): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_exec=/bin/true" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + for tokens in ("", "nvx_sandbox=1 nvx_lifecycle=managed"): + with self.subTest(tokens=tokens): + result = self._run(f"{tokens} nvx_exec=/bin/true") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_blockless_platform_and_snapshot_vms_keep_their_routes(self): + for tokens in ("", "nvx_snapshot_tier=platform", "shellsnap snap"): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("hostmount\n", result.stdout) + self.assertIn("blockless-shell\n", result.stdout) + self.assertNotIn("new-init", result.stdout) + self.assertNotIn("old-init", result.stdout) + if tokens == "shellsnap snap": + self.assertEqual(result.stdout.count("blockless-snapshot\n"), 2) + self.assertIn("SNAP-RESTORE-OK\n", result.stdout) + + def test_new_init_receives_the_announcers_process_id_when_present(self): + for ingress in ("allow", "host-loopback"): + with self.subTest(ingress=ingress): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform " + "virtnet_ip=172.16.0.2 virtnet_gw=172.16.0.1 " + f"virtnet_ingress={ingress}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + pid = self.announcer.read_text(encoding="utf-8").strip() + self.assertGreater(int(pid), 1) + self.assertIn(f"new-init <4242> <{pid}>\n", result.stdout) + + def test_new_init_waits_for_the_time_daemons_deferred_publication(self): + temporary = self.pid.with_name("daemon.pid.tmp") + self.helpers["nvx-time"].write_text( + "#!/bin/sh\n" + f"(sleep 0.1; printf '4242\\n' >{shlex.quote(temporary.as_posix())}; " + f"mv {shlex.quote(temporary.as_posix())} " + f"{shlex.quote(self.pid.as_posix())}) &\n", + encoding="utf-8", + ) + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_missing_helpers_or_selected_init_fail_without_fallback(self): + self.helpers["nvx-time"].write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + self.pid.mkdir() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("time daemon process ID is unavailable", result.stderr) + self.assertNotIn("new-init", result.stdout) + + self.helpers["nvx-init"].unlink() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("selected guest cannot act as sandbox control", result.stderr) + self.assertNotIn("old-init", result.stdout) + result = self._run("nvx_sandbox=1 nvx_lifecycle=managed") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + + class GuestInitWorkloadIdentityTests(unittest.TestCase): """The shell /init's workload account and identity checks, as a one-shot launch that asks for an account with nvx_workload_account=create runs diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d700bda5f..d5517f86e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1116,8 +1116,9 @@ NVX's initramfs hands a VM of the managed lifecycle to NVX's managed agent: its `/init` runs `exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME` as PID 1. A VM with a layered root, whose kernel command line has -`nvx_sandbox=1`, goes to NVX's init agent instead, which assembles the root: -`/init` runs `exec /sbin/nvx-init-agent`, without arguments. +`nvx_sandbox=1` and no old OpenVMM lifecycle token, goes to NVX's init instead: +`/init` runs `exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER]`, with the helpers' +process IDs. Old-token layered launches keep `/sbin/nvx-init-agent`. `initramfs::write` copies that initramfs, and appends to the copy a second archive, in the `newc` format, which Linux unpacks over the first: @@ -1128,8 +1129,8 @@ archive, in the `newc` format, which Linux unpacks over the first: - a symbolic link from `/sbin/nvx-managed-agent` to that script; - a script, `/sbin/runtime-adapter-layered`, that runs `runtime-adapter-guest init /dev/hvc2 layered`, which names the control tty - itself, since `/init` passes NVX's init agent none; -- a symbolic link from `/sbin/nvx-init-agent` to that script, so that NVX's + itself, and ignores the helper arguments; +- a symbolic link from `/sbin/nvx-init` to that script, so that NVX's init agent never assembles the root in the adapter's VMs; - for the capture of a template whose clones delay their repair, for tests, the delay's file, `/etc/runtime-adapter-repair-delay`, in the `/etc` directory of diff --git a/tests/runtime-adapter/src/initramfs.rs b/tests/runtime-adapter/src/initramfs.rs index 67384d158..f79127902 100644 --- a/tests/runtime-adapter/src/initramfs.rs +++ b/tests/runtime-adapter/src/initramfs.rs @@ -7,11 +7,11 @@ //! exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME //! ``` //! -//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1`, to NVX's init -//! agent, which assembles the root, without arguments: +//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1` and no old +//! OpenVMM lifecycle token, to NVX's init, with its helpers' process IDs: //! //! ```text -//! exec /sbin/nvx-init-agent +//! exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER] //! ``` //! //! [`write`](fn@write) copies that initramfs, and appends to the copy a second archive, in the @@ -23,7 +23,7 @@ //! - a symbolic link from [`MANAGED_AGENT`] to that script; //! - a script, at [`LAYERED_LAUNCHER`], that runs the guest agent as //! `runtime-adapter-guest init /dev/hvc2 layered`, PID 1 of a VM with a layered root, which -//! names the control tty, [`CONTROL_TTY`], itself, since `/init` passes NVX's init agent none; +//! names the control tty, [`CONTROL_TTY`], itself, and ignores the helper arguments; //! - and a symbolic link from [`INIT_AGENT`] to that script, so that NVX's init agent never //! assembles the root in the adapter's VMs; //! - for the capture of a template whose clones delay their repair, for tests, the delay's file, @@ -53,7 +53,7 @@ pub const MANAGED_AGENT: &str = "/sbin/nvx-managed-agent"; /// NVX's init agent. pub const LAYERED_LAUNCHER: &str = "/sbin/runtime-adapter-layered"; /// Path of NVX's init agent, which `/init` runs in a VM with a layered root. -pub const INIT_AGENT: &str = "/sbin/nvx-init-agent"; +pub const INIT_AGENT: &str = "/sbin/nvx-init"; /// The VM's control tty, which `/init` requires of both lifecycles that NVX's agents serve. pub const CONTROL_TTY: &str = "/dev/hvc2"; diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index c2eab36c9..dd80fe88c 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -4,7 +4,7 @@ //! //! # PID 1 and the worker //! -//! In a VM with a layered root, NVX's `/init` runs `/sbin/nvx-init-agent`, which the adapter's +//! In a VM with a tokenless layered root, NVX's `/init` runs `/sbin/nvx-init`, which the adapter's //! initramfs replaces with `runtime-adapter-guest init /dev/hvc2 layered`; see the host side's //! `initramfs` module. That PID 1, [`supervise`], stays minimal: //! diff --git a/tests/runtime-adapter/tests/initramfs.rs b/tests/runtime-adapter/tests/initramfs.rs index 22eea4537..8ad14aed9 100644 --- a/tests/runtime-adapter/tests/initramfs.rs +++ b/tests/runtime-adapter/tests/initramfs.rs @@ -103,7 +103,7 @@ fn expected(agent: &[u8]) -> Vec { 0o100_755, initramfs::layered_launcher_script().as_bytes(), ), - entry(INIT_AGENT, 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), + entry("/sbin/nvx-init", 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), ] } @@ -151,15 +151,14 @@ fn the_launchers_run_the_guest_agent_on_the_control_tty() { ); assert_eq!(MANAGED_AGENT, "/sbin/nvx-managed-agent"); - // It passes NVX's init agent nothing, so the layered launcher names the control tty, which - // `/init` requires of both lifecycles. + // The layered launcher ignores the helper PIDs and names its control tty itself. let layered = runtime_adapter::GUEST_LAYERED_MODE; assert_eq!( initramfs::layered_launcher_script(), format!("#!/bin/sh\nexec {GUEST_AGENT} {init} /dev/hvc2 {layered}\n") ); assert_eq!(CONTROL_TTY, "/dev/hvc2"); - assert_eq!(INIT_AGENT, "/sbin/nvx-init-agent"); + assert_eq!(INIT_AGENT, "/sbin/nvx-init"); } #[test]