diff --git a/nvx-rs/README.md b/nvx-rs/README.md index bc5bde8a3..595f88ad9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -153,7 +153,7 @@ changes only when the module's purpose does. | [`nvx::guest::exec`](#nvxguestexec) | `Execution`: drives one program as an execution, for any guest init or agent | | [`nvx::guest::exec::binding`](#nvxguestexecbinding) | `Binding`: an `nvx::exec::Runner` bound to an `Execution`, the glue that a guest adapter would otherwise write | | [`nvx::guest::time`](#nvxguesttime) | x86_64: the steps of NVX time ABI v1 as typed calls in the caller's process, for any guest init or agent | -| [`nvx::guest::clone`](#nvxguestclone) | x86_64: the preparation of a template's guest for a capture, and the repair and activation of each clone | +| [`nvx::guest::activation`](#nvxguestactivation) | x86_64: fence-free activation, with the retained template capture and clone repair interface | | [`nvx::guest::reseed`](#nvxguestreseed) | A restore's fresh entropy, and the reseed of the kernel's random number generator with it | | [`nvx::guest::root`](#nvxguestroot) | The operations of a layered root: its bootstrap, assembly, shares, export, mappings, and teardown | | [`nvx::guest::view`](#nvxguestview) | The VM-wide hostname, machine ID, and single-server resolver, whose binds the layered root owns | @@ -1696,12 +1696,16 @@ did not start. `nvx-time` runs its steps through them. -### `nvx::guest::clone` +### `nvx::guest::activation` x86_64: the preparation of a template's guest for a capture before its root's assembly, and the repair of each clone that a restore of its snapshot starts, for a guest init or agent, whether or not it is PID 1. +`nvx::guest::clone` remains a compatibility alias. Activation itself holds no +admission fence and starts no process; the older capture interface below +retains its caller-owned fence. + `CapturePreparation`, of the caller's `AdmissionFence` and the `Devices` that the template's clones replace, the `distro` and scratch block devices, which their MMIO windows name, and the host filesystem device's virtio-fs tag: @@ -1760,7 +1764,7 @@ capture's preparation and the time daemon enforce it. An `Activation` of the `Repaired` clone, or, with `Activation::with_resources`, of the resources and the instant of the acknowledgement that a repair reports, -as in an adapter's tests, and of the caller's closed `AdmissionFence` then takes +as in an adapter's tests, then takes the clone's own configuration from the host, in one Activate, on a restricted control session: a state machine without I/O, independent of any framing, to which the adapter passes what its session receives, as an `ActivationInput`, and @@ -1794,37 +1798,29 @@ the template's placeholder. Whatever the image, it requires a fresh upper, whose reads no file, while a cold sandbox's scratch keeps its upper across boots. It uses no execution identity from the record, which carries none: each execution names its own identity, which `Entry::check` checks, as in a sandbox without a workload -identity at a cold boot. With the record's generated machine ID, it then -generates one, once, with `MachineId::generate`, after the repair's reseed, so -that no two clones share it, to `MACHINE_ID_FILE` in the template's runtime -directory. With the record's export, it mounts the host export through that +identity at a cold boot. With the record's export, it mounts the host export through that filesystem device with `Root::mount_export`, read-write, at `hostfs/root` in the runtime directory, outside the root, since the record names no mode and OpenVMM -limits each child to its own access; with its network's DNS server, it writes a -resolver file that names it, mode 0444, to `RESOLVER_FILE` in the runtime -directory; and with a network that publishes the workload's listeners, it starts -their announcer, `/sbin/nvx-ingress inherit GATEWAY_IPV4`, which announces from -its standard input, a UDP socket that the application bound, as root, to a -privileged source port. It then binds the runtime's files for every execution -with one `Entry::bind_runtime` in the caller's mount namespace: the machine ID's -file read-only over the root's `/etc/machine-id` and the resolver file over its -`/etc/resolv.conf`, or, with neither, nothing; the entry checks the record's -hostname, which every execution's entry takes with `Entry::hostname`. It returns +limits each child to its own access. It applies the record's VM-wide view with +`view::apply`, passing the record's own DNS server. The view sets the hostname +and, when requested, generates a machine ID after repair's reseed. The root +owns the machine-ID and resolver binds, including those made before a later +failure. Application starts no announcer and no other process. It returns the `Applied` root and workload, from which `take_export` hands the adapter the export's `SourceRoot`, to map the host's mapping table from once the clone is ready, `proxy` the network's proxy endpoint, which the adapter tells its -workloads as at a cold boot, and `announcer` the announcer, which the adapter -watches through its pidfd, and must end the clone once it ended, since the -gateway then keeps publishing the listeners that it last announced until the VM -ends, and whose `teardown` stops the announcer, closes the export's root, and -removes the binds before it tears the root down, as a drop does, or an +workloads as at a cold boot, and `publication` and `gateway`, from which its +caller starts and supervises ingress. Its `teardown` closes the export's root, +then tears the root down with its view binds; `into_parts` returns +`(Root, MachineId)`, retaining every bind in the root. A switched root unmounts +nothing. A failure returns an `ApplyError`, `Export` or `Network` among them, after which nothing that it mounted or bound stays mounted unless the error reports the unmounts that -failed, and no announcer that it started runs. +failed. Once the adapter passed the activation what the session received while the -application ran, which came before Ready, `Activation::complete` opens the fence -and returns the `ReadyActivation` that Ready reports beside the adapter's own +application ran, which came before Ready, `Activation::complete` returns the +`ReadyActivation` that Ready reports beside the adapter's own identities, that the activation applied, with the record's transaction ID, after which the host's mapping table and start follow in the adapter's order, as after a cold boot, and `Activation::start` records the start. @@ -3105,7 +3101,7 @@ disk's block device. | `nvx-exit [CODE]` | Stops the VM with an 8-bit exit code through OpenVMM's control port 0x604 (x86_64) | | `nvx-device-io file ...`, `nvx-device-io network ...`, `nvx-device-io map PATH TEXT` | The benchmark helper that measures file and UDP round-trip I/O and prints one JSON line, and reports a file benchmark's progress on stderr; `map` writes TEXT at the start of a file through a shared mapping and holds the dirty mapping until its standard input ends, for the microVM scenarios (x86_64) | | `nvx-managed-agent CONTROL_TTY ROOTFS HOSTNAME UID GID USER HOME` | Serves the managed control session and supervises sequential workloads (x86_64) | -| `nvx-ingress [inherit\|start] GATEWAY_IPV4` | Announces reachable TCP listeners and settled UDP sockets to the portable-network gateway, from a privileged source port that it binds, or, with `inherit`, from its standard input, a socket that a clone's application bound to one; `start` starts `inherit` as `Announcer::start` does and exits once it runs, or with status 1 if it does not start, for a guest agent that cannot hold the announcer; `test DIRECTORY` runs synthetic procfs snapshots | +| `nvx-ingress [inherit\|start] GATEWAY_IPV4` | Announces reachable TCP listeners and settled UDP sockets to the portable-network gateway, from a privileged source port that it binds, or, with `inherit`, from its standard input, a socket that the caller bound to one after the clone's process-free application; `start` starts `inherit` as `Announcer::start` does and exits once it runs, or with status 1 if it does not start, for a guest agent that cannot hold the announcer; `test DIRECTORY` runs synthetic procfs snapshots | | `nvx-time [--report-only] COMMAND` | Enforces NVX time ABI v1: runs its conformance checks, repairs the wall clock after a restore, publishes the time state, and runs the discipline and violation-watcher daemon; `test NAME ...` runs its decoder and process test hooks (x86_64) | | `nvx-time-probe latency\|warp\|clock ...` | Diagnoses clock-read latency, cross-CPU TSC warp and offset, the clocks, CPUID, clock events, kernel messages, and optionally MSRs (x86_64) | | `nvx-init TIME_DAEMON [ANNOUNCER]` | Takes PID 1 over from a layered root's early boot, with its time daemon and optional announcer: gives itself and them an out-of-memory score adjustment of -1000 and a nice value of -10, collects every child's status, and, after `sync`, ends the VM with status 125 when a helper ends or 0 on `SIGTERM`; elsewhere it exits with 2 (x86_64) | diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index fd53490f6..d7e079c64 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -585,6 +585,221 @@ struct nvx::guest::GenerationId const nvx::guest::GenerationId::LENGTH method nvx::guest::GenerationId::from_bytes method nvx::guest::GenerationId::to_bytes +mod nvx::guest::activation +struct nvx::guest::activation::Activation +method nvx::guest::activation::Activation::complete +method nvx::guest::activation::Activation::deadline +method nvx::guest::activation::Activation::established +method nvx::guest::activation::Activation::input +method nvx::guest::activation::Activation::new +method nvx::guest::activation::Activation::start +method nvx::guest::activation::Activation::state +method nvx::guest::activation::Activation::tick +method nvx::guest::activation::Activation::with_resources +enum nvx::guest::activation::ActivationAction +variant nvx::guest::activation::ActivationAction::Acknowledge +variant nvx::guest::activation::ActivationAction::Apply +field nvx::guest::activation::ActivationAction::Apply::0 +variant nvx::guest::activation::ActivationAction::Continue +variant nvx::guest::activation::ActivationAction::Join +variant nvx::guest::activation::ActivationAction::Ready +field nvx::guest::activation::ActivationAction::Ready::0 +variant nvx::guest::activation::ActivationAction::Serve +enum nvx::guest::activation::ActivationCause +variant nvx::guest::activation::ActivationCause::Changed +variant nvx::guest::activation::ActivationCause::Cold +variant nvx::guest::activation::ActivationCause::Completion +variant nvx::guest::activation::ActivationCause::Expired +variant nvx::guest::activation::ActivationCause::Failed +field nvx::guest::activation::ActivationCause::Failed::0 +variant nvx::guest::activation::ActivationCause::Record +field nvx::guest::activation::ActivationCause::Record::0 +variant nvx::guest::activation::ActivationCause::Request +variant nvx::guest::activation::ActivationCause::Reset +variant nvx::guest::activation::ActivationCause::Resources +field nvx::guest::activation::ActivationCause::Resources::actual +field nvx::guest::activation::ActivationCause::Resources::expected +variant nvx::guest::activation::ActivationCause::Start +variant nvx::guest::activation::ActivationCause::Transaction +field nvx::guest::activation::ActivationCause::Transaction::accepted +field nvx::guest::activation::ActivationCause::Transaction::offered +struct nvx::guest::activation::ActivationFailure +method nvx::guest::activation::ActivationFailure::cause +method nvx::guest::activation::ActivationFailure::state +enum nvx::guest::activation::ActivationInput +variant nvx::guest::activation::ActivationInput::Activate +field nvx::guest::activation::ActivationInput::Activate::0 +variant nvx::guest::activation::ActivationInput::Flow +variant nvx::guest::activation::ActivationInput::Request +variant nvx::guest::activation::ActivationInput::Reset +variant nvx::guest::activation::ActivationInput::TooLong +field nvx::guest::activation::ActivationInput::TooLong::0 +enum nvx::guest::activation::ActivationState +variant nvx::guest::activation::ActivationState::Activating +variant nvx::guest::activation::ActivationState::Bootstrap +variant nvx::guest::activation::ActivationState::Failed +variant nvx::guest::activation::ActivationState::Ready +variant nvx::guest::activation::ActivationState::Restricted +variant nvx::guest::activation::ActivationState::Started +trait nvx::guest::activation::AdmissionFence +method nvx::guest::activation::AdmissionFence::close +method nvx::guest::activation::AdmissionFence::open +struct nvx::guest::activation::Applied (hidden) +method nvx::guest::activation::Applied::gateway (hidden) +method nvx::guest::activation::Applied::hostname (hidden) +method nvx::guest::activation::Applied::into_parts (hidden) +method nvx::guest::activation::Applied::machine_id (hidden) +method nvx::guest::activation::Applied::proxy (hidden) +method nvx::guest::activation::Applied::publication (hidden) +method nvx::guest::activation::Applied::resolver (hidden) +method nvx::guest::activation::Applied::root (hidden) +method nvx::guest::activation::Applied::root_mut (hidden) +method nvx::guest::activation::Applied::take_export (hidden) +method nvx::guest::activation::Applied::teardown (hidden) +struct nvx::guest::activation::AppliedTeardownError (hidden) +method nvx::guest::activation::AppliedTeardownError::binds (hidden) +method nvx::guest::activation::AppliedTeardownError::root (hidden) +enum nvx::guest::activation::ApplyError (hidden) +variant nvx::guest::activation::ApplyError::Assembly (hidden) +field nvx::guest::activation::ApplyError::Assembly::0 (hidden) +variant nvx::guest::activation::ApplyError::Export (hidden) +field nvx::guest::activation::ApplyError::Export::error (hidden) +field nvx::guest::activation::ApplyError::Export::teardown (hidden) +variant nvx::guest::activation::ApplyError::Gpt (hidden) +field nvx::guest::activation::ApplyError::Gpt::0 (hidden) +variant nvx::guest::activation::ApplyError::MachineId (hidden) +field nvx::guest::activation::ApplyError::MachineId::error (hidden) +field nvx::guest::activation::ApplyError::MachineId::teardown (hidden) +variant nvx::guest::activation::ApplyError::Network (hidden) +field nvx::guest::activation::ApplyError::Network::error (hidden) +field nvx::guest::activation::ApplyError::Network::teardown (hidden) +variant nvx::guest::activation::ApplyError::View (hidden) +field nvx::guest::activation::ApplyError::View::error (hidden) +field nvx::guest::activation::ApplyError::View::teardown (hidden) +struct nvx::guest::activation::CaptureBarrier +method nvx::guest::activation::CaptureBarrier::cancel +method nvx::guest::activation::CaptureBarrier::capture +enum nvx::guest::activation::CaptureCause +variant nvx::guest::activation::CaptureCause::Io +field nvx::guest::activation::CaptureCause::Io::0 +variant nvx::guest::activation::CaptureCause::Mounted +field nvx::guest::activation::CaptureCause::Mounted::0 +variant nvx::guest::activation::CaptureCause::Pending +variant nvx::guest::activation::CaptureCause::Time +field nvx::guest::activation::CaptureCause::Time::0 +struct nvx::guest::activation::CaptureError +method nvx::guest::activation::CaptureError::cause +method nvx::guest::activation::CaptureError::into_cause +method nvx::guest::activation::CaptureError::stage +struct nvx::guest::activation::CapturePreparation +method nvx::guest::activation::CapturePreparation::new +method nvx::guest::activation::CapturePreparation::prepare +enum nvx::guest::activation::CaptureStage +variant nvx::guest::activation::CaptureStage::BootChecks +variant nvx::guest::activation::CaptureStage::Capture +variant nvx::guest::activation::CaptureStage::CaptureChecks +variant nvx::guest::activation::CaptureStage::Fence +variant nvx::guest::activation::CaptureStage::GenerationId +variant nvx::guest::activation::CaptureStage::Mounts +variant nvx::guest::activation::CaptureStage::Release +variant nvx::guest::activation::CaptureStage::Suppression +enum nvx::guest::activation::Captured +variant nvx::guest::activation::Captured::Continued +variant nvx::guest::activation::Captured::Restored +field nvx::guest::activation::Captured::Restored::0 +struct nvx::guest::activation::Devices +field nvx::guest::activation::Devices::distro +field nvx::guest::activation::Devices::filesystem_tag +field nvx::guest::activation::Devices::scratch +const nvx::guest::activation::MACHINE_ID_FILE +const nvx::guest::activation::MOUNT_TABLE_LIMIT +struct nvx::guest::activation::Mounted +method nvx::guest::activation::Mounted::mount_point +method nvx::guest::activation::Mounted::origin +enum nvx::guest::activation::NetworkError (hidden) +variant nvx::guest::activation::NetworkError::Bind (hidden) +field nvx::guest::activation::NetworkError::Bind::0 (hidden) +variant nvx::guest::activation::NetworkError::Gateway (hidden) +variant nvx::guest::activation::NetworkError::Write (hidden) +field nvx::guest::activation::NetworkError::Write::0 (hidden) +enum nvx::guest::activation::Origin +variant nvx::guest::activation::Origin::Distro +variant nvx::guest::activation::Origin::Filesystem +variant nvx::guest::activation::Origin::Overlay +variant nvx::guest::activation::Origin::Scratch +const nvx::guest::activation::RESOLVER_FILE +enum nvx::guest::activation::RangeFault +variant nvx::guest::activation::RangeFault::Aperture +variant nvx::guest::activation::RangeFault::Extra +variant nvx::guest::activation::RangeFault::Gap +variant nvx::guest::activation::RangeFault::Misaligned +variant nvx::guest::activation::RangeFault::Overlap +struct nvx::guest::activation::Repair +method nvx::guest::activation::Repair::new +method nvx::guest::activation::Repair::run +enum nvx::guest::activation::RepairCause +variant nvx::guest::activation::RepairCause::Activation +field nvx::guest::activation::RepairCause::Activation::online +field nvx::guest::activation::RepairCause::Activation::target +variant nvx::guest::activation::RepairCause::BlockSize +field nvx::guest::activation::RepairCause::BlockSize::0 +variant nvx::guest::activation::RepairCause::CapturedRam +variant nvx::guest::activation::RepairCause::Finished +variant nvx::guest::activation::RepairCause::Incomplete +variant nvx::guest::activation::RepairCause::Io +field nvx::guest::activation::RepairCause::Io::0 +variant nvx::guest::activation::RepairCause::Missing +field nvx::guest::activation::RepairCause::Missing::0 +variant nvx::guest::activation::RepairCause::NoSeed +variant nvx::guest::activation::RepairCause::Offline +field nvx::guest::activation::RepairCause::Offline::block +field nvx::guest::activation::RepairCause::Offline::state +variant nvx::guest::activation::RepairCause::OnlineCpus +field nvx::guest::activation::RepairCause::OnlineCpus::0 +variant nvx::guest::activation::RepairCause::ProcessorTarget +field nvx::guest::activation::RepairCause::ProcessorTarget::online +field nvx::guest::activation::RepairCause::ProcessorTarget::target +variant nvx::guest::activation::RepairCause::Range +field nvx::guest::activation::RepairCause::Range::fault +field nvx::guest::activation::RepairCause::Range::index +variant nvx::guest::activation::RepairCause::Reseed +field nvx::guest::activation::RepairCause::Reseed::0 +variant nvx::guest::activation::RepairCause::Time +field nvx::guest::activation::RepairCause::Time::0 +variant nvx::guest::activation::RepairCause::UntargetedRanges +struct nvx::guest::activation::RepairError +method nvx::guest::activation::RepairError::cause +method nvx::guest::activation::RepairError::progress +method nvx::guest::activation::RepairError::stage +method nvx::guest::activation::RepairError::status +struct nvx::guest::activation::RepairProgress +method nvx::guest::activation::RepairProgress::added +method nvx::guest::activation::RepairProgress::finish_started +method nvx::guest::activation::RepairProgress::onlined +method nvx::guest::activation::RepairProgress::reseeded +enum nvx::guest::activation::RepairStage +variant nvx::guest::activation::RepairStage::Finish +variant nvx::guest::activation::RepairStage::Memory +variant nvx::guest::activation::RepairStage::Processors +variant nvx::guest::activation::RepairStage::Reseed +variant nvx::guest::activation::RepairStage::Restore +struct nvx::guest::activation::Repaired +method nvx::guest::activation::Repaired::acknowledged_at +method nvx::guest::activation::Repaired::cpus +method nvx::guest::activation::Repaired::generation_id +method nvx::guest::activation::Repaired::memory +struct nvx::guest::activation::Restored +method nvx::guest::activation::Restored::into_parts +method nvx::guest::activation::Restored::restore +struct nvx::guest::activation::RootTemplate (hidden) +field nvx::guest::activation::RootTemplate::distro (hidden) +field nvx::guest::activation::RootTemplate::filesystem_tag (hidden) +field nvx::guest::activation::RootTemplate::gateway (hidden) +field nvx::guest::activation::RootTemplate::runtime (hidden) +field nvx::guest::activation::RootTemplate::scratch (hidden) +field nvx::guest::activation::RootTemplate::timeout (hidden) +fn nvx::guest::activation::apply (hidden) +fn nvx::guest::activation::repair mod nvx::guest::cgroup struct nvx::guest::cgroup::ExecutionCgroup method nvx::guest::cgroup::ExecutionCgroup::is_occupied @@ -663,12 +878,12 @@ trait nvx::guest::clone::AdmissionFence method nvx::guest::clone::AdmissionFence::close method nvx::guest::clone::AdmissionFence::open struct nvx::guest::clone::Applied (hidden) -method nvx::guest::clone::Applied::announcer (hidden) -method nvx::guest::clone::Applied::announcer_mut (hidden) +method nvx::guest::clone::Applied::gateway (hidden) method nvx::guest::clone::Applied::hostname (hidden) method nvx::guest::clone::Applied::into_parts (hidden) method nvx::guest::clone::Applied::machine_id (hidden) method nvx::guest::clone::Applied::proxy (hidden) +method nvx::guest::clone::Applied::publication (hidden) method nvx::guest::clone::Applied::resolver (hidden) method nvx::guest::clone::Applied::root (hidden) method nvx::guest::clone::Applied::root_mut (hidden) @@ -735,8 +950,6 @@ struct nvx::guest::clone::Mounted method nvx::guest::clone::Mounted::mount_point method nvx::guest::clone::Mounted::origin enum nvx::guest::clone::NetworkError (hidden) -variant nvx::guest::clone::NetworkError::Announcer (hidden) -field nvx::guest::clone::NetworkError::Announcer::0 (hidden) variant nvx::guest::clone::NetworkError::Bind (hidden) field nvx::guest::clone::NetworkError::Bind::0 (hidden) variant nvx::guest::clone::NetworkError::Gateway (hidden) diff --git a/nvx-rs/src/guest/clone.rs b/nvx-rs/src/guest/activation.rs similarity index 93% rename from nvx-rs/src/guest/clone.rs rename to nvx-rs/src/guest/activation.rs index c92d453da..c88267576 100644 --- a/nvx-rs/src/guest/clone.rs +++ b/nvx-rs/src/guest/activation.rs @@ -107,9 +107,8 @@ //! # Activation //! //! A repaired clone then takes its own configuration from the host, in one Activate, on a -//! restricted control session, through an [`Activation`] of the [`Repaired`] clone and of the -//! caller's fence, which stays closed until the activation is ready, so that no execution starts -//! before. The activation is a state machine without I/O, independent of any framing: the adapter +//! restricted control session, through an [`Activation`] of the [`Repaired`] clone. Activation +//! holds no admission fence and makes no system call. It is independent of any framing: the adapter //! passes it what it receives on the clone's NVXS session, each an [`ActivationInput`], and does //! what each [`ActivationAction`] that it returns says. Its [`ActivationState`]s, in order, are: //! @@ -126,7 +125,7 @@ //! activates the root and the workload with its settings, as below, passes the activation //! what the session received meanwhile, which came before Ready, then reports the result to //! [`Activation::complete`]; -//! 4. ready, with the mappings pending: the activation completed, opened the fence, and returned +//! 4. ready, with the mappings pending: the activation completed and returned //! what Ready reports beside the adapter's own identities, such as its build, a //! [`ReadyActivation`](crate::activation::ReadyActivation): that the activation applied, with //! the record's transaction ID. The host's mapping table, which the record announced, and the @@ -167,34 +166,23 @@ //! sandbox's scratch keeps its upper across boots; the check reads at most one entry of each, and //! no file. The record holds no execution identity: the adapter gives each execution the identity //! that the execution names, which [`Entry::check`](super::entry::Entry::check) checks, as for a sandbox -//! without a workload identity at a cold boot. If the record asks for a generated machine -//! ID, it then generates one, once, with -//! [`MachineId::generate`](super::entry::MachineId::generate), after the repair's reseed, so that -//! no two clones of a template share it, to [`MACHINE_ID_FILE`] in the template's runtime -//! directory. If the record has an export, it mounts the host export through the template's +//! without a workload identity at a cold boot. If the record has an export, it mounts the host +//! export through the template's //! filesystem device, with `Root::mount_export`, read-write, at //! `hostfs/root` in the runtime directory, outside the root, where only the guest's root user //! reaches it: the record names no mode, and OpenVMM limits each of the export's children to its -//! own access. If the record's network names a DNS server, it writes a resolver file that names -//! it to [`RESOLVER_FILE`] in the runtime directory. If the network publishes the workload's -//! listeners, it starts their announcer, with -//! [`Announcer::start`](super::ingress::Announcer::start), to the template's gateway: the -//! template captured its NIC under a policy that publishes nothing, so that no announcer ran in -//! it. It then binds the runtime's files into the root for every execution, with one -//! [`Entry::bind_runtime`](super::entry::Entry::bind_runtime), in the caller's mount namespace: -//! the generated machine ID's file, read-only over the root's `/etc/machine-id`, and the -//! resolver file, read-only over the root's `/etc/resolv.conf`, or, with neither, nothing. The -//! entry checks the record's hostname first, which every execution's entry then takes. It returns +//! own access. It applies the record's VM-wide view through `view::apply`, with the record's +//! DNS server, if any. The root owns the machine-ID and resolver binds, including any made +//! before a later failure. The view sets the VM's hostname and generates an ID only when the +//! record requests one, after the repair's reseed. Application starts no process. It returns //! the `Applied` root and workload, which the adapter keeps, with the export's root, from which //! the adapter maps the host's mapping table once the clone is ready, the network's proxy -//! endpoint, which the adapter tells its workloads as at a cold boot, and the announcer, which the -//! adapter watches, and must end the clone once the announcer ended: the gateway then keeps -//! publishing the listeners that the announcer last announced until the VM ends, and learns of no -//! later change of them. The `teardown` of the `Applied` stops the -//! announcer, closes the export's root, and removes the binds before it tears the root down. An +//! endpoint, which the adapter tells its workloads as at a cold boot, and the publication and +//! gateway with which the caller starts and supervises ingress. The `teardown` of the `Applied` +//! closes the export's root, then tears the root down with its view binds. Its `into_parts` +//! returns the root and machine ID, with every bind still owned by the root. An //! application that fails returns an `ApplyError` instead, after which nothing that it mounted or -//! bound stays mounted, unless the error holds the unmounts that failed, and no announcer that it -//! started runs. +//! bound stays mounted, unless the error holds the unmounts that failed. //! //! # Example //! @@ -202,7 +190,7 @@ //! use std::io; //! use std::time::{Duration, Instant}; //! -//! use nvx::guest::clone::{ +//! use nvx::guest::activation::{ //! self, Activation, AdmissionFence, CapturePreparation, Captured, Devices, //! }; //! use nvx::guest::root::Device; @@ -242,8 +230,8 @@ //! Captured::Restored(restored) => { //! // A clone continues here, with its wall clock repaired. Its repair activates its //! // memory, reseeds it, and acknowledges the restore, while its fence stays closed. -//! let (restore, fence) = restored.into_parts(); -//! let repaired = match clone::repair(restore) { +//! let (restore, _fence) = restored.into_parts(); +//! let repaired = match activation::repair(restore) { //! Ok(repaired) => repaired, //! Err(error) => { //! // The agent ends the VM with the failure's status, after an error line. @@ -252,10 +240,10 @@ //! } //! }; //! println!("{} CPUs, {} bytes of memory", repaired.cpus(), repaired.memory()); -//! // Its activation takes the host's Activate, and opens the fence once the clone is -//! // ready: the agent drives it with what its control session receives. +//! // Its fence-free activation takes the host's Activate: the agent drives it with +//! // what its control session receives and admits executions only after Ready. //! let timeout = Duration::from_secs(30); -//! let activation = Activation::new(&repaired, fence, timeout, Instant::now()); +//! let activation = Activation::new(&repaired, timeout, Instant::now()); //! println!("activate by {:?}", activation.deadline()); //! } //! } @@ -276,20 +264,18 @@ //! the time ABI in the capture's preparation, which stops the VM unless the ABI is report-only, //! as [strict mode](#strict-mode) tells, they never power the VM off, so a caller that is not //! PID 1 keeps its own reaping, transport, and shutdown. The activation needs no privilege, and -//! makes no system call: of the clone, it changes only the caller's fence, which it opens. Its +//! makes no system call and changes no admission fence. Its //! application, `apply`, needs the guest's root user, in the guest's initial mount //! namespace, as `root::assemble` does: for a GPT disk, it asks the kernel to reread the disk's -//! partition table, which needs `CAP_SYS_ADMIN`; it mounts the root, reads the account databases -//! below it, and, if it fails, tears it down. It starts no process but the announcer of a record -//! whose network publishes the workload's listeners, whose socket it binds to a privileged source -//! port, and whose status it collects through the `Applied`, and it generates at most one ID, a +//! partition table, which needs `CAP_SYS_ADMIN`; it mounts the root, applies its VM-wide view, +//! and, if it fails, tears it down. It starts no process and generates at most one ID, a //! machine ID that the record asks for. It mounts the host export of a record that has one, and //! writes the machine ID's and the resolver's files in the runtime directory, whose binds over //! the root's `/etc/machine-id` and `/etc/resolv.conf` need Linux 6.8 or later. -mod activation; mod mounts; mod repair; +mod state; #[cfg(test)] mod tests; @@ -307,18 +293,18 @@ use super::time::{ CaptureOutcome, CaptureRequest, PreCaptureError, Restore, ScratchPolicy, TimeAbi, TimeError, }; -pub use self::activation::{ +pub use self::mounts::{Mounted, Origin}; +pub use self::repair::{ + RangeFault, Repair, RepairCause, RepairError, RepairProgress, RepairStage, Repaired, repair, +}; +pub use self::state::{ Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, ActivationState, MACHINE_ID_FILE, RESOLVER_FILE, }; #[doc(hidden)] -pub use self::activation::{ +pub use self::state::{ Applied, AppliedTeardownError, ApplyError, NetworkError, RootTemplate, apply, }; -pub use self::mounts::{Mounted, Origin}; -pub use self::repair::{ - RangeFault, Repair, RepairCause, RepairError, RepairProgress, RepairStage, Repaired, repair, -}; /// The most bytes of the mount table that a [`CapturePreparation`] reads: 1 MiB, the lines of /// thousands of mounts. It refuses a longer table, rather than check a part of it. diff --git a/nvx-rs/src/guest/clone/mounts.rs b/nvx-rs/src/guest/activation/mounts.rs similarity index 100% rename from nvx-rs/src/guest/clone/mounts.rs rename to nvx-rs/src/guest/activation/mounts.rs diff --git a/nvx-rs/src/guest/clone/mounts/tests.rs b/nvx-rs/src/guest/activation/mounts/tests.rs similarity index 100% rename from nvx-rs/src/guest/clone/mounts/tests.rs rename to nvx-rs/src/guest/activation/mounts/tests.rs diff --git a/nvx-rs/src/guest/clone/repair.rs b/nvx-rs/src/guest/activation/repair.rs similarity index 100% rename from nvx-rs/src/guest/clone/repair.rs rename to nvx-rs/src/guest/activation/repair.rs diff --git a/nvx-rs/src/guest/clone/repair/tests.rs b/nvx-rs/src/guest/activation/repair/tests.rs similarity index 99% rename from nvx-rs/src/guest/clone/repair/tests.rs rename to nvx-rs/src/guest/activation/repair/tests.rs index ae9ad384c..0fa4cafb3 100644 --- a/nvx-rs/src/guest/clone/repair/tests.rs +++ b/nvx-rs/src/guest/activation/repair/tests.rs @@ -1216,7 +1216,7 @@ fn a_repair_draws_no_randomness_before_its_reseed() { // that the thread starts, so the test runs in a process of its own. if crate::guest::managed_agent::tests::isolate_process_test( "NVX_CLONE_REPAIR_RANDOMNESS_TEST_CHILD", - "guest::clone::repair::tests::a_repair_draws_no_randomness_before_its_reseed", + "guest::activation::repair::tests::a_repair_draws_no_randomness_before_its_reseed", ) { return; } diff --git a/nvx-rs/src/guest/clone/activation.rs b/nvx-rs/src/guest/activation/state.rs similarity index 95% rename from nvx-rs/src/guest/clone/activation.rs rename to nvx-rs/src/guest/activation/state.rs index f9af8ae4f..02ecb7e23 100644 --- a/nvx-rs/src/guest/clone/activation.rs +++ b/nvx-rs/src/guest/activation/state.rs @@ -12,7 +12,7 @@ use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant}; -use super::{AdmissionFence, Repaired}; +use super::Repaired; use crate::activation::{ ActivationError, ActivationRecord, ExpectedResources, ReadyActivation, TransactionId, }; @@ -24,8 +24,8 @@ pub use self::apply::{ /// The activation of a repaired clone: the state machine, without I/O and independent of any /// framing, of the clone's control session, which takes the clone's configuration from the host -/// in one Activate, behind the caller's fence of workload admission, which it opens once the -/// activation is ready; see the [module](super#activation). +/// in one Activate. It holds no admission fence and makes no system call; see the +/// [module](super#activation). /// /// The adapter passes it what it receives on the session, each an [`ActivationInput`], through /// [`input`](Self::input), and does what the [`ActivationAction`] that it returns says. The @@ -48,9 +48,8 @@ pub use self::apply::{ /// | [`Ready`](ActivationState::Ready), [`Started`](ActivationState::Started) | [`Acknowledge`](ActivationAction::Acknowledge), a later session | [`Continue`](ActivationAction::Continue) | [`Ready`](ActivationAction::Ready) if byte-equal | [`Serve`](ActivationAction::Serve) | /// /// Every failure is final, and leaves the activation [failed](ActivationState::Failed): every -/// later call returns the same [`ActivationFailure`]. A failure before the activation is ready -/// leaves the fence closed, and one after it leaves the fence open. The activation never powers -/// the VM off: the adapter reports the failure and ends the VM. +/// later call returns the same [`ActivationFailure`]. The activation never starts a process or +/// powers the VM off: the adapter reports the failure and ends the VM. /// /// # Example /// @@ -58,8 +57,8 @@ pub use self::apply::{ /// use std::time::Instant; /// /// use nvx::activation::ReadyActivation; -/// use nvx::guest::clone::{ -/// self, Activation, ActivationAction, ActivationFailure, ActivationInput, AdmissionFence, +/// use nvx::guest::activation::{ +/// self, Activation, ActivationAction, ActivationFailure, ActivationInput, /// Applied, RootTemplate, /// }; /// @@ -104,10 +103,10 @@ pub use self::apply::{ /// /// /// Serves the control session of the clone whose activation is `activation`, and whose /// /// template captured `template`, the inputs of its root's assembly, until it fails. -/// fn serve( +/// fn serve( /// agent: &mut impl Agent, /// template: &RootTemplate, -/// mut activation: Activation, +/// mut activation: Activation, /// ) -> Result<(), ActivationFailure> { /// loop { /// let Some(received) = agent.receive(activation.deadline()) else { @@ -120,10 +119,10 @@ pub use self::apply::{ /// ActivationAction::Continue | ActivationAction::Join => {} /// ActivationAction::Apply(record) => { /// // The record is valid, and nothing happened before: the activation's side -/// // effects start here, and the fence opens once they all succeeded. The +/// // effects start here. The /// // application waits for the devices until the activation's deadline at the /// // latest, so that it ends by then. -/// let applied = clone::apply(&record, template, activation.deadline()); +/// let applied = activation::apply(&record, template, activation.deadline()); /// // What the host sent meanwhile came before Ready: a retry of the Activate, /// // byte for byte, joins the application, and anything else, a reset included, /// // fails the activation, which drops what the application assembled. @@ -139,7 +138,7 @@ pub use self::apply::{ /// agent.keep(applied); /// activation.complete(Instant::now(), Ok(()))? /// } -/// // A failed application fails the activation: the fence stays closed. +/// // A failed application fails the activation. /// Err(error) => activation.complete(Instant::now(), Err(error.into()))?, /// }; /// // Ready answers the Activate, and each retry that joined its application. @@ -171,9 +170,7 @@ pub use self::apply::{ /// } /// } /// ``` -pub struct Activation { - /// The caller's fence of workload admission, closed until the activation is ready. - fence: F, +pub struct Activation { /// The resources that the clone has, as its repair reports them, which the record of an /// Activate must expect. resources: ExpectedResources, @@ -183,23 +180,22 @@ pub struct Activation { state: State, } -impl Activation { +impl Activation { /// Returns the activation of the clone that `repaired` describes, in its - /// [transport bootstrap](ActivationState::Bootstrap), behind the caller's `fence`, which - /// [`Restored::into_parts`](super::Restored::into_parts) handed over closed, and which the - /// activation opens once it is ready. + /// [transport bootstrap](ActivationState::Bootstrap), without holding or changing an + /// admission fence. /// /// The activation must be ready by its deadline: `timeout`, which the caller chooses, after /// the repair acknowledged the restore, as [`Repaired::acknowledged_at`] tells, or after `now` /// for a restore that the host did not gate until an acknowledgement, whose repair /// acknowledged nothing. No input or retry extends it. A deadline that no instant can hold /// never passes. - pub fn new(repaired: &Repaired, fence: F, timeout: Duration, now: Instant) -> Self { + pub fn new(repaired: &Repaired, timeout: Duration, now: Instant) -> Self { let resources = ExpectedResources { cpus: repaired.cpus(), memory: repaired.memory(), }; - Self::with_resources(resources, repaired.acknowledged_at(), fence, timeout, now) + Self::with_resources(resources, repaired.acknowledged_at(), timeout, now) } /// Returns the activation of a clone that has `resources`, and whose repair acknowledged its @@ -209,13 +205,11 @@ impl Activation { pub fn with_resources( resources: ExpectedResources, acknowledged_at: Option, - fence: F, timeout: Duration, now: Instant, ) -> Self { let start = acknowledged_at.unwrap_or(now); Self { - fence, resources, deadline: start.checked_add(timeout), state: State::Bootstrap, @@ -302,10 +296,10 @@ impl Activation { /// the adapter's application of the record, and returns what Ready reports: that the /// activation applied, with the record's transaction ID. /// - /// A result that succeeded by the deadline makes the activation ready: it opens the fence, - /// which no input before opened, and the adapter reports Ready. A failure, which may have + /// A result that succeeded by the deadline makes the activation ready, and the adapter + /// reports Ready. A failure, which may have /// applied part of the record, is final, with [`ActivationCause::Failed`], so that no retry - /// activates the record again, and the fence stays closed. Past the deadline, the activation + /// activates the record again. Past the deadline, the activation /// fails with [`ActivationCause::Expired`] instead, whatever the result. Without an activation /// that runs, it fails with [`ActivationCause::Completion`]. pub fn complete( @@ -323,7 +317,6 @@ impl Activation { } let ready = accepted.ready; self.state = State::Ready(accepted); - self.fence.open(); Ok(ready) } @@ -444,7 +437,7 @@ impl Activation { } } -impl fmt::Debug for Activation { +impl fmt::Debug for Activation { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("Activation") @@ -520,7 +513,7 @@ pub enum ActivationState { Restricted, /// The adapter applies the accepted Activate's record. Activating, - /// Ready, with the mappings pending: the activation completed, which opened the fence, and + /// Ready, with the mappings pending: the activation completed, and /// returned what Ready reports. The host's mapping table, which the record announced, and the /// start of the sandbox follow in the adapter's order, as after a cold boot. Ready, diff --git a/nvx-rs/src/guest/activation/state/apply.rs b/nvx-rs/src/guest/activation/state/apply.rs new file mode 100644 index 000000000..8cff2d356 --- /dev/null +++ b/nvx-rs/src/guest/activation/state/apply.rs @@ -0,0 +1,576 @@ +//! Application of an accepted activation record, without starting a process. +//! +//! This is unstable internal reference code of the guest's space. The root owns the VM-wide +//! view's binds; the caller owns ingress and the admission of its workloads. + +#[cfg(test)] +mod tests; + +use std::fmt; +use std::io; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +#[cfg(doc)] +use super::super::Devices; +#[cfg(doc)] +use super::Activation; +use crate::activation::{self, ActivationRecord, Configuration, Publication}; +use crate::gpt::Expected; +use crate::guest::mappings::{self, Access, SourceRoot}; +use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; +use crate::guest::view::{self, MachineId}; + +pub use crate::guest::view::{MACHINE_ID_FILE, RESOLVER_FILE}; + +/// The root inputs captured by a template, shared by its clones. +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RootTemplate { + /// The runtime directory beneath which the root and its view files are assembled. + pub runtime: PathBuf, + /// The distro device, whose placeholder a clone replaces with its image. + pub distro: Device, + /// The scratch device, unused by a record with a RAM upper. + pub scratch: Device, + /// How long assembly waits for each device, never past the activation's deadline. + pub timeout: Duration, + /// The virtio-fs tag of the host filesystem device, as [`Devices::filesystem_tag`] names it. + pub filesystem_tag: String, + /// The template NIC's gateway, returned for the caller's ingress setup. + pub gateway: Option, +} + +/// The applied root and VM-wide view, with the settings the caller needs for ingress. +/// +/// The application starts no process. The caller decides when to admit workloads and, for +/// published listeners, starts and supervises ingress itself. The root owns every view bind, +/// including one made before a later failure: its teardown removes them before its switch, +/// and nothing unmounts them afterwards. +#[doc(hidden)] +#[derive(Debug)] +pub struct Applied { + /// The export descriptor, dropped before the root unless the caller takes it. + export: Option, + /// The root, which also owns the view's binds. + root: Root, + /// The VM-wide hostname taken from the record. + hostname: Vec, + /// The machine ID that the view applied. + machine_id: MachineId, + /// The resolver file, when the record names a DNS server. + resolver: Option, + /// The proxy endpoint, which the caller tells its workloads. + proxy: Option, + /// Whether the record publishes listeners. + publication: Publication, + /// The template NIC's gateway, if any. + gateway: Option, +} + +impl Applied { + /// Returns the assembled root. + pub fn root(&self) -> &Root { + &self.root + } + + /// Returns the assembled root to mount runtime filesystems, shares, and mappings. + pub fn root_mut(&mut self) -> &mut Root { + &mut self.root + } + + /// Returns the VM-wide hostname taken from the record. + pub fn hostname(&self) -> &[u8] { + &self.hostname + } + + /// Returns the machine ID that the root's view applied. + pub fn machine_id(&self) -> &MachineId { + &self.machine_id + } + + /// Returns the runtime resolver file, or `None` when the image's resolver remains in use. + pub fn resolver(&self) -> Option<&Path> { + self.resolver.as_deref() + } + + /// Returns the proxy endpoint for the caller to tell its workloads. + pub fn proxy(&self) -> Option<&str> { + self.proxy.as_deref() + } + + /// Returns the record's publication, or unpublished when it has no network. + pub fn publication(&self) -> Publication { + self.publication + } + + /// Returns the template NIC's gateway for the caller's ingress setup. + pub fn gateway(&self) -> Option { + self.gateway + } + + /// Takes the export's root, whose descriptor must close before the root's teardown. + pub fn take_export(&mut self) -> Option { + self.export.take() + } + + /// Closes the export descriptor, then tears the root down, including its view binds. + /// + /// As [`Root::teardown`] specifies, a switched root refuses teardown and unmounts nothing. + pub fn teardown(self) -> Result<(), AppliedTeardownError> { + teardown_with(&mut LiveSystem, self.export, self.root) + } + + /// Returns the root and its machine ID, closing an export descriptor not already taken. + /// + /// The root retains the view's binds. No separate owner can unmount them after its switch. + pub fn into_parts(self) -> (Root, MachineId) { + drop(self.export); + (self.root, self.machine_id) + } +} + +/// Why an applied root's teardown failed. +/// +/// View-bind failures now belong to the root's teardown. The separate bind-failure accessor +/// remains for compatibility with callers of the earlier application interface. +#[doc(hidden)] +#[derive(Debug)] +pub struct AppliedTeardownError { + /// A legacy failure of independently owned runtime binds. + binds: Option, + /// The root's teardown failure, including its view binds. + root: Option, +} + +impl AppliedTeardownError { + /// Returns a legacy independently owned bind failure; new teardowns report through `root`. + pub fn binds(&self) -> Option<&mappings::TeardownError> { + self.binds.as_ref() + } + + /// Returns the root's teardown failure, including its view binds. + pub fn root(&self) -> Option<&TeardownError> { + self.root.as_ref() + } +} + +impl fmt::Display for AppliedTeardownError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.binds, &self.root) { + (Some(binds), Some(root)) => write!(formatter, "{binds}; {root}"), + (Some(binds), None) => binds.fmt(formatter), + (None, Some(root)) => root.fmt(formatter), + (None, None) => Ok(()), + } + } +} + +impl std::error::Error for AppliedTeardownError {} + +/// Applies the record accepted by a clone's [`Activation`], without starting any process. +/// +/// Rereads a GPT image's partition table, assembles a fresh root, mounts the optional export, +/// and applies the record's VM-wide view through [`view::apply`], with the record's DNS server. +/// Device waits end by `deadline`, if supplied. The root owns all view binds, and application +/// failure closes the export and tears the root down, reporting any teardown failure. +/// +/// The result reports the record's publication and the template's gateway. Published listeners +/// require a gateway, but the caller, not this application, starts and supervises ingress. +/// Each execution supplies its own identity; application reads no account database. +#[doc(hidden)] +pub fn apply( + record: &ActivationRecord, + template: &RootTemplate, + deadline: Option, +) -> Result { + let activated = apply_with(&mut LiveSystem, record, template, deadline)?; + Ok(Applied { + export: activated.export, + root: activated.root, + hostname: activated.hostname, + machine_id: activated.machine_id, + resolver: activated.resolver, + proxy: activated.proxy, + publication: activated.publication, + gateway: activated.gateway, + }) +} + +/// Why applying an activation record failed. +#[doc(hidden)] +#[derive(Debug)] +#[non_exhaustive] +pub enum ApplyError { + /// The distro device did not appear, or its GPT partition table could not be reread. + Gpt(root::Error), + /// Assembly failed, including a refusal of a non-fresh upper. + Assembly(AssemblyError), + /// A legacy separately generated machine ID could not be written. + MachineId { + /// Why generation failed. + error: io::Error, + /// Any root teardown failure. + teardown: Option, + }, + /// The VM-wide view could not be prepared or its machine ID applied. + View { + /// The failure reported by [`view::apply`]. + error: crate::Error, + /// Any root teardown failure, including view binds left mounted. + teardown: Option, + }, + /// The host export could not be mounted. + Export { + /// The mount failure. + error: root::Error, + /// Any root teardown failure. + teardown: Option, + }, + /// The record's network settings could not be applied. + Network { + /// Why the settings were refused. + error: NetworkError, + /// Any root teardown failure. + teardown: Option, + }, +} + +impl fmt::Display for ApplyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let teardown = match self { + Self::Gpt(error) => { + return write!( + formatter, + "the reread of the GPT disk's partition table failed: {error}" + ); + } + Self::Assembly(error) => { + return write!(formatter, "the root's assembly failed: {error}"); + } + Self::MachineId { error, teardown } => { + write!( + formatter, + "the workload's machine ID cannot be generated: {error}" + )?; + teardown + } + Self::View { error, teardown } => { + write!(formatter, "the workload's view cannot be set: {error}")?; + teardown + } + Self::Export { error, teardown } => { + write!(formatter, "the host export cannot be mounted: {error}")?; + teardown + } + Self::Network { error, teardown } => { + write!(formatter, "the network settings cannot be applied: {error}")?; + teardown + } + }; + match teardown { + Some(teardown) => write!(formatter, "; then {teardown}"), + None => Ok(()), + } + } +} + +impl std::error::Error for ApplyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Gpt(error) | Self::Export { error, .. } => Some(error), + Self::Assembly(error) => Some(error), + Self::View { error, .. } => Some(error), + Self::MachineId { error, .. } => Some(error), + Self::Network { error, .. } => Some(error), + } + } +} + +/// Why an activation's network settings could not be applied. +#[doc(hidden)] +#[derive(Debug)] +#[non_exhaustive] +pub enum NetworkError { + /// The resolver file could not be written. + Write(io::Error), + /// The resolver bind retained by the root failed. + Bind(crate::Error), + /// The record publishes listeners but the template names no IPv4 gateway. + Gateway, +} + +impl fmt::Display for NetworkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write(error) => write!(formatter, "the resolver file cannot be written: {error}"), + Self::Bind(error) => error.fmt(formatter), + Self::Gateway => formatter.write_str( + "the network publishes the workload's listeners, but the template names no IPv4 \ + gateway to announce them to", + ), + } + } +} + +impl std::error::Error for NetworkError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Write(error) => Some(error), + Self::Bind(error) => error.source(), + Self::Gateway => None, + } + } +} + +/// The assembly, export, and VM-wide view operations used by application. +trait System { + /// The assembled root, which owns its view binds. + type Root; + /// The export descriptor. + type Export; + + /// Rereads the distro device's partition table by the supplied deadline. + fn reread( + &mut self, + distro: Device, + timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error>; + /// Assembles a fresh root, bounding device waits by the supplied deadline. + fn assemble( + &mut self, + assembly: &Assembly, + deadline: Option, + ) -> Result; + /// Mounts an export and opens its root. + fn mount_export( + &mut self, + root: &mut Self::Root, + tag: &str, + access: Access, + ) -> Result; + /// Closes the export descriptor before its root unmounts. + fn close(&mut self, export: Self::Export); + /// Applies the VM-wide view and DNS server, with binds retained by the root. + fn view( + &mut self, + root: &mut Self::Root, + view: &activation::View, + dns: Option, + ) -> Result; + /// Tears the root down, including its view binds. + fn teardown(&mut self, root: Self::Root) -> Result<(), TeardownError>; +} + +/// The guest kernel's operations. +struct LiveSystem; + +impl System for LiveSystem { + type Root = Root; + type Export = SourceRoot; + + fn reread( + &mut self, + distro: Device, + timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error> { + root::reread_partitions(distro, timeout, deadline) + } + + fn assemble( + &mut self, + assembly: &Assembly, + deadline: Option, + ) -> Result { + root::assemble_fresh(assembly, deadline) + } + + fn mount_export( + &mut self, + root: &mut Root, + tag: &str, + access: Access, + ) -> Result { + root.mount_export(tag, access) + } + + fn close(&mut self, export: SourceRoot) { + drop(export); + } + + fn view( + &mut self, + root: &mut Root, + view: &activation::View, + dns: Option, + ) -> Result { + view::apply_staged(root, view, dns) + } + + fn teardown(&mut self, root: Root) -> Result<(), TeardownError> { + root.teardown() + } +} + +/// The result of applying a record through the system seam. +#[derive(Debug)] +struct Activated { + /// The root, retaining its view binds. + root: R, + /// The export descriptor, if any. + export: Option, + /// The record's VM-wide hostname. + hostname: Vec, + /// The machine ID applied by the view. + machine_id: MachineId, + /// The resolver file, if DNS was supplied. + resolver: Option, + /// The record's proxy endpoint. + proxy: Option, + /// The record's publication, or unpublished without a network. + publication: Publication, + /// The template's gateway. + gateway: Option, +} + +/// The result through a particular system implementation. +type ActivatedBy = Activated<::Root, ::Export>; + +/// Applies an accepted record through the system seam. +fn apply_with( + system: &mut S, + record: &ActivationRecord, + template: &RootTemplate, + deadline: Option, +) -> Result, ApplyError> { + let configuration = record.configuration(); + let assembly = assembly(template, configuration); + if let root::Image::Gpt { .. } = assembly.image { + system + .reread(assembly.distro, assembly.timeout, deadline) + .map_err(ApplyError::Gpt)?; + } + let mut root = system + .assemble(&assembly, deadline) + .map_err(ApplyError::Assembly)?; + let export = match configuration.export { + Some(_) => { + match system.mount_export(&mut root, &template.filesystem_tag, Access::ReadWrite) { + Ok(export) => Some(export), + Err(error) => { + let teardown = system.teardown(root).err(); + return Err(ApplyError::Export { error, teardown }); + } + } + } + None => None, + }; + let network = configuration.network.as_ref(); + let publication = network.map_or(Publication::Unpublished, |network| network.publication); + if publication == Publication::Published && template.gateway.is_none() { + let teardown = discard(system, export, root); + return Err(ApplyError::Network { + error: NetworkError::Gateway, + teardown, + }); + } + let dns = network.and_then(|network| network.dns); + let machine_id = match system.view(&mut root, &configuration.view, dns) { + Ok(machine_id) => machine_id, + Err(error) => { + let teardown = discard(system, export, root); + return Err(view_failure(error, teardown)); + } + }; + Ok(Activated { + root, + export, + hostname: configuration.view.hostname.clone(), + machine_id, + resolver: dns.map(|_| template.runtime.join(RESOLVER_FILE)), + proxy: network.and_then(|network| network.proxy.clone()), + publication, + gateway: template.gateway, + }) +} + +/// Preserves the network classification and original cause of resolver failures. +fn view_failure(failure: view::ApplyFailure, teardown: Option) -> ApplyError { + let error = match failure { + view::ApplyFailure::View(error) => return ApplyError::View { error, teardown }, + view::ApplyFailure::ResolverWrite(error) => NetworkError::Write(error), + view::ApplyFailure::ResolverBind(error) => NetworkError::Bind(error), + }; + ApplyError::Network { error, teardown } +} + +/// Closes an export descriptor, then tears down its root, returning any teardown failure. +fn discard( + system: &mut S, + export: Option, + root: S::Root, +) -> Option { + if let Some(export) = export { + system.close(export); + } + system.teardown(root).err() +} + +/// Tears down an application through the seam, with bind cleanup owned solely by its root. +fn teardown_with( + system: &mut S, + export: Option, + root: S::Root, +) -> Result<(), AppliedTeardownError> { + match discard(system, export, root) { + None => Ok(()), + Some(root) => Err(AppliedTeardownError { + binds: None, + root: Some(root), + }), + } +} + +/// Combines the record's root settings with the template's devices and runtime directory. +fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly { + let image = match configuration.image { + activation::Image::Erofs { uuid } => root::Image::Erofs { uuid }, + activation::Image::Gpt { + disk_guid, + first_partition_bound, + } => root::Image::Gpt { + expected: match disk_guid { + Some(guid) => Expected::new().disk_guid(guid), + None => Expected::new(), + }, + first_partition_bound: usize::try_from(first_partition_bound).unwrap_or(usize::MAX), + }, + }; + let upper = match configuration.upper { + activation::Upper::Tmpfs { max_memory_percent } => root::Upper::Tmpfs { + max_memory_percent: Some(max_memory_percent), + }, + activation::Upper::Scratch { journal } => root::Upper::Scratch { + device: template.scratch, + journal: match journal { + activation::Journal::Allowed => root::Journal::Allowed, + activation::Journal::Forbidden => root::Journal::Forbidden, + }, + }, + }; + let overlay = match configuration.overlay { + activation::OverlayMode::TrustedLowers => root::OverlayMode::TrustedLowers, + activation::OverlayMode::UntrustedLowers => root::OverlayMode::UntrustedLowers, + }; + Assembly { + runtime: template.runtime.clone(), + distro: template.distro, + image, + upper, + overlay, + timeout: template.timeout, + } +} diff --git a/nvx-rs/src/guest/activation/state/apply/tests.rs b/nvx-rs/src/guest/activation/state/apply/tests.rs new file mode 100644 index 000000000..149435110 --- /dev/null +++ b/nvx-rs/src/guest/activation/state/apply/tests.rs @@ -0,0 +1,873 @@ +//! Tests of activation application through an assembly, export, and VM-wide view seam. +//! The seam has no process or admission operation. + +use std::error::Error as _; + +use super::*; +use crate::activation::{ + ExpectedResources, Export, ExportKind, MachineIdMode, Network, RestoreSection, TransactionId, + View, +}; +use crate::guest::activation::{ + Activation, ActivationAction, ActivationCause, ActivationInput, ActivationState, +}; +use crate::guest::sys::Errno; +use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; +use crate::uuid::Uuid; + +/// The test template's runtime directory. +const RUNTIME: &str = "/run/nvx"; +/// The test template's filesystem tag. +const TAG: &str = "microvm"; +/// The test template's gateway. +const GATEWAY: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +/// A DNS server deliberately different from the gateway. +const DNS: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53)); +/// The test records' proxy endpoint. +const PROXY: &str = "http://10.0.0.1:3128"; +/// The test records' hostname. +const HOSTNAME: &[u8] = b"clone"; +/// The resources reported by the repaired clone. +const RESOURCES: ExpectedResources = ExpectedResources { + cpus: 2, + memory: 1 << 30, +}; +/// The tests' transaction. +const TRANSACTION: TransactionId = TransactionId::from_bytes([0x22; TransactionId::LEN]); +/// The expected EROFS UUID. +const IMAGE_UUID: Uuid = Uuid::from_bytes([0x5a; 16]); +/// The expected GPT disk GUID. +const DISK_GUID: Uuid = Uuid::from_bytes([0xd1; 16]); + +/// Every system operation the application can perform. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Effect { + /// Reread this distro device's partition table. + Reread(Device), + /// Assemble this root with a fresh upper. + Assemble(Assembly), + /// Mount the export through this tag with this access. + MountExport(String, Access), + /// Apply this exact VM-wide view and DNS server. + View(View, Option), + /// Close the export descriptor. + Close, + /// Tear down the root, including its view binds. + Teardown, +} + +/// Records all calls and returns a temporary directory in place of a mounted root. +struct FakeSystem { + /// The assembled root's directory. + image: tempfile::TempDir, + /// Whether assembly fails. + failing: bool, + /// Whether rereading the partition table fails. + refusing: bool, + /// Whether mounting the export fails. + unmountable: bool, + /// Whether applying the VM-wide view fails. + unviewable: bool, + /// All system calls, in order. + effects: Vec, + /// The deadline passed to each device wait. + deadlines: Vec>, +} + +impl FakeSystem { + /// Returns an initially successful system. + fn new() -> Self { + Self { + image: tempfile::tempdir().unwrap(), + failing: false, + refusing: false, + unmountable: false, + unviewable: false, + effects: Vec::new(), + deadlines: Vec::new(), + } + } + + /// Returns the assembled root's path. + fn root(&self) -> PathBuf { + self.image.path().to_owned() + } +} + +impl System for FakeSystem { + type Root = PathBuf; + type Export = (); + + fn reread( + &mut self, + distro: Device, + _timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error> { + self.effects.push(Effect::Reread(distro)); + self.deadlines.push(deadline); + if self.refusing { + return Err(root::Error::Device { + role: root::Role::Distro, + step: root::DeviceStep::Reread, + device: "/dev/vda".into(), + error: Errno::EBUSY.into(), + }); + } + Ok(()) + } + + fn assemble( + &mut self, + assembly: &Assembly, + deadline: Option, + ) -> Result { + self.effects.push(Effect::Assemble(assembly.clone())); + self.deadlines.push(deadline); + if self.failing { + return Err(root::assemble(&Assembly { + runtime: "run/nvx".into(), + ..assembly.clone() + }) + .unwrap_err()); + } + Ok(self.root()) + } + + fn mount_export( + &mut self, + root: &mut PathBuf, + tag: &str, + access: Access, + ) -> Result<(), root::Error> { + assert_eq!(*root, self.root()); + self.effects + .push(Effect::MountExport(tag.to_owned(), access)); + if self.unmountable { + return Err(root::Error::Mount { + mount: root::Mount::Export { + tag: tag.to_owned(), + }, + error: Errno::ENOENT.into(), + }); + } + Ok(()) + } + + fn close(&mut self, (): ()) { + self.effects.push(Effect::Close); + } + + fn view( + &mut self, + root: &mut PathBuf, + view: &View, + dns: Option, + ) -> Result { + assert_eq!(*root, self.root()); + self.effects.push(Effect::View(view.clone(), dns)); + if self.unviewable { + return Err(crate::Error::io("the VM view failed") + .with_source(io::Error::from(Errno::EACCES)) + .into()); + } + Ok(match view.machine_id { + MachineIdMode::Image => MachineId::Image, + MachineIdMode::Generated => { + MachineId::Generated(Path::new(RUNTIME).join(MACHINE_ID_FILE)) + } + }) + } + + fn teardown(&mut self, root: PathBuf) -> Result<(), TeardownError> { + assert_eq!(root, self.root()); + self.effects.push(Effect::Teardown); + Ok(()) + } +} + +/// Returns the template's devices, runtime, deadline, and gateway. +fn template() -> RootTemplate { + RootTemplate { + runtime: RUNTIME.into(), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: Some(1 << 21), + }, + scratch: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: Some(1 << 22), + }, + timeout: Duration::from_secs(5), + filesystem_tag: TAG.to_owned(), + gateway: Some(GATEWAY), + } +} + +/// Returns the tests' basic configuration, without an export or network. +fn configuration() -> Configuration { + Configuration { + image: activation::Image::Erofs { + uuid: Some(IMAGE_UUID), + }, + upper: activation::Upper::Tmpfs { + max_memory_percent: 50, + }, + overlay: activation::OverlayMode::UntrustedLowers, + export: None, + mappings: 0, + network: None, + view: View { + hostname: HOSTNAME.to_vec(), + machine_id: MachineIdMode::Image, + }, + resources: RESOURCES, + } +} + +/// Returns a clone's activation record. +fn clone_record(configuration: Configuration) -> ActivationRecord { + ActivationRecord::new( + Some(RestoreSection { + transaction: TRANSACTION, + }), + configuration, + ) + .unwrap() +} + +/// Returns the tests' basic accepted record. +fn accepted() -> ActivationRecord { + clone_record(configuration()) +} + +/// Returns a record with every optional application input. +fn with_everything() -> ActivationRecord { + let mut configuration = configuration(); + configuration.view.machine_id = MachineIdMode::Generated; + configuration.export = Some(Export { + kind: ExportKind::Aggregate, + }); + configuration.mappings = 3; + configuration.network = Some(Network { + dns: Some(DNS), + publication: Publication::Published, + proxy: Some(PROXY.to_owned()), + }); + clone_record(configuration) +} + +/// Returns the expected assembly of the basic accepted record. +fn accepted_assembly() -> Assembly { + Assembly { + runtime: RUNTIME.into(), + distro: template().distro, + image: root::Image::Erofs { + uuid: Some(IMAGE_UUID), + }, + upper: root::Upper::Tmpfs { + max_memory_percent: Some(50), + }, + overlay: root::OverlayMode::UntrustedLowers, + timeout: template().timeout, + } +} + +/// Returns an activation whose transport is restricted and ready for one Activate. +fn restricted(now: Instant) -> Activation { + let mut activation = + Activation::with_resources(RESOURCES, Some(now), Duration::from_secs(10), now); + assert_eq!( + activation.input(now, ActivationInput::Reset).unwrap(), + ActivationAction::Acknowledge, + ); + activation +} + +#[test] +fn no_mount_spawn_or_id_generation_happens_before_the_gate() { + let now = Instant::now(); + let bytes = with_everything().encode(); + let cold = ActivationRecord::new(None, configuration()) + .unwrap() + .encode(); + let mut wrong = configuration(); + wrong.resources.cpus += 1; + let wrong = clone_record(wrong).encode(); + for offered in [&bytes[..bytes.len() - 1], &cold, &wrong] { + let mut activation = restricted(now); + assert!( + activation + .input(now, ActivationInput::Activate(offered)) + .is_err() + ); + assert_eq!(activation.state(), ActivationState::Failed); + } + let mut activation = restricted(now); + assert!(matches!( + activation + .input( + now + Duration::from_secs(10), + ActivationInput::Activate(&bytes) + ) + .unwrap_err() + .cause(), + ActivationCause::Expired, + )); +} + +#[test] +fn a_valid_activate_activates_the_root_and_the_workload_once_before_ready() { + let now = Instant::now(); + let record = with_everything(); + let bytes = record.encode(); + let mut activation = restricted(now); + let ActivationAction::Apply(accepted) = activation + .input(now, ActivationInput::Activate(&bytes)) + .unwrap() + else { + panic!("a valid record was not accepted") + }; + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &accepted, &template(), activation.deadline()).unwrap(); + let expected = vec![ + Effect::Assemble(accepted_assembly()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::View(record.configuration().view.clone(), Some(DNS)), + ]; + assert_eq!(system.effects, expected); + assert_eq!( + applied.machine_id, + MachineId::Generated(Path::new(RUNTIME).join(MACHINE_ID_FILE)) + ); + assert_eq!( + applied.resolver, + Some(Path::new(RUNTIME).join(RESOLVER_FILE)) + ); + assert_eq!(applied.proxy.as_deref(), Some(PROXY)); + assert_eq!(applied.publication, Publication::Published); + assert_eq!(applied.gateway, Some(GATEWAY)); + let ready = activation.complete(now, Ok(())).unwrap(); + assert_eq!(ready, crate::activation::ReadyActivation::of(&record)); + for _ in 0..2 { + assert_eq!( + activation.input(now, ActivationInput::Reset).unwrap(), + ActivationAction::Acknowledge + ); + assert_eq!( + activation + .input(now, ActivationInput::Activate(&bytes)) + .unwrap(), + ActivationAction::Ready(ready) + ); + } + assert_eq!(system.effects, expected); + assert_eq!( + activation.input(now, ActivationInput::Request).unwrap(), + ActivationAction::Serve + ); +} + +#[test] +fn assembly_inputs_are_taken_exactly_from_the_record() { + let images = [ + ( + activation::Image::Erofs { uuid: None }, + root::Image::Erofs { uuid: None }, + false, + ), + ( + activation::Image::Erofs { + uuid: Some(IMAGE_UUID), + }, + root::Image::Erofs { + uuid: Some(IMAGE_UUID), + }, + false, + ), + ( + activation::Image::Gpt { + disk_guid: None, + first_partition_bound: 4096, + }, + root::Image::Gpt { + expected: Expected::new(), + first_partition_bound: 4096, + }, + true, + ), + ( + activation::Image::Gpt { + disk_guid: Some(DISK_GUID), + first_partition_bound: 4096, + }, + root::Image::Gpt { + expected: Expected::new().disk_guid(DISK_GUID), + first_partition_bound: 4096, + }, + true, + ), + ]; + let uppers = [ + ( + activation::Upper::Tmpfs { + max_memory_percent: 1, + }, + root::Upper::Tmpfs { + max_memory_percent: Some(1), + }, + ), + ( + activation::Upper::Tmpfs { + max_memory_percent: 90, + }, + root::Upper::Tmpfs { + max_memory_percent: Some(90), + }, + ), + ( + activation::Upper::Scratch { + journal: activation::Journal::Allowed, + }, + root::Upper::Scratch { + device: template().scratch, + journal: root::Journal::Allowed, + }, + ), + ( + activation::Upper::Scratch { + journal: activation::Journal::Forbidden, + }, + root::Upper::Scratch { + device: template().scratch, + journal: root::Journal::Forbidden, + }, + ), + ]; + let overlays = [ + ( + activation::OverlayMode::TrustedLowers, + root::OverlayMode::TrustedLowers, + ), + ( + activation::OverlayMode::UntrustedLowers, + root::OverlayMode::UntrustedLowers, + ), + ]; + for (image, expected_image, reread) in images { + for (upper, expected_upper) in uppers { + for (overlay, expected_overlay) in overlays { + let record = clone_record(Configuration { + image, + upper, + overlay, + ..configuration() + }); + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &record, &template(), None).unwrap(); + let expected = Assembly { + image: expected_image.clone(), + upper: expected_upper, + overlay: expected_overlay, + ..accepted_assembly() + }; + let mut effects = Vec::new(); + if reread { + effects.push(Effect::Reread(template().distro)); + } + effects.push(Effect::Assemble(expected)); + effects.push(Effect::View(record.configuration().view.clone(), None)); + assert_eq!(system.effects, effects, "{record:?}"); + assert_eq!(applied.root, system.root()); + } + } + } + let first = with_everything(); + let mut other = first.configuration().clone(); + other.export = Some(Export { + kind: ExportKind::Directory, + }); + other.mappings = 7; + other.resources = ExpectedResources { + cpus: 8, + memory: 1 << 33, + }; + let other = ActivationRecord::new(None, other).unwrap(); + let (mut first_system, mut other_system) = (FakeSystem::new(), FakeSystem::new()); + apply_with(&mut first_system, &first, &template(), None).unwrap(); + apply_with(&mut other_system, &other, &template(), None).unwrap(); + assert_eq!( + first_system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::View(first.configuration().view.clone(), Some(DNS)), + ] + ); + assert_eq!(first_system.effects, other_system.effects); +} + +#[test] +fn a_gpt_disk_s_partition_table_is_reread_before_its_root_is_assembled() { + let record = clone_record(Configuration { + image: activation::Image::Gpt { + disk_guid: Some(DISK_GUID), + first_partition_bound: 4096, + }, + ..configuration() + }); + let deadline = Some(Instant::now() + Duration::from_secs(10)); + let mut system = FakeSystem::new(); + apply_with(&mut system, &record, &template(), deadline).unwrap(); + assert!(matches!( + system.effects.as_slice(), + [Effect::Reread(_), Effect::Assemble(_), Effect::View(..)] + )); + assert_eq!(system.deadlines, [deadline, deadline]); + let mut refusing = FakeSystem { + refusing: true, + ..FakeSystem::new() + }; + let error = apply_with(&mut refusing, &record, &template(), deadline).unwrap_err(); + assert!(matches!( + error, + ApplyError::Gpt(root::Error::Device { + step: root::DeviceStep::Reread, + .. + }) + )); + assert_eq!(refusing.effects, [Effect::Reread(template().distro)]); +} + +#[test] +fn the_application_leaves_the_images_accounts_to_each_execution() { + for (passwd, group) in [ + (None, None), + (Some(&b"malformed\n"[..]), Some(&b"malformed\n"[..])), + ( + Some(&b"root:x:0:0::/root:/bin/sh\n"[..]), + Some(&b"root:x:0:\n"[..]), + ), + ] { + let mut system = FakeSystem::new(); + let etc = system.image.path().join("etc"); + std::fs::create_dir(&etc).unwrap(); + if let Some(passwd) = passwd { + std::fs::write(etc.join("passwd"), passwd).unwrap(); + } + if let Some(group) = group { + std::fs::write(etc.join("group"), group).unwrap(); + } + apply_with(&mut system, &accepted(), &template(), None).unwrap(); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::View(configuration().view, None), + ] + ); + } +} + +#[test] +fn a_failed_assembly_is_final_and_reads_and_tears_down_nothing() { + let now = Instant::now(); + let mut activation = restricted(now); + let bytes = accepted().encode(); + activation + .input(now, ActivationInput::Activate(&bytes)) + .unwrap(); + let mut system = FakeSystem { + failing: true, + ..FakeSystem::new() + }; + let error = apply_with(&mut system, &accepted(), &template(), None).unwrap_err(); + assert!(matches!(&error, ApplyError::Assembly(error) if matches!( + error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx") + ))); + let failure = activation.complete(now, Err(error.into())).unwrap_err(); + assert!(matches!(failure.cause(), ActivationCause::Failed(_))); + assert!( + activation + .input(now, ActivationInput::Activate(&bytes)) + .is_err() + ); + assert_eq!(system.effects, [Effect::Assemble(accepted_assembly())]); +} + +#[test] +fn the_record_s_view_is_applied_once_with_its_own_dns_and_returned_machine_id() { + let ipv6 = IpAddr::V6("fd00::53".parse().unwrap()); + for mode in [MachineIdMode::Image, MachineIdMode::Generated] { + for dns in [None, Some(DNS), Some(ipv6)] { + let mut configuration = configuration(); + configuration.view.machine_id = mode; + configuration.network = Some(Network { + dns, + publication: Publication::Unpublished, + proxy: Some(PROXY.to_owned()), + }); + let record = clone_record(configuration.clone()); + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &record, &template(), None).unwrap(); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::View(configuration.view, dns), + ] + ); + assert_eq!( + applied.machine_id, + match mode { + MachineIdMode::Image => MachineId::Image, + MachineIdMode::Generated => + MachineId::Generated(Path::new(RUNTIME).join(MACHINE_ID_FILE)), + } + ); + assert_eq!( + applied.resolver, + dns.map(|_| Path::new(RUNTIME).join(RESOLVER_FILE)) + ); + assert_eq!(applied.proxy.as_deref(), Some(PROXY)); + } + } +} + +#[test] +fn an_export_is_mounted_through_the_template_s_device_and_handed_to_the_adapter() { + for kind in [ExportKind::Directory, ExportKind::Aggregate] { + let record = clone_record(Configuration { + export: Some(Export { kind }), + mappings: 2, + ..configuration() + }); + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &record, &template(), None).unwrap(); + assert_eq!(applied.export, Some(())); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::View(configuration().view, None), + ] + ); + } + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &accepted(), &template(), None).unwrap(); + assert_eq!(applied.export, None); +} + +#[test] +fn an_export_or_a_view_that_fails_closes_owned_resources_before_root_teardown() { + for export in [false, true] { + let record = if export { + with_everything() + } else { + accepted() + }; + let mut system = FakeSystem { + unviewable: true, + ..FakeSystem::new() + }; + let error = apply_with(&mut system, &record, &template(), None).unwrap_err(); + let ApplyError::View { + error, + teardown: None, + } = error + else { + panic!("{error}") + }; + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .raw_os_error(), + Some(Errno::EACCES as i32) + ); + let tail = if export { + vec![Effect::Close, Effect::Teardown] + } else { + vec![Effect::Teardown] + }; + assert!(system.effects.ends_with(&tail)); + assert_eq!( + system + .effects + .iter() + .filter(|effect| matches!(effect, Effect::View(..))) + .count(), + 1 + ); + } + let mut system = FakeSystem { + unmountable: true, + ..FakeSystem::new() + }; + let error = apply_with(&mut system, &with_everything(), &template(), None).unwrap_err(); + assert!(matches!(error, ApplyError::Export { teardown: None, .. })); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::Teardown, + ] + ); +} + +#[test] +fn teardown_closes_the_export_before_the_root_removes_its_own_binds() { + for export in [None, Some(())] { + let mut system = FakeSystem::new(); + let root = system.root(); + teardown_with(&mut system, export, root).unwrap(); + let expected = if export.is_some() { + vec![Effect::Close, Effect::Teardown] + } else { + vec![Effect::Teardown] + }; + assert_eq!(system.effects, expected); + } +} + +#[test] +fn publication_and_gateway_are_reported_without_starting_or_stopping_a_process() { + for publication in [Publication::Unpublished, Publication::Published] { + let record = clone_record(Configuration { + network: Some(Network { + dns: Some(DNS), + publication, + proxy: None, + }), + ..configuration() + }); + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &record, &template(), None).unwrap(); + assert_eq!(applied.publication, publication); + assert_eq!(applied.gateway, Some(GATEWAY)); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::View(configuration().view, Some(DNS)), + ] + ); + teardown_with(&mut system, applied.export, applied.root).unwrap(); + assert_eq!(system.effects.last(), Some(&Effect::Teardown)); + } + let gatewayless = RootTemplate { + gateway: None, + ..template() + }; + let mut system = FakeSystem::new(); + let error = apply_with(&mut system, &with_everything(), &gatewayless, None).unwrap_err(); + assert!(matches!( + error, + ApplyError::Network { + error: NetworkError::Gateway, + teardown: None + } + )); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::Close, + Effect::Teardown, + ] + ); + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &accepted(), &gatewayless, None).unwrap(); + assert_eq!(applied.publication, Publication::Unpublished); + assert_eq!(applied.gateway, None); +} + +#[test] +fn the_application_refuses_without_privileges_what_it_cannot_assemble() { + let relative = RootTemplate { + runtime: "run/nvx".into(), + ..template() + }; + let Err(ApplyError::Assembly(error)) = apply(&accepted(), &relative, None) else { + panic!("a relative runtime directory was assembled") + }; + assert!(matches!(error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx"))); + let disk = clone_record(Configuration { + image: activation::Image::Gpt { + disk_guid: None, + first_partition_bound: 4096, + }, + ..configuration() + }); + let at_once = RootTemplate { + timeout: Duration::ZERO, + ..relative + }; + let Err(ApplyError::Gpt(error)) = apply(&disk, &at_once, None) else { + panic!("a GPT disk was assembled before its partition table was reread") + }; + assert!(matches!( + error, + root::Error::Missing { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE + } | root::Error::Lookup { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE, + .. + } + )); +} + +#[test] +fn application_errors_name_their_cause() { + let missing = root::Error::Missing { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE, + }; + let text = missing.to_string(); + let error = ApplyError::Gpt(missing); + assert!(error.to_string().ends_with(&text)); + assert_eq!(error.source().unwrap().to_string(), text); + let error = ApplyError::View { + error: crate::Error::unsupported("invalid hostname"), + teardown: None, + }; + assert!(error.to_string().contains("invalid hostname")); + assert_eq!(error.source().unwrap().to_string(), "invalid hostname"); + let error = ApplyError::Network { + error: NetworkError::Gateway, + teardown: None, + }; + assert!(error.to_string().contains("template names no IPv4 gateway")); + assert!(error.source().unwrap().source().is_none()); +} + +impl ApplyError { + /// Classifies an actual view failure for the root's retained-bind regression tests. + pub(crate) fn from_test_view_failure(failure: view::ApplyFailure) -> Self { + view_failure(failure, None) + } +} + +impl Applied { + /// Wraps an assembled root to exercise the ownership transfer in the root's unit tests. + pub(crate) fn from_test_root(root: Root, machine_id: MachineId) -> Self { + Self { + export: None, + root, + hostname: HOSTNAME.to_vec(), + machine_id, + resolver: None, + proxy: None, + publication: Publication::Unpublished, + gateway: None, + } + } +} diff --git a/nvx-rs/src/guest/clone/activation/tests.rs b/nvx-rs/src/guest/activation/state/tests.rs similarity index 82% rename from nvx-rs/src/guest/clone/activation/tests.rs rename to nvx-rs/src/guest/activation/state/tests.rs index aad5dfe1a..648192c6b 100644 --- a/nvx-rs/src/guest/clone/activation/tests.rs +++ b/nvx-rs/src/guest/activation/state/tests.rs @@ -1,12 +1,8 @@ -//! Tests of a clone's activation, with a fake fence that counts how often it opened, records that -//! each test builds with `nvx::activation`, a fake clock, whose instants each test takes after a +//! Tests of a clone's fence-free activation, with records that each test builds with +//! `nvx::activation`, a fake clock, whose instants each test takes after a //! base instant, and a fake adapter of a framing of the tests' own, whose transport delivers the //! host's bytes when and as a test chooses, and whose output takes nothing. -use std::cell::Cell; -use std::io; -use std::rc::Rc; - use super::*; use crate::activation::{ Configuration, Image, MAX_RECORD_BYTES, MachineIdMode, OverlayMode, RestoreSection, Upper, View, @@ -27,38 +23,12 @@ const ACTIVATE: u8 = 1; /// The kind of any other request of the host in the tests' framing. const PING: u8 = 2; -/// Returns the activation, behind a fence, in a state that each such function chooses, that it -/// reached at an instant. -type Setup = fn(&FakeFence, Instant) -> Activation; +/// Returns the activation in a state that it reached at an instant. +type Setup = fn(Instant) -> Activation; /// Returns whether a failure's cause is the one that a test expects. type Expected = fn(&ActivationCause) -> bool; -/// A fence that a restore handed over closed, which counts how often it opened, and which a test -/// shares with the activation that holds it. -#[derive(Clone, Default)] -struct FakeFence { - /// How often the fence opened. - opened: Rc>, -} - -impl FakeFence { - /// Returns how often the fence opened. - fn opened(&self) -> u32 { - self.opened.get() - } -} - -impl AdmissionFence for FakeFence { - fn close(&mut self) -> io::Result<()> { - unreachable!("an activation never closes the fence"); - } - - fn open(&mut self) { - self.opened.set(self.opened.get() + 1); - } -} - /// Returns the instant `seconds` seconds after `base`. fn after(base: Instant, seconds: u64) -> Instant { base + Duration::from_secs(seconds) @@ -119,21 +89,15 @@ fn reported() -> ReadyActivation { ReadyActivation::of(&accepted()) } -/// Returns the activation, in its transport bootstrap, of the clone behind `fence`, whose repair +/// Returns the activation, in its transport bootstrap, of the clone whose repair /// acknowledged its restore at `acknowledged`. -fn bootstrap(fence: &FakeFence, acknowledged: Instant) -> Activation { - Activation::with_resources( - resources(), - Some(acknowledged), - fence.clone(), - TIMEOUT, - acknowledged, - ) +fn bootstrap(acknowledged: Instant) -> Activation { + Activation::with_resources(resources(), Some(acknowledged), TIMEOUT, acknowledged) } /// Returns the activation of [`bootstrap`] once the session's reset at `at` restricted it. -fn restricted(fence: &FakeFence, at: Instant) -> Activation { - let mut activation = bootstrap(fence, at); +fn restricted(at: Instant) -> Activation { + let mut activation = bootstrap(at); let action = activation.input(at, ActivationInput::Reset).unwrap(); assert_eq!(action, ActivationAction::Acknowledge); assert_eq!(activation.state(), ActivationState::Restricted); @@ -141,8 +105,8 @@ fn restricted(fence: &FakeFence, at: Instant) -> Activation { } /// Returns the activation of [`restricted`] once it accepted the host's Activate at `at`. -fn activating(fence: &FakeFence, at: Instant) -> Activation { - let mut activation = restricted(fence, at); +fn activating(at: Instant) -> Activation { + let mut activation = restricted(at); let action = activation .input(at, ActivationInput::Activate(&record())) .unwrap(); @@ -153,31 +117,24 @@ fn activating(fence: &FakeFence, at: Instant) -> Activation { /// Returns the activation of [`activating`] once its application of the record completed at /// `at`. -fn ready(fence: &FakeFence, at: Instant) -> Activation { - let mut activation = activating(fence, at); +fn ready(at: Instant) -> Activation { + let mut activation = activating(at); assert_eq!(activation.complete(at, Ok(())).unwrap(), reported()); assert_eq!(activation.state(), ActivationState::Ready); activation } /// Returns the activation of [`ready`] once the adapter's order started the sandbox. -fn started(fence: &FakeFence, at: Instant) -> Activation { - let mut activation = ready(fence, at); +fn started(at: Instant) -> Activation { + let mut activation = ready(at); activation.start().unwrap(); assert_eq!(activation.state(), ActivationState::Started); activation } -/// Checks that `activation`, behind `fence`, failed finally with `failure` at `now`: it has no -/// deadline and no established activation, every later call returns the same failure, and the -/// fence opened `opened` times, as it had before the failure. -fn assert_final( - activation: &mut Activation, - fence: &FakeFence, - failure: &ActivationFailure, - now: Instant, - opened: u32, -) { +/// Checks that `activation` failed finally with `failure` at `now`: it has no deadline or +/// established activation, and every later call returns the same failure. +fn assert_final(activation: &mut Activation, failure: &ActivationFailure, now: Instant) { assert_ne!(failure.state(), ActivationState::Failed); assert_eq!(activation.state(), ActivationState::Failed); assert_eq!(activation.deadline(), None); @@ -198,14 +155,13 @@ fn assert_final( assert_eq!(later.state(), failure.state()); assert!(Arc::ptr_eq(&later.cause, &failure.cause)); } - assert_eq!(fence.opened(), opened); } #[test] -fn a_clone_bootstraps_takes_one_activate_and_opens_the_fence_once_ready() { +fn a_clone_bootstraps_takes_one_activate_and_becomes_ready_without_a_fence() { let t0 = Instant::now(); - let fence = FakeFence::default(); - let mut activation = bootstrap(&fence, t0); + + let mut activation = bootstrap(t0); assert_eq!(activation.state(), ActivationState::Bootstrap); assert_eq!(activation.deadline(), Some(after(t0, 10))); // Bytes of the session's reset arrive before it completes, and then the reset. @@ -225,15 +181,15 @@ fn a_clone_bootstraps_takes_one_activate_and_opens_the_fence_once_ready() { assert_eq!(activation.state(), ActivationState::Activating); assert_eq!(activation.deadline(), Some(after(t0, 10))); assert_eq!(activation.established(), None); - assert_eq!(fence.opened(), 0); + let flow = activation.input(after(t0, 3), ActivationInput::Flow); assert_eq!(flow.unwrap(), ActivationAction::Continue); - // Once the application succeeded, the fence opens, and Ready reports the activation, with the + // Once the application succeeded, Ready reports the activation, with the // record's transaction. let ready = activation.complete(after(t0, 4), Ok(())).unwrap(); assert_eq!(ready, reported()); assert_eq!(ready.transaction, Some(TRANSACTION)); - assert_eq!(fence.opened(), 1); + assert_eq!(activation.state(), ActivationState::Ready); assert_eq!(activation.established(), Some(reported())); // No deadline holds a ready clone: the host's mapping table and its start go to the adapter's @@ -249,7 +205,6 @@ fn a_clone_bootstraps_takes_one_activate_and_opens_the_fence_once_ready() { assert_eq!(request.unwrap(), ActivationAction::Serve); let flow = activation.input(after(t0, 7200), ActivationInput::Flow); assert_eq!(flow.unwrap(), ActivationAction::Continue); - assert_eq!(fence.opened(), 1); } #[test] @@ -265,13 +220,12 @@ fn no_request_is_accepted_before_activate() { (activating, ActivationInput::Request), ]; for (activation, input) in cases { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let mut activation = activation(t0); let state = activation.state(); let failure = activation.input(after(t0, 1), input).unwrap_err(); assert_eq!(failure.state(), state, "{input:?}"); assert!(matches!(failure.cause(), ActivationCause::Request)); - assert_final(&mut activation, &fence, &failure, after(t0, 2), 0); + assert_final(&mut activation, &failure, after(t0, 2)); } } @@ -281,21 +235,19 @@ fn a_reset_before_ready_is_final_and_one_after_it_starts_a_later_session() { // Only the reset that the restore queued bootstraps the session: another, such as once the // host's attachment ended, fails the activation before it is ready. for activation in [restricted, activating] { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let mut activation = activation(t0); let state = activation.state(); let failure = activation .input(after(t0, 1), ActivationInput::Reset) .unwrap_err(); assert_eq!(failure.state(), state); assert!(matches!(failure.cause(), ActivationCause::Reset)); - assert_final(&mut activation, &fence, &failure, after(t0, 1), 0); + assert_final(&mut activation, &failure, after(t0, 1)); } // Once the clone is ready, or started, a reset starts a later session, which gets the // established activation and transaction without activating again. for activation in [ready, started] { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let mut activation = activation(t0); let state = activation.state(); for session in 1..=3 { let now = after(t0, 60 * session); @@ -308,15 +260,14 @@ fn a_reset_before_ready_is_final_and_one_after_it_starts_a_later_session() { let request = activation.input(now, ActivationInput::Request); assert_eq!(request.unwrap(), ActivationAction::Serve); } - assert_eq!(fence.opened(), 1); } } #[test] fn retries_join_the_running_activation_and_get_its_cached_result() { let t0 = Instant::now(); - let fence = FakeFence::default(); - let mut activation = activating(&fence, t0); + + let mut activation = activating(t0); // A retry of the same transaction, with a byte-equal record, joins the activation that runs, // whose deadline stays where it was. for second in 1..=3 { @@ -325,20 +276,19 @@ fn retries_join_the_running_activation_and_get_its_cached_result() { assert_eq!(activation.state(), ActivationState::Activating); assert_eq!(activation.deadline(), Some(after(t0, 10))); } - assert_eq!(fence.opened(), 0); + assert_eq!( activation.complete(after(t0, 4), Ok(())).unwrap(), reported() ); // Afterwards, one gets the cached result, before and after the start, without applying the - // record again or opening the fence again. + // record again. let retry = activation.input(after(t0, 5), ActivationInput::Activate(&record())); assert_eq!(retry.unwrap(), ActivationAction::Ready(reported())); activation.start().unwrap(); let retry = activation.input(after(t0, 6), ActivationInput::Activate(&record())); assert_eq!(retry.unwrap(), ActivationAction::Ready(reported())); assert_eq!(activation.state(), ActivationState::Started); - assert_eq!(fence.opened(), 1); } #[test] @@ -367,19 +317,18 @@ fn a_changed_record_or_a_new_transaction_is_final() { matches!(cause, ActivationCause::Record(ActivationError::Truncated)) }), ]; - // While the activation runs, and once it is ready, or started, whose fence stays open. - let states: [(Setup, u32); 3] = [(activating, 0), (ready, 1), (started, 1)]; - for (activation, opened) in states { + // While the activation runs, and once it is ready or started. + let states: [Setup; 3] = [activating, ready, started]; + for activation in states { for (retry, cause) in retries { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let mut activation = activation(t0); let state = activation.state(); let failure = activation .input(after(t0, 1), ActivationInput::Activate(retry)) .unwrap_err(); assert_eq!(failure.state(), state); assert!(cause(failure.cause()), "{state}: {failure}"); - assert_final(&mut activation, &fence, &failure, after(t0, 2), opened); + assert_final(&mut activation, &failure, after(t0, 2)); } } } @@ -388,22 +337,20 @@ fn a_changed_record_or_a_new_transaction_is_final() { fn an_activate_too_long_to_read_fails_as_a_record_that_long() { let t0 = Instant::now(); // In the transport bootstrap, as any Activate. - let fence = FakeFence::default(); - let mut activation = bootstrap(&fence, t0); + + let mut activation = bootstrap(t0); let failure = activation .input(after(t0, 1), ActivationInput::TooLong(MAX_RECORD_BYTES + 1)) .unwrap_err(); assert_eq!(failure.state(), ActivationState::Bootstrap); assert!(matches!(failure.cause(), ActivationCause::Request)); - assert_final(&mut activation, &fence, &failure, after(t0, 2), 0); + assert_final(&mut activation, &failure, after(t0, 2)); // From then on, as an Activate whose record has that many bytes, which no record has: while - // restricted, while the activation runs, and once it is ready, or started, whose fence stays - // open. - let states: [(Setup, u32); 4] = [(restricted, 0), (activating, 0), (ready, 1), (started, 1)]; - for (activation, opened) in states { + // restricted, while the activation runs, and once it is ready or started. + let states: [Setup; 4] = [restricted, activating, ready, started]; + for activation in states { for length in [MAX_RECORD_BYTES + 1, usize::MAX] { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let mut activation = activation(t0); let state = activation.state(); let failure = activation .input(after(t0, 1), ActivationInput::TooLong(length)) @@ -416,12 +363,12 @@ fn an_activate_too_long_to_read_fails_as_a_record_that_long() { ), "{state}: {failure}" ); - assert_final(&mut activation, &fence, &failure, after(t0, 2), opened); + assert_final(&mut activation, &failure, after(t0, 2)); } } // Past the deadline, before the clone is ready, it fails as every input does. - let fence = FakeFence::default(); - let mut activation = restricted(&fence, t0); + + let mut activation = restricted(t0); let failure = activation .input( after(t0, 10), @@ -429,14 +376,14 @@ fn an_activate_too_long_to_read_fails_as_a_record_that_long() { ) .unwrap_err(); assert!(matches!(failure.cause(), ActivationCause::Expired)); - assert_final(&mut activation, &fence, &failure, after(t0, 11), 0); + assert_final(&mut activation, &failure, after(t0, 11)); } #[test] fn a_failed_application_is_final_and_no_retry_applies_the_record_again() { let t0 = Instant::now(); - let fence = FakeFence::default(); - let mut activation = activating(&fence, t0); + + let mut activation = activating(t0); let failure = activation .complete(after(t0, 1), Err("the root's assembly failed".into())) .unwrap_err(); @@ -450,7 +397,7 @@ fn a_failed_application_is_final_and_no_retry_applies_the_record_again() { "the root's assembly failed" ); // A byte-equal retry neither joins nor applies the record again: it gets the failure. - assert_final(&mut activation, &fence, &failure, after(t0, 2), 0); + assert_final(&mut activation, &failure, after(t0, 2)); } #[test] @@ -519,33 +466,30 @@ fn an_activate_is_decoded_and_validated_before_any_side_effect() { ), ]; for (bytes, cause) in cases { - let fence = FakeFence::default(); - let mut activation = restricted(&fence, t0); + let mut activation = restricted(t0); let failure = activation .input(after(t0, 1), ActivationInput::Activate(&bytes)) .unwrap_err(); assert_eq!(failure.state(), ActivationState::Restricted); assert!(cause(failure.cause()), "{failure}"); - assert_final(&mut activation, &fence, &failure, after(t0, 2), 0); + assert_final(&mut activation, &failure, after(t0, 2)); } // A valid record goes to the adapter exactly as it decodes, and opens nothing yet. - let fence = FakeFence::default(); - let mut activation = restricted(&fence, t0); + + let mut activation = restricted(t0); let action = activation.input(after(t0, 1), ActivationInput::Activate(&record())); assert_eq!( action.unwrap(), ActivationAction::Apply(Box::new(ActivationRecord::decode(&record()).unwrap())) ); - assert_eq!(fence.opened(), 0); } #[test] fn the_deadline_starts_at_the_acknowledgement_and_retries_never_extend_it() { let t0 = Instant::now(); - let fence = FakeFence::default(); + // The repair acknowledged the restore at `t0`, and the adapter made the activation later. - let mut activation = - Activation::with_resources(resources(), Some(t0), fence.clone(), TIMEOUT, after(t0, 3)); + let mut activation = Activation::with_resources(resources(), Some(t0), TIMEOUT, after(t0, 3)); assert_eq!(activation.deadline(), Some(after(t0, 10))); activation .input(after(t0, 8), ActivationInput::Reset) @@ -564,11 +508,10 @@ fn the_deadline_starts_at_the_acknowledgement_and_retries_never_extend_it() { let failure = activation.tick(after(t0, 10)).unwrap_err(); assert_eq!(failure.state(), ActivationState::Activating); assert!(matches!(failure.cause(), ActivationCause::Expired)); - assert_final(&mut activation, &fence, &failure, after(t0, 11), 0); + assert_final(&mut activation, &failure, after(t0, 11)); // An application that completes at the deadline completes too late, as does any input. for complete in [true, false] { - let fence = FakeFence::default(); - let mut activation = activating(&fence, t0); + let mut activation = activating(t0); let failure = if complete { activation.complete(after(t0, 10), Ok(())).unwrap_err() } else { @@ -577,17 +520,16 @@ fn the_deadline_starts_at_the_acknowledgement_and_retries_never_extend_it() { }; assert_eq!(failure.state(), ActivationState::Activating); assert!(matches!(failure.cause(), ActivationCause::Expired)); - assert_final(&mut activation, &fence, &failure, after(t0, 11), 0); + assert_final(&mut activation, &failure, after(t0, 11)); } } #[test] fn an_ungated_restore_s_deadline_starts_at_the_activation() { let t0 = Instant::now(); - let fence = FakeFence::default(); + // Without an acknowledgement, the deadline starts when the adapter makes the activation. - let mut activation = - Activation::with_resources(resources(), None, fence.clone(), TIMEOUT, after(t0, 5)); + let mut activation = Activation::with_resources(resources(), None, TIMEOUT, after(t0, 5)); assert_eq!(activation.deadline(), Some(after(t0, 15))); activation.tick(after(t0, 14)).unwrap(); let failure = activation @@ -595,10 +537,9 @@ fn an_ungated_restore_s_deadline_starts_at_the_activation() { .unwrap_err(); assert_eq!(failure.state(), ActivationState::Bootstrap); assert!(matches!(failure.cause(), ActivationCause::Expired)); - assert_final(&mut activation, &fence, &failure, after(t0, 16), 0); + assert_final(&mut activation, &failure, after(t0, 16)); // A deadline that no instant can hold never passes. - let mut activation = - Activation::with_resources(resources(), Some(t0), fence.clone(), Duration::MAX, t0); + let mut activation = Activation::with_resources(resources(), Some(t0), Duration::MAX, t0); assert_eq!(activation.deadline(), None); activation.tick(after(t0, 1 << 30)).unwrap(); let reset = activation.input(after(t0, 1 << 30), ActivationInput::Reset); @@ -609,32 +550,24 @@ fn an_ungated_restore_s_deadline_starts_at_the_activation() { fn out_of_order_completions_and_starts_are_final() { let t0 = Instant::now(); // An activation completes only while it runs, and the sandbox starts only once it is ready, - // and once: the fence opens no sooner, and no more than once. - let completions: [(Setup, u32); 4] = - [(bootstrap, 0), (restricted, 0), (ready, 1), (started, 1)]; - for (activation, opened) in completions { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + // and once. + let completions: [Setup; 4] = [bootstrap, restricted, ready, started]; + for activation in completions { + let mut activation = activation(t0); let state = activation.state(); let failure = activation.complete(after(t0, 1), Ok(())).unwrap_err(); assert_eq!(failure.state(), state); assert!(matches!(failure.cause(), ActivationCause::Completion)); - assert_final(&mut activation, &fence, &failure, after(t0, 2), opened); + assert_final(&mut activation, &failure, after(t0, 2)); } - let starts: [(Setup, u32); 4] = [ - (bootstrap, 0), - (restricted, 0), - (activating, 0), - (started, 1), - ]; - for (activation, opened) in starts { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); + let starts: [Setup; 4] = [bootstrap, restricted, activating, started]; + for activation in starts { + let mut activation = activation(t0); let state = activation.state(); let failure = activation.start().unwrap_err(); assert_eq!(failure.state(), state); assert!(matches!(failure.cause(), ActivationCause::Start)); - assert_final(&mut activation, &fence, &failure, after(t0, 2), opened); + assert_final(&mut activation, &failure, after(t0, 2)); } } @@ -665,7 +598,7 @@ enum Reply { /// which it never waits for. struct Adapter { /// The activation. - activation: Activation, + activation: Activation, /// The bytes of the host's message in progress. message: Vec, /// The replies that wait for the output. @@ -676,7 +609,7 @@ struct Adapter { impl Adapter { /// Returns the adapter that drives `activation`. - fn new(activation: Activation) -> Self { + fn new(activation: Activation) -> Self { Self { activation, message: Vec::new(), @@ -737,8 +670,8 @@ fn the_deadline_stays_live_under_trickled_input_and_blocked_output() { assert!(activate.len() > 10); // A host that trickles its Activate, a byte a second, never completes it by the deadline: // the byte at the deadline fails the activation, while the output took nothing. - let fence = FakeFence::default(); - let mut adapter = Adapter::new(bootstrap(&fence, t0)); + + let mut adapter = Adapter::new(bootstrap(t0)); adapter.reset(t0).unwrap(); let mut failure = None; for (second, byte) in (1..).zip(&activate) { @@ -753,11 +686,11 @@ fn the_deadline_stays_live_under_trickled_input_and_blocked_output() { assert!(matches!(failure.cause(), ActivationCause::Expired)); assert!(adapter.applied.is_empty()); assert_eq!(adapter.output, [Reply::Acknowledgement]); - assert_final(&mut adapter.activation, &fence, &failure, after(t0, 11), 0); + assert_final(&mut adapter.activation, &failure, after(t0, 11)); // A host that sends nothing leaves the adapter only its wake at the deadline, which fails the // activation as well. - let fence = FakeFence::default(); - let mut adapter = Adapter::new(bootstrap(&fence, t0)); + + let mut adapter = Adapter::new(bootstrap(t0)); adapter.reset(after(t0, 1)).unwrap(); let deadline = adapter.activation.deadline().unwrap(); assert_eq!(deadline, after(t0, 10)); @@ -768,12 +701,12 @@ fn the_deadline_stays_live_under_trickled_input_and_blocked_output() { let failure = adapter.activation.tick(deadline).unwrap_err(); assert_eq!(failure.state(), ActivationState::Restricted); assert!(matches!(failure.cause(), ActivationCause::Expired)); - assert_final(&mut adapter.activation, &fence, &failure, after(t0, 11), 0); + assert_final(&mut adapter.activation, &failure, after(t0, 11)); // A host whose Activate arrives at once, and that trickles a retry while the record's // application runs, gets the retry joined, without another application or a later deadline, // and an application that completes after the deadline fails. - let fence = FakeFence::default(); - let mut adapter = Adapter::new(bootstrap(&fence, t0)); + + let mut adapter = Adapter::new(bootstrap(t0)); adapter.reset(t0).unwrap(); adapter.receive(after(t0, 1), &activate).unwrap(); assert_eq!(adapter.applied, [accepted()]); @@ -790,21 +723,21 @@ fn the_deadline_stays_live_under_trickled_input_and_blocked_output() { assert_eq!(failure.state(), ActivationState::Activating); assert!(matches!(failure.cause(), ActivationCause::Expired)); assert_eq!(adapter.output, [Reply::Acknowledgement]); - assert_final(&mut adapter.activation, &fence, &failure, after(t0, 12), 0); + assert_final(&mut adapter.activation, &failure, after(t0, 12)); // Another message of the framing, whole, while the record's application runs fails the // activation, as it would before the Activate. - let fence = FakeFence::default(); - let mut adapter = Adapter::new(activating(&fence, t0)); + + let mut adapter = Adapter::new(activating(t0)); let failure = adapter .receive(after(t0, 1), &message(PING, b"")) .unwrap_err(); assert_eq!(failure.state(), ActivationState::Activating); assert!(matches!(failure.cause(), ActivationCause::Request)); - assert_final(&mut adapter.activation, &fence, &failure, after(t0, 2), 0); + assert_final(&mut adapter.activation, &failure, after(t0, 2)); } /// A call that fails an activation. -type Failing = Box) -> Result<(), ActivationFailure>>; +type Failing = Box Result<(), ActivationFailure>>; #[test] fn strict_failures_are_returned_and_never_power_off() { @@ -870,14 +803,13 @@ fn strict_failures_are_returned_and_never_power_off() { ]; let mut causes = Vec::new(); for (activation, fail) in failures { - let fence = FakeFence::default(); - let mut activation = activation(&fence, t0); - let opened = fence.opened(); + let mut activation = activation(t0); + let failure = fail(&mut activation).unwrap_err(); let cause = std::mem::discriminant(failure.cause()); assert!(!causes.contains(&cause), "{failure}"); causes.push(cause); - assert_final(&mut activation, &fence, &failure, after(t0, 11), opened); + assert_final(&mut activation, &failure, after(t0, 11)); } } @@ -994,7 +926,7 @@ fn failures_name_their_state_and_their_cause() { } #[test] -fn an_activation_debugs_its_state_resources_and_deadline_without_its_fence() { +fn an_activation_debugs_its_state_resources_and_deadline() { let t0 = Instant::now(); let activation = Activation::with_resources( ExpectedResources { @@ -1002,7 +934,6 @@ fn an_activation_debugs_its_state_resources_and_deadline_without_its_fence() { memory: 2 << 30, }, Some(t0), - FakeFence::default(), TIMEOUT, after(t0, 1), ); diff --git a/nvx-rs/src/guest/clone/tests.rs b/nvx-rs/src/guest/activation/tests.rs similarity index 100% rename from nvx-rs/src/guest/clone/tests.rs rename to nvx-rs/src/guest/activation/tests.rs diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs deleted file mode 100644 index d67dfca27..000000000 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ /dev/null @@ -1,953 +0,0 @@ -//! The application of the record of the Activate that a clone's [`Activation`] accepted: -//! [`apply`](fn@apply), which activates the clone's root and workload with the record's settings, -//! as at a cold boot, as the [module](super::super#activation) describes. -//! -//! The application, `apply` and its types, is NVX's internal reference code of the guest's space, -//! hidden from the documentation and unstable: it becomes crate-private once nothing outside NVX -//! uses it. - -#[cfg(test)] -mod tests; - -use std::fmt; -use std::io; -use std::net::{IpAddr, Ipv4Addr}; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -#[cfg(doc)] -use super::super::Devices; -#[cfg(doc)] -use super::Activation; -use crate::activation::{self, ActivationRecord, Configuration, MachineIdMode, Publication}; -use crate::gpt::Expected; -use crate::guest::entry::{self, Entry, Identity, RuntimeBinds}; -use crate::guest::ingress::{Announcer, StartError}; -use crate::guest::mappings::{self, Access, SourceRoot}; -use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; -use crate::guest::view::{MachineId, write_resolver_file}; -#[cfg(doc)] -use crate::protocol::cmdline::write_resolver; - -pub use crate::guest::view::{MACHINE_ID_FILE, RESOLVER_FILE}; - -/// The inputs of a clone's activation that its guest takes from its own configuration, which its -/// template captured, so that every clone of the template has the same: the runtime directory, -/// the block devices, which their MMIO windows name, with the sizes that they must have, how long -/// the assembly of the root waits for each, the host filesystem device, which its virtio-fs tag -/// names, and the IPv4 gateway of the template's NIC, if it has one. [`apply`](fn@apply) takes -/// the rest from the activation record. -#[doc(hidden)] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct RootTemplate { - /// The runtime directory, below which the root is mounted, as [`Assembly::runtime`] tells, - /// and in which the application writes a machine ID that it generates, as - /// [`MACHINE_ID_FILE`], and a resolver file, as [`RESOLVER_FILE`]. - pub runtime: PathBuf, - /// The `distro` block device, the template's placeholder, which holds each clone's image. - pub distro: Device, - /// The scratch block device, which holds the upper of a record whose upper is the scratch, - /// and which a record whose upper is a tmpfs leaves alone. - pub scratch: Device, - /// How long the assembly waits for each device to appear, until the deadline that - /// [`apply`](fn@apply) takes at the latest. - pub timeout: Duration, - /// The virtio-fs tag of the host filesystem device, from which the template's capture found - /// nothing mounted, as [`Devices::filesystem_tag`] names it, and through which the host - /// attaches the export of a record that has one. - pub filesystem_tag: String, - /// The IPv4 gateway of the template's NIC, which the template's kernel command line names, as - /// [`gateway`](crate::protocol::cmdline::gateway) reads it, if the template has a NIC: the - /// gateway to which the announcer of the workload's listeners sends its announcements, for a - /// record that publishes them. - pub gateway: Option, -} - -/// A clone's root and workload, which [`apply`](fn@apply) activated: the [`Root`] that it -/// assembled, which owns the root's mounts, the host export's among them, the root of the host -/// export, if the record has one, the runtime's binds that it made on the root, in the caller's -/// mount namespace, the workload's hostname and machine ID, the record's, the resolver file that -/// names the DNS server of the record's network, if it names one, the endpoint of its proxy, if -/// any, and the announcer of the workload's listeners, if the record's network publishes them. -/// -/// It holds no identity in which the executions run: the adapter gives each execution the -/// identity that the execution names, which [`Entry::check`] checks, and whose account -/// [`Entry::prepare`] reads in existing-account mode, when the adapter prepares the execution's -/// entry, as for a sandbox without a workload identity at a cold boot. -/// Every execution's entry takes the [hostname](Self::hostname), through [`Entry::hostname`], and -/// sees the [machine ID](Self::machine_id) and the [resolver file](Self::resolver) through the -/// runtime's binds, which each execution's mount namespace copies from the caller's. The adapter -/// [takes](Self::take_export) the export's root, from which it maps the host's mapping table once -/// the clone is ready, and tells its workloads the [proxy's endpoint](Self::proxy) as at a cold -/// boot. The adapter watches the [announcer](Self::announcer) through its pidfd, and must end the -/// clone once the announcer ended: the gateway then keeps publishing the listeners that the -/// announcer last announced until the VM ends, and learns of no later change of them. -/// [`teardown`](Self::teardown) stops the announcer, closes the export's root, if the adapter did -/// not take it, removes the binds, then tears the root down, and dropping it does the same, -/// ignoring failures, as dropping a [`Root`] does. -#[doc(hidden)] -#[derive(Debug)] -pub struct Applied { - /// The announcer of the workload's listeners, if the record's network publishes them, which a - /// drop stops first, as a teardown does. - announcer: Option, - /// The root of the host export, which keeps the export's mount busy, so that a drop closes it - /// before the root drops, unless the adapter took it. - export: Option, - /// The runtime's binds on the root, which a drop removes before the root drops: the binds - /// keep the root's mount busy. - binds: Binds, - /// The assembled root. - root: Root, - /// The hostname of every execution: the record's. - hostname: Vec, - /// The machine ID that every execution sees at `/etc/machine-id`. - machine_id: MachineId, - /// The resolver file that every execution sees at `/etc/resolv.conf`, if the record's - /// network names a DNS server. - resolver: Option, - /// The endpoint of the proxy through which the workload reaches the network, if the record's - /// network names one. - proxy: Option, -} - -impl Applied { - /// Returns the assembled root. - pub fn root(&self) -> &Root { - &self.root - } - - /// Returns the assembled root, to mount live shares and mappings in it. - pub fn root_mut(&mut self) -> &mut Root { - &mut self.root - } - - /// Returns the hostname of every execution, the record's, which the adapter gives each - /// execution's entry through [`Entry::hostname`], and which each execution then sets in a UTS - /// namespace of its own. - pub fn hostname(&self) -> &[u8] { - &self.hostname - } - - /// Returns the machine ID that every execution sees at `/etc/machine-id`: the image's own, - /// [`MachineId::Image`], or [`MachineId::Generated`], with the file of [`MACHINE_ID_FILE`] - /// that the application generated once, and bound read-only over the root's - /// `/etc/machine-id`, for every execution. - pub fn machine_id(&self) -> &MachineId { - &self.machine_id - } - - /// Returns the resolver file, of [`RESOLVER_FILE`], that names the DNS server of the record's - /// network, which the application wrote once, and bound read-only over the root's - /// `/etc/resolv.conf`, for every execution. Returns `None` if the record's network names no - /// DNS server, or the record has no network, so that every execution sees the image's own - /// `/etc/resolv.conf`. - pub fn resolver(&self) -> Option<&Path> { - self.resolver.as_deref() - } - - /// Returns the endpoint of the proxy through which the workload reaches the network, as the - /// guest sees it, if the record's network names one. The application leaves it to the - /// adapter, which tells its workloads as at a cold boot. - pub fn proxy(&self) -> Option<&str> { - self.proxy.as_deref() - } - - /// Returns the announcer of the workload's listeners, which the application started, if the - /// record's network publishes them, so that the adapter polls its [pidfd](Announcer::pidfd), - /// and ends the clone once the announcer ended, as it must: the gateway then keeps publishing - /// the listeners that the announcer last announced, and learns of no later change of them. - pub fn announcer(&self) -> Option<&Announcer> { - self.announcer.as_ref() - } - - /// Returns the announcer of the workload's listeners, as [`announcer`](Self::announcer) - /// does, so that the adapter collects its status once it ended. - pub fn announcer_mut(&mut self) -> Option<&mut Announcer> { - self.announcer.as_mut() - } - - /// Takes the root of the host export, if the record has an export, and the adapter did not - /// take it before: the [`SourceRoot`] from which the adapter maps the host's mapping table - /// with [`Root::map`] once the clone is ready, as at a cold boot. - /// - /// The export's root keeps the export's mount busy, so the adapter drops it before the - /// [teardown](Self::teardown), which otherwise fails to unmount the export, and leaves it - /// mounted, as [`Root::mount_export`] tells. - pub fn take_export(&mut self) -> Option { - self.export.take() - } - - /// Stops the announcer of the workload's listeners, if the application started one, as - /// [`Announcer::stop`] does, ignoring its failure, closes the root of the host export, unless - /// the adapter [took](Self::take_export) it, removes the runtime's binds, then tears the root - /// down, as [`Root::teardown`] does, which unmounts the host export too, in the caller's mount - /// namespace, which must be the one where the application made the binds: the export's root - /// and the binds keep the root's mounts busy, and a teardown detaches no busy mount. - /// - /// It attempts both the removal and the teardown, whatever failed before, and fails with the - /// binds that stay mounted, and the unmounts of the root's teardown that failed, whose mounts - /// stay. - pub fn teardown(self) -> Result<(), AppliedTeardownError> { - let Self { - export, - mut binds, - root, - announcer, - .. - } = self; - teardown_with(&mut LiveSystem, announcer, export, binds.take(), root) - } - - /// Returns the assembled root and the runtime's binds on it, for an adapter that keeps them - /// apart: it then removes the binds with [`RuntimeBinds::remove`] before it tears the root - /// down, in the same mount namespace, since dropping them leaves them mounted. It stops the - /// announcer of the workload's listeners, if the application started one, and closes the root - /// of the host export, unless the adapter [took](Self::take_export) it. - pub fn into_parts(self) -> (Root, RuntimeBinds) { - let Self { - export, - mut binds, - root, - announcer, - .. - } = self; - if let Some(announcer) = announcer { - let _ = announcer.stop(); - } - drop(export); - (root, binds.take()) - } -} - -/// The runtime's binds on a clone's root, which a drop removes, ignoring failures, before the root -/// that they keep busy drops. -#[derive(Debug)] -struct Binds(Option); - -impl Binds { - /// Takes the binds, which the drop then leaves alone. - fn take(&mut self) -> RuntimeBinds { - self.0 - .take() - .expect("an application's binds are taken at most once") - } -} - -impl Drop for Binds { - fn drop(&mut self) { - if let Some(binds) = self.0.take() { - let _ = binds.remove(); - } - } -} - -/// Why [`Applied::teardown`] failed: the runtime's binds that it failed to remove, which stay -/// mounted and keep the root's mount busy, and the unmounts of the root's teardown that failed, -/// whose mounts stay. At least one of them failed. -#[doc(hidden)] -#[derive(Debug)] -pub struct AppliedTeardownError { - /// The binds that stay mounted, if any. - binds: Option, - /// The unmounts of the root's teardown that failed, if any. - root: Option, -} - -impl AppliedTeardownError { - /// Returns the runtime's binds that the teardown failed to remove, if any, which stay mounted. - pub fn binds(&self) -> Option<&mappings::TeardownError> { - self.binds.as_ref() - } - - /// Returns the unmounts of the root's teardown that failed, if any, whose mounts stay. - pub fn root(&self) -> Option<&TeardownError> { - self.root.as_ref() - } -} - -impl fmt::Display for AppliedTeardownError { - /// Writes the binds' failures, then the root's, separated by `; `. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match (&self.binds, &self.root) { - (Some(binds), Some(root)) => write!(formatter, "{binds}; {root}"), - (Some(binds), None) => binds.fmt(formatter), - (None, Some(root)) => root.fmt(formatter), - (None, None) => Ok(()), - } - } -} - -impl std::error::Error for AppliedTeardownError {} - -/// Applies `record`, the record of the Activate that a clone's [`Activation`] accepted, to the -/// clone, as at a cold boot, by `deadline`, the activation's [deadline](Activation::deadline), if -/// any, and returns the root and the workload that it activated, which the adapter keeps once it -/// reported the result to [`Activation::complete`]. -/// -/// In order, it: -/// -/// 1. takes the inputs of the root's [`Assembly`] from the record: its image, an EROFS image, -/// with the UUID that the record expects of it, if any, or a GPT disk, with the disk GUID that -/// the record expects of it, if any, and the bound of its first partition; its upper, a tmpfs -/// with the record's RAM cap, or the scratch with its journal policy; and its overlay mode; -/// and, from `template`, the runtime directory, the devices, and the timeout. It takes the workload's -/// hostname and machine-ID mode, whether it has an export, and its network's DNS server, -/// proxy endpoint, and publication of the workload's listeners from the record too. It takes -/// no identity in which the executions run: each execution names its own identity, which its -/// entry checks; -/// 2. for a GPT disk, asks the kernel to reread the disk's partition table, once the distro -/// device appeared, so that the block devices of the disk's partitions are those of the -/// clone's disk, rather than those of the template's placeholder, which the kernel created -/// when it found the placeholder: the kernel then has parsed the same read-only bytes that -/// the assembly reads and checks, as at a cold boot. It waits for the distro device as the -/// assembly does, never past `deadline`. A failure, before anything is mounted, fails with -/// [`ApplyError::Gpt`]; -/// 3. assembles the root as [`root::assemble`] does, which checks the devices, and the image as -/// the record expects it, and mounts the root, but with a fresh upper: it refuses the upper's -/// `upper` or `work` directory if it exists and is not empty, with -/// [`root::Error::NotFresh`], since a clone's root starts as its image alone, while a cold -/// sandbox's scratch keeps its upper across boots. That check reads at most one entry of -/// each directory, and no file. A failure fails with [`ApplyError::Assembly`], once the -/// assembly unmounted what it mounted. It waits for each device as long as the template's -/// timeout allows, but never past `deadline`: a device that has not appeared by then fails -/// the assembly, as at its timeout, so that the application ends by the activation's -/// deadline, past which [`Activation::complete`] fails the activation as expired, whatever -/// the result; -/// 4. if the record asks for a generated machine ID, generates it, once, with -/// [`MachineId::generate`], from the kernel's random number generator, which the clone's -/// repair reseeded before the activation accepted the record, so that no two clones of a -/// template share it, to [`MACHINE_ID_FILE`] in the template's runtime directory. A file there -/// already, as once a boot generated its ID, or one that cannot be created or written, fails -/// with [`ApplyError::MachineId`], once the application tore the root down; -/// 5. if the record has an export, mounts the host export, through the template's host filesystem -/// device, which [`RootTemplate::filesystem_tag`] names, with [`Root::mount_export`], whatever -/// the export's shape: at `hostfs/root` in the runtime directory, outside the root, where only -/// the guest's root user reaches it, read-write. The record names no mode of the mount, and none -/// is needed: OpenVMM limits each of the export's children to its own access, and each mapping -/// takes its own, so the mount only lets the read-write children through. OpenVMM resolves an -/// export's path again at each launch and restore, so the directories above an export's -/// directory must be ones that no guest may change. Whoever names an export must not name one -/// inside a directory that a writable export of another sandbox or clone holds. A failure fails -/// with [`ApplyError::Export`], once the application tore the root down, which unmounts an -/// export that it mounted; -/// 6. if the record's network names a DNS server, writes a resolver file that names it, as -/// [`write_resolver`] writes one, to [`RESOLVER_FILE`] in the template's runtime directory: a -/// new file, of mode 0444, whatever the umask, so that every execution may read it. A file -/// there already, or one that cannot be created or written, fails with -/// [`ApplyError::Network`], once the application tore the root down; -/// 7. if the record's network publishes the workload's listeners, starts their announcer, to -/// the gateway of [`RootTemplate::gateway`], with [`Announcer::start`]: it binds a UDP socket -/// to a privileged source port, and spawns `/sbin/nvx-ingress inherit GATEWAY_IPV4` with the -/// socket as its standard input, and waits until the program runs, but for no announcement. -/// The template captured its NIC under a policy that publishes nothing, so no announcer ran -/// in it. A template without a gateway, a socket that cannot be bound, and a program that does -/// not run fail with [`ApplyError::Network`], once the application tore the root down; -/// 8. binds the runtime's files into the root, for every execution, with one -/// [`Entry::bind_runtime`], in the caller's mount namespace, of which each execution's starts -/// as a copy: a generated machine ID's file, read-only over the root's `/etc/machine-id`, then -/// the resolver file, read-only over the root's `/etc/resolv.conf`, and, with the image's own -/// machine ID and without a resolver file, nothing. The entry checks the record's hostname -/// first, which every execution's entry then takes, through [`Entry::hostname`]. A failure to -/// bind the resolver file fails with [`ApplyError::Network`], and any other with -/// [`ApplyError::View`], once the binds that it made were removed, the application stopped -/// the announcer that it started, and tore the root down. -/// -/// The activation answers a retry of the Activate without applying the record again, so every -/// execution, and every retry, sees the same machine ID and resolver file. A failure after the -/// generation, or after the resolver file's write, leaves that file, since the activation fails -/// finally, and the adapter ends the VM. -/// -/// It returns the root of the host export, the [`SourceRoot`] from which the adapter maps the -/// host's mapping table once the clone is ready, as at a cold boot, the endpoint of the network's -/// proxy, which it leaves to the adapter, which tells its workloads as at a cold boot, and the -/// announcer that it started, which the adapter watches. It starts no other process, generates at -/// most one ID, after the activation accepted the record, and never powers the VM off: a failure, -/// which the adapter reports to [`Activation::complete`], fails the activation finally, and the -/// adapter ends the VM. -/// -/// It needs the guest's root user, in the guest's initial mount namespace, as -/// [`root::assemble`] does, and the binds of a generated machine ID and of a resolver file need -/// Linux 6.8 or later, as [`Entry::bind_runtime`] does. -#[doc(hidden)] -pub fn apply( - record: &ActivationRecord, - template: &RootTemplate, - deadline: Option, -) -> Result { - let activated = apply_with(&mut LiveSystem, record, template, deadline)?; - Ok(Applied { - export: activated.export, - binds: Binds(Some(activated.binds)), - root: activated.root, - hostname: activated.hostname, - machine_id: activated.machine_id, - resolver: activated.resolver, - proxy: activated.proxy, - announcer: activated.announcer, - }) -} - -/// Why [`apply`](fn@apply) failed, which the adapter reports to [`Activation::complete`]. -/// -/// Nothing that the application mounted stays mounted, unless the error holds the unmounts that -/// failed, whose mounts stay. -#[doc(hidden)] -#[derive(Debug)] -#[non_exhaustive] -pub enum ApplyError { - /// The partition table of the record's GPT disk could not be reread, before anything was - /// mounted, as this error tells: the distro device did not appear in time, or the kernel - /// refused the reread, as it does while one of the disk's partitions is open. The block - /// devices of the disk's partitions need not be the clone's then: the kernel created them - /// when it found the template's placeholder. - Gpt(root::Error), - /// The root's assembly failed, as this error tells, and unmounted what it had mounted, unless - /// the error holds the unmounts that failed. It fails with [`root::Error::NotFresh`] if the - /// upper's `upper` or `work` directory exists and is not empty. - Assembly(AssemblyError), - /// The machine ID that the record asks the guest to generate could not be written to - /// [`MACHINE_ID_FILE`] in the template's runtime directory, as this error tells: a file is - /// there already, as once a boot generated its ID, or the file cannot be created or written. - /// The application then tore the root down. - MachineId { - /// Why the machine ID could not be written. - error: io::Error, - /// The unmounts of the root's teardown that failed, if any, whose mounts stay. - teardown: Option, - }, - /// The workload's view could not be set in the assembled root, as this error tells: the - /// entry refused the record's hostname, or could not bind the generated machine ID's file - /// over the root's `/etc/machine-id`, after which it removed the binds that it made, unless - /// the error names those that stay. The application then tore the root down. - View { - /// Why the view could not be set, as [`Entry::bind_runtime`] tells. - error: entry::Error, - /// The unmounts of the root's teardown that failed, if any, whose mounts stay. - teardown: Option, - }, - /// The host export could not be mounted, as this error tells: the template's host filesystem - /// device has no export attached, or the root refused the mount. The application then tore - /// the root down, which unmounts the export if it was mounted. - Export { - /// Why the export could not be mounted, as [`Root::mount_export`] tells. - error: root::Error, - /// The unmounts of the root's teardown that failed, if any, whose mounts stay. - teardown: Option, - }, - /// The record's network settings could not be applied, as this error tells: the resolver - /// file that names the network's DNS server could not be written, or bound over the root's - /// `/etc/resolv.conf`, or the announcer of the workload's listeners, which the network - /// publishes, could not start. The application then stopped an announcer that it started, - /// and tore the root down. - Network { - /// Why the network settings could not be applied. - error: NetworkError, - /// The unmounts of the root's teardown that failed, if any, whose mounts stay. - teardown: Option, - }, -} - -impl fmt::Display for ApplyError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - let teardown = match self { - Self::Gpt(error) => { - return write!( - formatter, - "the reread of the GPT disk's partition table failed: {error}" - ); - } - Self::Assembly(error) => { - return write!(formatter, "the root's assembly failed: {error}"); - } - Self::MachineId { error, teardown } => { - write!( - formatter, - "the workload's machine ID cannot be generated: {error}" - )?; - teardown - } - Self::View { error, teardown } => { - write!( - formatter, - "the workload's hostname or machine ID cannot be set: {error}" - )?; - teardown - } - Self::Export { error, teardown } => { - write!(formatter, "the host export cannot be mounted: {error}")?; - teardown - } - Self::Network { error, teardown } => { - write!(formatter, "the network settings cannot be applied: {error}")?; - teardown - } - }; - match teardown { - Some(teardown) => write!(formatter, "; then {teardown}"), - None => Ok(()), - } - } -} - -impl std::error::Error for ApplyError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Gpt(error) | Self::Export { error, .. } => Some(error), - Self::Assembly(error) => Some(error), - Self::View { error, .. } => Some(error), - Self::MachineId { error, .. } => Some(error), - Self::Network { error, .. } => Some(error), - } - } -} - -/// Why the network settings of a record could not be applied, which an [`ApplyError::Network`] -/// holds. -#[doc(hidden)] -#[derive(Debug)] -#[non_exhaustive] -pub enum NetworkError { - /// The resolver file that names the network's DNS server could not be written to - /// [`RESOLVER_FILE`] in the template's runtime directory, as this error tells: a file is - /// there already, or the file cannot be created or written. - Write(io::Error), - /// The resolver file could not be bound over the root's `/etc/resolv.conf`, as - /// [`Entry::bind_runtime`] tells: with an [`entry::Error::Resolver`], or an - /// [`entry::Error::Rollback`] of one, after which the entry removed the binds that it made, - /// unless the error names those that stay. - Bind(entry::Error), - /// The network publishes the workload's listeners, but the template names no IPv4 gateway, - /// [`RootTemplate::gateway`], to which their announcer would send its announcements. - Gateway, - /// The announcer of the workload's listeners, which the network publishes, could not start, - /// as [`Announcer::start`] tells. - Announcer(StartError), -} - -impl fmt::Display for NetworkError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Write(error) => write!(formatter, "the resolver file cannot be written: {error}"), - Self::Bind(error) => error.fmt(formatter), - Self::Gateway => formatter.write_str( - "the network publishes the workload's listeners, but the template names no IPv4 \ - gateway to announce them to", - ), - Self::Announcer(error) => error.fmt(formatter), - } - } -} - -impl std::error::Error for NetworkError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Write(error) => Some(error), - // The entry's error names the resolver file already, so it stands for this one, as - // the start's error names the announcer. - Self::Bind(error) => error.source(), - Self::Announcer(error) => error.source(), - Self::Gateway => None, - } - } -} - -/// The calls through which an application reaches the system: the guest kernel's, in a VM, and a -/// fake's, which records them, in tests. -trait System { - /// What an assembly mounted: a [`Root`], in a VM. - type Root; - - /// The root of a host export that a mount returned: a [`SourceRoot`], in a VM. - type Export; - - /// The runtime's binds that a bind made: [`RuntimeBinds`], in a VM. - type Binds; - - /// The announcer of the workload's listeners that a start returned: an [`Announcer`], in a - /// VM. - type Announcer; - - /// Asks the kernel to reread the partition table of the GPT disk on the distro device - /// `distro`, once it appeared, waiting for it for as long as `timeout` allows, but until - /// `deadline` at the latest, if any, as [`root::reread_partitions`] does. - fn reread( - &mut self, - distro: Device, - timeout: Duration, - deadline: Option, - ) -> Result<(), root::Error>; - - /// Assembles the root of `assembly` with a fresh upper, as [`root::assemble_fresh`] does, - /// waiting for each device until `deadline` at the latest, if any. - fn assemble( - &mut self, - assembly: &Assembly, - deadline: Option, - ) -> Result; - - /// Generates a machine ID, and writes it to a new file at `path`, as [`MachineId::generate`] - /// does. - fn generate(&mut self, path: &Path) -> io::Result; - - /// Mounts the host export, the virtio-fs device `tag`, with `access`, for `root`, and returns - /// the export's root, as [`Root::mount_export`] does. - fn mount_export( - &mut self, - root: &mut Self::Root, - tag: &str, - access: Access, - ) -> Result; - - /// Closes `export`, the root of a host export, which keeps the export's mount busy until then. - fn close(&mut self, export: Self::Export); - - /// Writes a resolver file that names `server` to a new file at `path`, as - /// [`write_resolver_file`] does. - fn write_resolver(&mut self, path: &Path, server: IpAddr) -> io::Result<()>; - - /// Starts the announcer of the workload's listeners, which announces them to `gateway`, as - /// [`Announcer::start`] does. - fn announce(&mut self, gateway: Ipv4Addr) -> Result; - - /// Stops `announcer`, as [`Announcer::stop`] does, ignoring its failure. - fn stop(&mut self, announcer: Self::Announcer); - - /// Binds the runtime's files of `view` into `root`, for every execution, in the caller's - /// mount namespace, as [`Entry::bind_runtime`] does with the view's [entry](View::entry). - fn bind(&mut self, root: &Self::Root, view: &View) -> Result; - - /// Removes `binds`, as [`RuntimeBinds::remove`] does. - fn remove(&mut self, binds: Self::Binds) -> Result<(), mappings::TeardownError>; - - /// Tears `root` down, as [`Root::teardown`] does. - fn teardown(&mut self, root: Self::Root) -> Result<(), TeardownError>; -} - -/// The guest kernel's calls. -struct LiveSystem; - -impl System for LiveSystem { - type Root = Root; - type Export = SourceRoot; - type Binds = RuntimeBinds; - type Announcer = Announcer; - - fn reread( - &mut self, - distro: Device, - timeout: Duration, - deadline: Option, - ) -> Result<(), root::Error> { - root::reread_partitions(distro, timeout, deadline) - } - - fn assemble( - &mut self, - assembly: &Assembly, - deadline: Option, - ) -> Result { - root::assemble_fresh(assembly, deadline) - } - - fn generate(&mut self, path: &Path) -> io::Result { - MachineId::generate(path) - } - - fn mount_export( - &mut self, - root: &mut Root, - tag: &str, - access: Access, - ) -> Result { - root.mount_export(tag, access) - } - - fn close(&mut self, export: SourceRoot) { - drop(export); - } - - fn write_resolver(&mut self, path: &Path, server: IpAddr) -> io::Result<()> { - write_resolver_file(path, server) - } - - fn announce(&mut self, gateway: Ipv4Addr) -> Result { - Announcer::start(gateway) - } - - fn stop(&mut self, announcer: Announcer) { - let _ = announcer.stop(); - } - - fn bind(&mut self, root: &Root, view: &View) -> Result { - view.entry(root.path()).bind_runtime() - } - - fn remove(&mut self, binds: RuntimeBinds) -> Result<(), mappings::TeardownError> { - binds.remove() - } - - fn teardown(&mut self, root: Root) -> Result<(), TeardownError> { - root.teardown() - } -} - -/// The workload's view of its root, which the application sets for every execution: the -/// hostname, the machine ID, and the resolver file. -#[derive(Clone, Debug, PartialEq, Eq)] -struct View { - /// The hostname of every execution: the record's. - hostname: Vec, - /// The machine ID that every execution sees. - machine_id: MachineId, - /// The resolver file that every execution sees at `/etc/resolv.conf`, if the record's - /// network names a DNS server. - resolver: Option, -} - -impl View { - /// Returns the entry into the root at `root` that binds the view's runtime files, and checks - /// its hostname, with [`Entry::bind_runtime`]. The entry binds the same files whatever its - /// identity, and no execution runs through it, so it takes the user and group ID 0, which - /// it checks as it checks any identity. - fn entry(&self, root: &Path) -> Entry { - let entry = Entry::new(root, Identity::new(0, 0)) - .hostname(self.hostname.clone()) - .machine_id(self.machine_id.clone()); - match &self.resolver { - Some(resolver) => entry.resolver(resolver.clone()), - None => entry, - } - } -} - -/// What an application activated through a system, which an [`Applied`] holds in a VM: the -/// root, the root of the host export, if any, the runtime's binds on the root, the workload's -/// hostname and machine ID, the resolver file, if any, the proxy's endpoint, if any, and the -/// announcer of the workload's listeners, if any. -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq, Eq))] -struct Activated { - /// The assembled root. - root: R, - /// The root of the host export, if the record has an export. - export: Option, - /// The runtime's binds on the root. - binds: B, - /// The hostname of every execution. - hostname: Vec, - /// The machine ID that every execution sees. - machine_id: MachineId, - /// The resolver file that every execution sees, if the record's network names a DNS server. - resolver: Option, - /// The endpoint of the proxy, if the record's network names one. - proxy: Option, - /// The announcer of the workload's listeners, if the record's network publishes them. - announcer: Option, -} - -/// What an application through a system of type `S` activated. -type ActivatedBy = Activated< - ::Root, - ::Export, - ::Binds, - ::Announcer, ->; - -/// Applies `record` with `template` by `deadline`, if any, through `system`, as -/// [`apply`](fn@apply) does, and returns what it activated. -fn apply_with( - system: &mut S, - record: &ActivationRecord, - template: &RootTemplate, - deadline: Option, -) -> Result, ApplyError> { - let configuration = record.configuration(); - let assembly = assembly(template, configuration); - let view = &configuration.view; - if let root::Image::Gpt { .. } = assembly.image { - system - .reread(assembly.distro, assembly.timeout, deadline) - .map_err(ApplyError::Gpt)?; - } - let mut root = system - .assemble(&assembly, deadline) - .map_err(ApplyError::Assembly)?; - let machine_id = match view.machine_id { - MachineIdMode::Image => MachineId::Image, - MachineIdMode::Generated => { - match system.generate(&template.runtime.join(MACHINE_ID_FILE)) { - Ok(machine_id) => machine_id, - Err(error) => { - let teardown = system.teardown(root).err(); - return Err(ApplyError::MachineId { error, teardown }); - } - } - } - }; - // The record names no mode of the export's mount: OpenVMM bounds each child's access. - let export = match configuration.export { - Some(_) => { - let tag = &template.filesystem_tag; - match system.mount_export(&mut root, tag, Access::ReadWrite) { - Ok(export) => Some(export), - Err(error) => { - let teardown = system.teardown(root).err(); - return Err(ApplyError::Export { error, teardown }); - } - } - } - None => None, - }; - let network = configuration.network.as_ref(); - let resolver = match network.and_then(|network| network.dns) { - Some(server) => { - let path = template.runtime.join(RESOLVER_FILE); - if let Err(error) = system.write_resolver(&path, server) { - let teardown = discard(system, export, root); - let error = NetworkError::Write(error); - return Err(ApplyError::Network { error, teardown }); - } - Some(path) - } - None => None, - }; - let publishes = network.is_some_and(|network| network.publication == Publication::Published); - let announcer = if publishes { - let started = match template.gateway { - Some(gateway) => system.announce(gateway).map_err(NetworkError::Announcer), - None => Err(NetworkError::Gateway), - }; - match started { - Ok(announcer) => Some(announcer), - Err(error) => { - let teardown = discard(system, export, root); - return Err(ApplyError::Network { error, teardown }); - } - } - } else { - None - }; - let view = View { - hostname: view.hostname.clone(), - machine_id, - resolver, - }; - let binds = match system.bind(&root, &view) { - Ok(binds) => binds, - Err(error) => { - if let Some(announcer) = announcer { - system.stop(announcer); - } - let teardown = discard(system, export, root); - return Err(if is_resolver(&error) { - let error = NetworkError::Bind(error); - ApplyError::Network { error, teardown } - } else { - ApplyError::View { error, teardown } - }); - } - }; - Ok(Activated { - root, - export, - binds, - hostname: view.hostname, - machine_id: view.machine_id, - resolver: view.resolver, - proxy: network.and_then(|network| network.proxy.clone()), - announcer, - }) -} - -/// Closes `export`, the root of the host export, if any, then tears `root` down, through -/// `system`, once an application failed, and returns the unmounts of the teardown that failed, if -/// any. -fn discard( - system: &mut S, - export: Option, - root: S::Root, -) -> Option { - if let Some(export) = export { - system.close(export); - } - system.teardown(root).err() -} - -/// Returns whether `error`, why [`Entry::bind_runtime`] failed, is the failure of the resolver -/// file's bind, possibly with binds that stay mounted. -fn is_resolver(error: &entry::Error) -> bool { - match error { - entry::Error::Resolver(_) => true, - entry::Error::Rollback { error, .. } => is_resolver(error), - _ => false, - } -} - -/// Stops `announcer`, if any, closes `export`, the root of the host export, if any, removes -/// `binds`, then tears `root` down, through `system`, as [`Applied::teardown`] does, attempting the -/// removal and the teardown whatever failed before. -fn teardown_with( - system: &mut S, - announcer: Option, - export: Option, - binds: S::Binds, - root: S::Root, -) -> Result<(), AppliedTeardownError> { - if let Some(announcer) = announcer { - system.stop(announcer); - } - if let Some(export) = export { - system.close(export); - } - let binds = system.remove(binds).err(); - let root = system.teardown(root).err(); - if binds.is_none() && root.is_none() { - return Ok(()); - } - Err(AppliedTeardownError { binds, root }) -} - -/// Returns the assembly of the root that `configuration` sets, with `template`'s runtime -/// directory, devices, and timeout. -fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly { - let image = match configuration.image { - activation::Image::Erofs { uuid, .. } => root::Image::Erofs { uuid }, - activation::Image::Gpt { - disk_guid, - first_partition_bound, - .. - } => root::Image::Gpt { - expected: match disk_guid { - Some(guid) => Expected::new().disk_guid(guid), - None => Expected::new(), - }, - // A larger bound than the guest can address bounds nothing more. - first_partition_bound: usize::try_from(first_partition_bound).unwrap_or(usize::MAX), - }, - }; - let upper = match configuration.upper { - activation::Upper::Tmpfs { max_memory_percent } => root::Upper::Tmpfs { - max_memory_percent: Some(max_memory_percent), - }, - activation::Upper::Scratch { journal, .. } => root::Upper::Scratch { - device: template.scratch, - journal: match journal { - activation::Journal::Allowed => root::Journal::Allowed, - activation::Journal::Forbidden => root::Journal::Forbidden, - }, - }, - }; - let overlay = match configuration.overlay { - activation::OverlayMode::TrustedLowers => root::OverlayMode::TrustedLowers, - activation::OverlayMode::UntrustedLowers => root::OverlayMode::UntrustedLowers, - }; - Assembly { - runtime: template.runtime.clone(), - distro: template.distro, - image, - upper, - overlay, - timeout: template.timeout, - } -} diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs deleted file mode 100644 index 70c5789d5..000000000 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ /dev/null @@ -1,1938 +0,0 @@ -//! Tests of the application of a clone's activation record, through a fake system, which records -//! every call of the application, and whose assembly returns a temporary directory that stands -//! for the root, the image's view through the overlay, whose account databases the application -//! leaves alone, and which generates no machine ID, writes no resolver file, starts -//! no announcer, and mounts, binds, and unmounts nothing; and, with the clone's activation, -//! through an adapter of -//! the tests' own, which takes what the host sends, applies the record of the Activate that the -//! activation accepted through the fake system, and spawns an execution for each request that -//! the activation serves. -//! -//! One test runs the live system's generation of a machine ID, its write of a resolver file, and -//! their binds with the kernel itself, over a tmpfs that stands for the root, which needs the -//! privilege to mount and Linux 6.8 or later; it is ignored unless asked for: -//! `cargo test --all-features -- --ignored guest::clone::activation::apply`, as root. The host -//! export's mount, which needs a virtio-fs device, is `Root::mount_export`'s, which its own -//! tests cover. - -use std::cell::Cell; -use std::error::Error as _; -use std::fs; -use std::io; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::rc::Rc; -use std::time::Instant; - -use super::*; -use crate::activation::{ - ExpectedResources, Export, ExportKind, MachineIdMode, Network, Publication, ReadyActivation, - RestoreSection, TransactionId, View as RecordView, -}; -use crate::guest::clone::{ - Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, - ActivationState, AdmissionFence, -}; -use crate::guest::mappings::MappingError; -use crate::guest::sys::{self, Errno, Mode, MountFlags, OpenFlags}; -use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; -use crate::uuid::Uuid; - -/// The runtime directory of the tests' template. -const RUNTIME: &str = "/run/nvx"; -/// The virtio-fs tag of the host filesystem device of the tests' template. -const TAG: &str = "microvm"; -/// How long the tests' assembly waits for each device. -const DEVICE_TIMEOUT: Duration = Duration::from_secs(5); -/// The number of online CPUs of the tests' clone. -const CPUS: u32 = 2; -/// The memory size of the tests' clone: eight online memory blocks of 128 MiB. -const MEMORY: u64 = 8 << 27; -/// How long the tests' activation may take after the restore's acknowledgement. -const TIMEOUT: Duration = Duration::from_secs(10); -/// The transaction of the tests' Activate. -const TRANSACTION: TransactionId = TransactionId::from_bytes([0x22; TransactionId::LEN]); -/// The UUID that the record of the tests' Activate expects of its EROFS image. -const IMAGE_UUID: Uuid = Uuid::from_bytes([0x5a; 16]); -/// The GUID that a record of the tests expects of its GPT disk. -const DISK_GUID: Uuid = Uuid::from_bytes([0xd1; 16]); -/// The most bytes that the first partition of the tests' GPT disks may hold. -const FIRST_PARTITION_BOUND: u64 = 4096; -/// The passwd database of the tests' image, which holds the workload identity's account. -const PASSWD: &[u8] = b"root:x:0:0:root:/root:/bin/sh\n\ -workload:x:1000:1001::/home/workload:/bin/sh\n"; -/// The group database of the tests' image, which holds the workload identity's group. -const GROUP: &[u8] = b"root:x:0:\nworkload:x:1001:\n"; -/// The hostname of the tests' records. -const HOSTNAME: &[u8] = b"clone"; -/// The DNS server of the tests' networks. -const DNS: IpAddr = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); -/// The endpoint of the proxy of the tests' networks. -const PROXY: &str = "http://10.0.0.1:3128"; -/// The IPv4 gateway of the tests' template's NIC. -const GATEWAY: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); - -/// Returns whether a failure's cause is the one that a test expects. -type Expected = fn(&ActivationCause) -> bool; - -/// Returns whether an application's failure is the one that a test expects. -type Failed = fn(&ApplyError) -> bool; - -/// What a clone did, in order: each call that the application made of the fake system, and each -/// execution that the tests' adapter spawned. -#[derive(Clone, Debug, PartialEq, Eq)] -enum Effect { - /// The application asked the kernel to reread the partition table of the GPT disk on this - /// distro device, which drops and creates the block devices of the disk's partitions. - Reread(Device), - /// The application assembled the root of this assembly, with a fresh upper, which mounts it. - Assemble(Assembly), - /// The application generated a machine ID, which it wrote to a new file at this path. - Generate(PathBuf), - /// The application mounted the host export, the virtio-fs device of this tag, with this - /// access. - MountExport(String, Access), - /// The application wrote a resolver file that names this DNS server to a new file at this - /// path. - Resolver(PathBuf, IpAddr), - /// The application started the announcer of the workload's listeners, to this gateway. - Announce(Ipv4Addr), - /// The announcer of the workload's listeners was stopped. - Stop, - /// The application bound the runtime's files of this view into the root, for every - /// execution. - Bind(View), - /// The root of the host export was closed. - Close, - /// The runtime's binds were removed. - Remove, - /// The application tore the root down, which unmounts it. - Teardown, - /// The adapter spawned an execution. - Spawn, -} - -/// A system that records each call of the application, and whose assembly returns the directory -/// of its image, unless it fails as a real one does: the image's view through the overlay, whose -/// `etc` holds the image's account databases. -struct FakeSystem { - /// The directory that stands for the assembled root. - image: tempfile::TempDir, - /// Whether each assembly fails. - failing: bool, - /// The error with which the kernel refuses each reread of a partition table, if it does. - refusing: Option, - /// The kind of the error with which each generation of a machine ID fails, if it does. - generating: Option, - /// Whether each mount of the host export fails, as when the template's filesystem device has - /// no export attached. - unmountable: bool, - /// The kind of the error with which each write of a resolver file fails, if it does. - unwritable: Option, - /// The error with which each start of the announcer of the workload's listeners fails, if it - /// does. - unannounced: Option StartError>, - /// The error with which each bind of the runtime's files fails, if it does, as when the - /// image's `/etc/machine-id` or `/etc/resolv.conf` is a directory. - unbindable: Option entry::Error>, - /// Whether each removal of the runtime's binds fails, as when another mount covers one. - stuck: bool, - /// What the clone did, in order. - effects: Vec, - /// The deadline of each reread and assembly, in order, by which it waits for its devices. - deadlines: Vec>, -} - -impl FakeSystem { - /// Returns a system whose image's `etc` holds `passwd` and `group`. - fn with(passwd: &[u8], group: &[u8]) -> Self { - let image = tempfile::tempdir().unwrap(); - let etc = image.path().join("etc"); - fs::create_dir(&etc).unwrap(); - fs::write(etc.join("passwd"), passwd).unwrap(); - fs::write(etc.join("group"), group).unwrap(); - Self { - image, - failing: false, - refusing: None, - generating: None, - unmountable: false, - unwritable: None, - unannounced: None, - unbindable: None, - stuck: false, - effects: Vec::new(), - deadlines: Vec::new(), - } - } - - /// Returns a system whose image holds the tests' account databases. - fn new() -> Self { - Self::with(PASSWD, GROUP) - } - - /// Returns a system whose image holds the tests' account databases, and each of whose - /// assemblies fails. - fn failing() -> Self { - Self { - failing: true, - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and whose kernel refuses - /// each reread of a partition table with `error`, as while one of the disk's partitions is - /// open. - fn refusing(error: Errno) -> Self { - Self { - refusing: Some(error), - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose - /// generations of a machine ID fails with an error of `kind`. - fn generating(kind: io::ErrorKind) -> Self { - Self { - generating: Some(kind), - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose mounts - /// of the host export fails. - fn unmountable() -> Self { - Self { - unmountable: true, - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose writes - /// of a resolver file fails with an error of `kind`. - fn unwritable(kind: io::ErrorKind) -> Self { - Self { - unwritable: Some(kind), - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose starts - /// of the announcer of the workload's listeners fails with the error that `error` returns. - fn unannounced(error: fn() -> StartError) -> Self { - Self { - unannounced: Some(error), - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose binds - /// of the runtime's files fails with the error that `error` returns, once the entry checked - /// its inputs. - fn unbindable(error: fn() -> entry::Error) -> Self { - Self { - unbindable: Some(error), - ..Self::new() - } - } - - /// Returns a system whose image holds the tests' account databases, and each of whose - /// removals of the runtime's binds fails. - fn stuck() -> Self { - Self { - stuck: true, - ..Self::new() - } - } - - /// Returns the path of the root that each assembly returns. - fn root(&self) -> PathBuf { - self.image.path().to_owned() - } - - /// Returns how many machine IDs the application generated. - fn generated(&self) -> usize { - let generated = |effect: &&Effect| matches!(effect, Effect::Generate(_)); - self.effects.iter().filter(generated).count() - } -} - -impl System for FakeSystem { - type Root = PathBuf; - type Export = (); - type Binds = (); - type Announcer = Ipv4Addr; - - fn reread( - &mut self, - distro: Device, - _timeout: Duration, - deadline: Option, - ) -> Result<(), root::Error> { - self.effects.push(Effect::Reread(distro)); - self.deadlines.push(deadline); - match self.refusing { - Some(error) => Err(root::Error::Device { - role: root::Role::Distro, - step: root::DeviceStep::Reread, - device: PathBuf::from("/dev/vda"), - error: error.into(), - }), - None => Ok(()), - } - } - - fn assemble( - &mut self, - assembly: &Assembly, - deadline: Option, - ) -> Result { - self.effects.push(Effect::Assemble(assembly.clone())); - self.deadlines.push(deadline); - if self.failing { - // An assembly refuses a relative runtime directory before it does anything else, - // which gives the fake a real failure. - let refused = Assembly { - runtime: PathBuf::from("run/nvx"), - ..assembly.clone() - }; - return Err(root::assemble(&refused).unwrap_err()); - } - Ok(self.root()) - } - - fn generate(&mut self, path: &Path) -> io::Result { - self.effects.push(Effect::Generate(path.to_owned())); - match self.generating { - Some(kind) => Err(kind.into()), - None => Ok(MachineId::Generated(path.to_owned())), - } - } - - fn mount_export( - &mut self, - root: &mut PathBuf, - tag: &str, - access: Access, - ) -> Result<(), root::Error> { - assert_eq!(*root, self.root()); - self.effects - .push(Effect::MountExport(tag.to_owned(), access)); - if self.unmountable { - // The virtio-fs device has no export attached. - return Err(root::Error::Mount { - mount: root::Mount::Export { - tag: tag.to_owned(), - }, - error: Errno::ENOENT.into(), - }); - } - Ok(()) - } - - fn close(&mut self, (): ()) { - self.effects.push(Effect::Close); - } - - fn write_resolver(&mut self, path: &Path, server: IpAddr) -> io::Result<()> { - self.effects.push(Effect::Resolver(path.to_owned(), server)); - match self.unwritable { - Some(kind) => Err(kind.into()), - None => Ok(()), - } - } - - fn announce(&mut self, gateway: Ipv4Addr) -> Result { - self.effects.push(Effect::Announce(gateway)); - match self.unannounced { - Some(error) => Err(error()), - None => Ok(gateway), - } - } - - fn stop(&mut self, announcer: Ipv4Addr) { - assert_eq!(announcer, GATEWAY); - self.effects.push(Effect::Stop); - } - - fn bind(&mut self, root: &PathBuf, view: &View) -> Result<(), entry::Error> { - assert_eq!(*root, self.root()); - self.effects.push(Effect::Bind(view.clone())); - // The entry checks its inputs, the hostname among them, before it binds anything. - view.entry(root).check()?; - match self.unbindable { - Some(error) => Err(error()), - None => Ok(()), - } - } - - fn remove(&mut self, (): ()) -> Result<(), mappings::TeardownError> { - self.effects.push(Effect::Remove); - if self.stuck { - return Err(mappings::TeardownError::of( - "/etc/machine-id", - MappingError::MountPoint, - )); - } - Ok(()) - } - - fn teardown(&mut self, root: PathBuf) -> Result<(), TeardownError> { - assert_eq!(root, self.root()); - self.effects.push(Effect::Teardown); - Ok(()) - } -} - -/// The caller's fence of workload admission, which a restore handed over closed, and which the -/// tests' adapter checks before it spawns an execution. -#[derive(Clone, Default)] -struct FakeFence { - /// Whether the fence opened. - opened: Rc>, -} - -impl FakeFence { - /// Returns whether the fence is open. - fn is_open(&self) -> bool { - self.opened.get() - } -} - -impl AdmissionFence for FakeFence { - fn close(&mut self) -> io::Result<()> { - unreachable!("an activation never closes the fence"); - } - - fn open(&mut self) { - self.opened.set(true); - } -} - -/// A clone's adapter in the tests, which takes what the host sends, as its session receives it, -/// through the clone's activation, behind a fake fence: it applies the record of the Activate that -/// the activation accepted through a fake system, with the tests' template, reports the result, -/// and spawns an execution for each request that the activation serves, once the fence is open. -struct Adapter { - /// The clone's activation. - activation: Activation, - /// The fence that the activation holds. - fence: FakeFence, - /// The system through which the adapter applies the record, which records what the clone did. - system: FakeSystem, - /// What the application activated: the root, the export's root, if any, the runtime's binds - /// on the root, the workload's hostname and machine ID, the resolver file, if any, the proxy's - /// endpoint, if any, and the gateway of the announcer of the workload's listeners, if any. - applied: Option>, - /// What the adapter's latest Ready reported. - ready: Option, -} - -impl Adapter { - /// Returns the adapter of a clone, in its transport bootstrap, which applies records through - /// `system`, and whose repair acknowledged its restore at `acknowledged`. - fn new(system: FakeSystem, acknowledged: Instant) -> Self { - let fence = FakeFence::default(); - let resources = ExpectedResources { - cpus: CPUS, - memory: MEMORY, - }; - let activation = Activation::with_resources( - resources, - Some(acknowledged), - fence.clone(), - TIMEOUT, - acknowledged, - ); - Self { - activation, - fence, - system, - applied: None, - ready: None, - } - } - - /// Returns the adapter of [`new`](Self::new) once the session's reset at `at` restricted its - /// activation. - fn restricted(system: FakeSystem, at: Instant) -> Self { - let mut adapter = Self::new(system, at); - adapter.take(at, ActivationInput::Reset).unwrap(); - assert_eq!(adapter.activation.state(), ActivationState::Restricted); - adapter - } - - /// Takes `input`, which the session received at `now`, as an adapter does, or returns the - /// activation's failure. - fn take(&mut self, now: Instant, input: ActivationInput<'_>) -> Result<(), ActivationFailure> { - match self.activation.input(now, input)? { - ActivationAction::Acknowledge | ActivationAction::Continue | ActivationAction::Join => { - } - ActivationAction::Apply(record) => { - let deadline = self.activation.deadline(); - let applied = apply_with(&mut self.system, &record, &template(), deadline); - let result = match applied { - Ok(applied) => { - self.applied = Some(applied); - Ok(()) - } - Err(error) => Err(error.into()), - }; - self.ready = Some(self.activation.complete(now, result)?); - } - ActivationAction::Ready(ready) => self.ready = Some(ready), - ActivationAction::Serve => { - assert!( - self.fence.is_open(), - "an execution spawned behind the fence" - ); - self.system.effects.push(Effect::Spawn); - } - } - Ok(()) - } - - /// Checks that the clone failed with `failure`, whose cause `expected` accepts, and did - /// nothing: it mounted nothing, read nothing, and spawned nothing, reported no Ready, and its - /// fence is closed. - fn assert_untouched(&self, failure: &ActivationFailure, expected: Expected) { - assert!(expected(failure.cause()), "{failure}"); - assert_eq!(self.activation.state(), ActivationState::Failed); - assert!(self.system.effects.is_empty(), "{:?}", self.system.effects); - assert_eq!(self.applied, None); - assert_eq!(self.ready, None); - assert!(!self.fence.is_open()); - } -} - -/// Returns the tests' template: its runtime directory, its devices at NVX's MMIO windows, with -/// the sizes that they must have, its timeout, its host filesystem device's tag, and its NIC's -/// gateway. -fn template() -> RootTemplate { - RootTemplate { - runtime: PathBuf::from(RUNTIME), - distro: Device { - mmio: DISTRO_MMIO_BASE, - sectors: Some(1 << 21), - }, - scratch: Device { - mmio: SCRATCH_MMIO_BASE, - sectors: Some(1 << 22), - }, - timeout: DEVICE_TIMEOUT, - filesystem_tag: TAG.to_owned(), - gateway: Some(GATEWAY), - } -} - -/// Returns an EROFS image, with the UUID `uuid` that the record expects of it, if any. -fn erofs(uuid: Option) -> activation::Image { - activation::Image::Erofs { uuid } -} - -/// Returns a GPT disk, with the GUID `disk_guid` that the record expects of it, if any, whose -/// first partition holds at most [`FIRST_PARTITION_BOUND`] bytes. -fn gpt(disk_guid: Option) -> activation::Image { - activation::Image::Gpt { - disk_guid, - first_partition_bound: FIRST_PARTITION_BOUND, - } -} - -/// Returns the image of the root's assembly of a GPT disk of [`gpt`]`(disk_guid)`. -fn gpt_image(disk_guid: Option) -> root::Image { - let expected = crate::gpt::Expected::new(); - root::Image::Gpt { - expected: match disk_guid { - Some(guid) => expected.disk_guid(guid), - None => expected, - }, - first_partition_bound: FIRST_PARTITION_BOUND as usize, - } -} - -/// Returns the scratch upper, with the journal policy `journal`. -fn scratch(journal: activation::Journal) -> activation::Upper { - activation::Upper::Scratch { journal } -} - -/// Returns the configuration of a root of `image` over `upper`, in `overlay` mode, which names -/// the view and expects the tests' clone's resources, without an -/// export or a network. -fn configuration( - image: activation::Image, - upper: activation::Upper, - overlay: activation::OverlayMode, -) -> Configuration { - Configuration { - image, - upper, - overlay, - export: None, - mappings: 0, - network: None, - view: RecordView { - hostname: HOSTNAME.to_vec(), - machine_id: MachineIdMode::Image, - }, - resources: ExpectedResources { - cpus: CPUS, - memory: MEMORY, - }, - } -} - -/// Returns the record of a clone's restore of the tests' transaction, of `configuration`. -fn clone_record(configuration: Configuration) -> ActivationRecord { - let restore = RestoreSection { - transaction: TRANSACTION, - }; - ActivationRecord::new(Some(restore), configuration).unwrap() -} - -/// Returns the record of the tests' Activate: an EROFS image, whose UUID it expects, over a -/// tmpfs capped at 50 percent, with untrusted lowers. -fn accepted() -> ActivationRecord { - clone_record(configuration( - erofs(Some(IMAGE_UUID)), - activation::Upper::Tmpfs { - max_memory_percent: 50, - }, - activation::OverlayMode::UntrustedLowers, - )) -} - -/// Returns the assembly of the root of the tests' Activate: its EROFS image, with the UUID that -/// the record expects of it, over a tmpfs, with untrusted lowers, with the template's runtime -/// directory, devices, and timeout. -fn accepted_assembly() -> Assembly { - let template = template(); - Assembly { - runtime: template.runtime, - distro: template.distro, - image: root::Image::Erofs { - uuid: Some(IMAGE_UUID), - }, - upper: root::Upper::Tmpfs { - max_memory_percent: Some(50), - }, - overlay: root::OverlayMode::UntrustedLowers, - timeout: template.timeout, - } -} - -/// Returns the record of the tests' Activate, as [`accepted`] returns it, but whose workload sees -/// a machine ID that the guest generates. -fn with_generated_id() -> ActivationRecord { - let mut configuration = accepted().configuration().clone(); - configuration.view.machine_id = MachineIdMode::Generated; - clone_record(configuration) -} - -/// Returns an export of `kind`. -fn export(kind: ExportKind) -> Export { - Export { kind } -} - -/// Returns a network whose resolver names `dns`, if any, and whose proxy is `proxy`, if any, -/// with published listeners. -fn network(dns: Option, proxy: Option<&str>) -> Network { - Network { - dns, - publication: Publication::Published, - proxy: proxy.map(str::to_owned), - } -} - -/// Returns a network as [`network`] does, but without published listeners. -fn unpublished(dns: Option, proxy: Option<&str>) -> Network { - Network { - publication: Publication::Unpublished, - ..network(dns, proxy) - } -} - -/// Returns the record of the tests' Activate, as [`with_generated_id`] returns it, but with an -/// aggregate export, of three mappings, and a network that names [`DNS`] and [`PROXY`], and -/// publishes the workload's listeners. -fn with_everything() -> ActivationRecord { - let mut configuration = with_generated_id().configuration().clone(); - configuration.export = Some(export(ExportKind::Aggregate)); - configuration.mappings = 3; - configuration.network = Some(network(Some(DNS), Some(PROXY))); - clone_record(configuration) -} - -/// Returns where the application writes the machine ID that it generates with the tests' -/// template. -fn machine_id_path() -> PathBuf { - Path::new(RUNTIME).join(MACHINE_ID_FILE) -} - -/// Returns the machine ID that the application generates with the tests' template. -fn generated_id() -> MachineId { - MachineId::Generated(machine_id_path()) -} - -/// Returns where the application writes the resolver file with the tests' template. -fn resolver_path() -> PathBuf { - Path::new(RUNTIME).join(RESOLVER_FILE) -} - -/// Returns the view that the application binds for a record of the tests' hostname, whose -/// workload sees `machine_id`, without a resolver file. -fn view(machine_id: MachineId) -> View { - View { - hostname: HOSTNAME.to_vec(), - machine_id, - resolver: None, - } -} - -/// Returns the view that the application binds for [`with_everything`]'s record: with the -/// generated machine ID and the resolver file. -fn full_view() -> View { - View { - resolver: Some(resolver_path()), - ..view(generated_id()) - } -} - -/// Returns what the application activates through `system` for a record of the tests' hostname, -/// whose workload sees `machine_id`, without an export or a network. -fn activated(system: &FakeSystem, machine_id: MachineId) -> Activated { - Activated { - root: system.root(), - export: None, - binds: (), - hostname: HOSTNAME.to_vec(), - machine_id, - resolver: None, - proxy: None, - announcer: None, - } -} - -/// Returns the effects of the application of [`with_everything`]'s record, which the system -/// records until it bound the runtime's files. -fn applied_everything() -> Vec { - vec![ - Effect::Assemble(accepted_assembly()), - Effect::Generate(machine_id_path()), - Effect::MountExport(TAG.to_owned(), Access::ReadWrite), - Effect::Resolver(resolver_path(), DNS), - Effect::Announce(GATEWAY), - Effect::Bind(full_view()), - ] -} - -/// Returns the application's error that `failure`, an activation's, holds. -fn apply_error(failure: &ActivationFailure) -> &ApplyError { - match failure.cause() { - ActivationCause::Failed(error) => error.downcast_ref::().unwrap(), - cause => panic!("{cause}"), - } -} - -#[test] -fn no_mount_spawn_or_id_generation_happens_before_the_gate() { - // The application reaches the system only through its calls, which the fake records in - // full: none starts a process but the announcer of the workload's listeners, and the adapter - // spawns each execution itself, once the activation serves its request. Every record asks - // for a generated machine ID, has an export, names a DNS server, and publishes the workload's - // listeners, which only the application of an accepted record generates, mounts, writes, and - // announces. - let base = Instant::now(); - let record = with_everything().encode(); - let settings = with_everything().configuration().clone(); - let cold = ActivationRecord::new(None, settings.clone()) - .unwrap() - .encode(); - let larger = clone_record(Configuration { - resources: ExpectedResources { - cpus: CPUS, - memory: MEMORY + (1 << 27), - }, - ..settings.clone() - }) - .encode(); - let disk = clone_record(Configuration { - image: gpt(None), - ..settings - }) - .encode(); - - // Before the session's reset, the host can send nothing. - let mut adapter = Adapter::new(FakeSystem::new(), base); - adapter.take(base, ActivationInput::Flow).unwrap(); - let failure = adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap_err(); - adapter.assert_untouched(&failure, |cause| matches!(cause, ActivationCause::Request)); - let mut adapter = Adapter::new(FakeSystem::new(), base); - let failure = adapter.take(base, ActivationInput::Request).unwrap_err(); - adapter.assert_untouched(&failure, |cause| matches!(cause, ActivationCause::Request)); - - // Once the session is restricted, a request or another reset fails, and so does an Activate - // whose record does not decode, is a cold boot's, or expects other resources. - let refused: [(ActivationInput<'_>, Expected); 5] = [ - (ActivationInput::Request, |cause| { - matches!(cause, ActivationCause::Request) - }), - (ActivationInput::Reset, |cause| { - matches!(cause, ActivationCause::Reset) - }), - ( - ActivationInput::Activate(&record[..record.len() - 1]), - |cause| matches!(cause, ActivationCause::Record(_)), - ), - (ActivationInput::Activate(&cold), |cause| { - matches!(cause, ActivationCause::Cold) - }), - (ActivationInput::Activate(&larger), |cause| { - matches!(cause, ActivationCause::Resources { .. }) - }), - ]; - for (input, expected) in refused { - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter.take(base, ActivationInput::Flow).unwrap(); - let failure = adapter.take(base, input).unwrap_err(); - adapter.assert_untouched(&failure, expected); - } - - // A GPT disk, which the activation accepted, whose partition table the kernel refuses to - // reread, fails the application before anything is mounted: the reread is its one call. - let mut adapter = Adapter::restricted(FakeSystem::refusing(Errno::EBUSY), base); - let failure = adapter - .take(base, ActivationInput::Activate(&disk)) - .unwrap_err(); - assert!( - matches!( - apply_error(&failure), - ApplyError::Gpt(root::Error::Device { - step: root::DeviceStep::Reread, - .. - }) - ), - "{failure}" - ); - assert_eq!(failure.state(), ActivationState::Activating); - assert_eq!(adapter.activation.state(), ActivationState::Failed); - assert_eq!(adapter.system.effects, [Effect::Reread(template().distro)]); - assert_eq!(adapter.applied, None); - assert_eq!(adapter.ready, None); - assert!(!adapter.fence.is_open()); - - // Past the deadline, even a valid Activate does nothing. - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - let failure = adapter - .take(base + TIMEOUT, ActivationInput::Activate(&record)) - .unwrap_err(); - adapter.assert_untouched(&failure, |cause| matches!(cause, ActivationCause::Expired)); - - // Only a valid Activate mounts the root, generates the machine ID, mounts the export, writes - // the resolver file, starts the announcer, and binds the runtime's files, before Ready, and - // an execution spawns only once Ready opened the fence. - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter.take(base, ActivationInput::Flow).unwrap(); - assert!(adapter.system.effects.is_empty()); - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - assert_eq!(adapter.system.effects, applied_everything()); - assert!(adapter.fence.is_open()); - adapter.take(base, ActivationInput::Request).unwrap(); - assert_eq!(adapter.system.effects.last(), Some(&Effect::Spawn)); -} - -#[test] -fn a_valid_activate_activates_the_root_and_the_workload_once_before_ready() { - let base = Instant::now(); - let record = accepted().encode(); - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - // The application assembled the root with the record's settings, before Ready, which reports - // that the activation applied, with the record's transaction. - assert_eq!(adapter.activation.state(), ActivationState::Ready); - assert_eq!( - adapter.ready, - Some(ReadyActivation { - transaction: Some(TRANSACTION), - }) - ); - assert_eq!( - adapter.applied, - Some(activated(&adapter.system, MachineId::Image)) - ); - assert!(adapter.fence.is_open()); - - // A later session, and a retry of the Activate in it, get the established activation - // without applying the record again; requests spawn executions. - adapter.take(base, ActivationInput::Request).unwrap(); - adapter.take(base, ActivationInput::Reset).unwrap(); - adapter.ready = None; - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - assert_eq!(adapter.ready, Some(ReadyActivation::of(&accepted()))); - adapter.take(base, ActivationInput::Request).unwrap(); - assert_eq!( - adapter.system.effects, - [ - Effect::Assemble(accepted_assembly()), - Effect::Bind(view(MachineId::Image)), - Effect::Spawn, - Effect::Spawn, - ] - ); - // The assembly waited for the devices until the activation's deadline at the latest. - assert_eq!(adapter.system.deadlines, [Some(base + TIMEOUT)]); -} - -#[test] -fn assembly_inputs_are_taken_exactly_from_the_record() { - let template = template(); - // Each image, with what the assembly expects of it, and whether the kernel rereads its - // partition table first. - let images = [ - (erofs(None), root::Image::Erofs { uuid: None }, false), - ( - erofs(Some(IMAGE_UUID)), - root::Image::Erofs { - uuid: Some(IMAGE_UUID), - }, - false, - ), - (gpt(None), gpt_image(None), true), - (gpt(Some(DISK_GUID)), gpt_image(Some(DISK_GUID)), true), - ]; - // The root receives the record's cap, including both ends of its range. - let uppers = [ - ( - activation::Upper::Tmpfs { - max_memory_percent: 1, - }, - root::Upper::Tmpfs { - max_memory_percent: Some(1), - }, - ), - ( - activation::Upper::Tmpfs { - max_memory_percent: 90, - }, - root::Upper::Tmpfs { - max_memory_percent: Some(90), - }, - ), - ( - scratch(activation::Journal::Allowed), - root::Upper::Scratch { - device: template.scratch, - journal: root::Journal::Allowed, - }, - ), - ( - scratch(activation::Journal::Forbidden), - root::Upper::Scratch { - device: template.scratch, - journal: root::Journal::Forbidden, - }, - ), - ]; - let overlays = [ - ( - activation::OverlayMode::TrustedLowers, - root::OverlayMode::TrustedLowers, - ), - ( - activation::OverlayMode::UntrustedLowers, - root::OverlayMode::UntrustedLowers, - ), - ]; - for (image, root_image, reread) in images { - for (upper, root_upper) in uppers { - for (overlay, root_overlay) in overlays { - let record = clone_record(configuration(image, upper, overlay)); - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &record, &template, None).unwrap(); - let assembly = Assembly { - runtime: template.runtime.clone(), - distro: template.distro, - image: root_image.clone(), - upper: root_upper, - overlay: root_overlay, - timeout: template.timeout, - }; - let mut effects = Vec::new(); - if reread { - effects.push(Effect::Reread(template.distro)); - } - effects.push(Effect::Assemble(assembly)); - effects.push(Effect::Bind(view(MachineId::Image))); - assert_eq!(system.effects, effects, "{record:?}"); - assert_eq!(applied, activated(&system, MachineId::Image)); - } - } - } - - // The record's other settings, which the application leaves alone, change no input: its - // export's shape, the number of its mappings, which the adapter's order takes, its resources, - // and its restore section. - let mut other = with_everything().configuration().clone(); - other.image = activation::Image::Erofs { - uuid: Some(IMAGE_UUID), - }; - other.export = Some(Export { - kind: ExportKind::Directory, - }); - other.mappings = 7; - other.resources = ExpectedResources { - cpus: 8, - memory: 1 << 33, - }; - let other = ActivationRecord::new(None, other).unwrap(); - let (mut first, mut second) = (FakeSystem::new(), FakeSystem::new()); - let first_applied = apply_with(&mut first, &with_everything(), &template, None).unwrap(); - let second_applied = apply_with(&mut second, &other, &template, None).unwrap(); - assert_eq!(first.effects, applied_everything()); - assert_eq!(first.effects, second.effects); - assert_eq!( - first_applied, - Activated { - root: first.root(), - ..second_applied - } - ); -} - -#[test] -fn a_gpt_disk_s_partition_table_is_reread_before_its_root_is_assembled() { - // The kernel rereads the table from the clone's disk before the assembly reads and checks - // it, as at a cold boot, where it read the table when it found the disk; the clone then - // activates as with an EROFS image, over a fresh upper. - let base = Instant::now(); - let record = clone_record(configuration( - gpt(Some(DISK_GUID)), - scratch(activation::Journal::Forbidden), - activation::OverlayMode::UntrustedLowers, - )); - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter - .take(base, ActivationInput::Activate(&record.encode())) - .unwrap(); - let template = template(); - let assembly = Assembly { - runtime: template.runtime.clone(), - distro: template.distro, - image: gpt_image(Some(DISK_GUID)), - upper: root::Upper::Scratch { - device: template.scratch, - journal: root::Journal::Forbidden, - }, - overlay: root::OverlayMode::UntrustedLowers, - timeout: template.timeout, - }; - assert_eq!( - adapter.system.effects, - [ - Effect::Reread(template.distro), - Effect::Assemble(assembly), - Effect::Bind(view(MachineId::Image)), - ] - ); - // The reread waited for the distro device, and the assembly for each device, until the - // activation's deadline at the latest. - assert_eq!(adapter.system.deadlines, [Some(base + TIMEOUT); 2]); - assert_eq!(adapter.activation.state(), ActivationState::Ready); - assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); - assert!(adapter.fence.is_open()); -} - -#[test] -fn the_application_leaves_the_images_accounts_to_each_execution() { - // Each execution names its own identity, whose entry checks its account. Activation reads - // no account database, including one that is absent or malformed. - let base = Instant::now(); - let record = accepted().encode(); - let without_passwd = FakeSystem::new(); - fs::remove_file(without_passwd.image.path().join("etc/passwd")).unwrap(); - let systems = [ - FakeSystem::new(), - FakeSystem::with(b"root:x:0:0:root:/root:/bin/sh\n", GROUP), - FakeSystem::with(b"workload:x:1000:1002::/home/workload:/bin/sh\n", GROUP), - FakeSystem::with(PASSWD, b"root:x:0:\n"), - FakeSystem::with(b"workload:x:1000:1001::home:/bin/sh\n", GROUP), - FakeSystem::with(b"", b""), - without_passwd, - ]; - for system in systems { - let mut adapter = Adapter::restricted(system, base); - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - assert_eq!(adapter.activation.state(), ActivationState::Ready); - assert_eq!(adapter.ready, Some(ReadyActivation::of(&accepted()))); - assert_eq!( - adapter.applied, - Some(activated(&adapter.system, MachineId::Image)) - ); - assert_eq!( - adapter.system.effects, - [ - Effect::Assemble(accepted_assembly()), - Effect::Bind(view(MachineId::Image)), - ] - ); - assert!(adapter.fence.is_open()); - } -} - -#[test] -fn a_failed_assembly_is_final_and_reads_and_tears_down_nothing() { - let base = Instant::now(); - let record = accepted().encode(); - let mut adapter = Adapter::restricted(FakeSystem::failing(), base); - let failure = adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap_err(); - let ApplyError::Assembly(error) = apply_error(&failure) else { - panic!("{failure}"); - }; - assert!( - matches!(error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx")), - "{error}" - ); - assert!(error.cleanup().is_none()); - assert_eq!( - adapter.system.effects, - [Effect::Assemble(accepted_assembly())] - ); - assert_eq!(adapter.applied, None); - assert_eq!(adapter.ready, None); - assert!(!adapter.fence.is_open()); - assert!( - adapter - .take(base, ActivationInput::Activate(&record)) - .is_err() - ); - assert_eq!(adapter.system.effects.len(), 1); -} - -#[test] -fn a_generated_machine_id_is_generated_once_after_the_gate_for_every_execution() { - // The application generates the machine ID once the activation accepted the record, after - // the root's assembly, in the template's runtime directory, and binds it for every - // execution, through the entry of the record's hostname. - let base = Instant::now(); - let record = with_generated_id(); - let bytes = record.encode(); - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter - .take(base, ActivationInput::Activate(&bytes)) - .unwrap(); - let expected = [ - Effect::Assemble(accepted_assembly()), - Effect::Generate(machine_id_path()), - Effect::Bind(view(generated_id())), - ]; - assert_eq!(adapter.system.effects, expected); - assert_eq!( - adapter.applied, - Some(activated(&adapter.system, generated_id())) - ); - assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); - assert_eq!(machine_id_path(), Path::new("/run/nvx/workload-machine-id")); - - // Every execution, a retry of the Activate, and a retry in a later session reuse it: nothing - // generates or binds it again. - adapter.take(base, ActivationInput::Request).unwrap(); - adapter - .take(base, ActivationInput::Activate(&bytes)) - .unwrap(); - adapter.take(base, ActivationInput::Reset).unwrap(); - adapter.ready = None; - adapter - .take(base, ActivationInput::Activate(&bytes)) - .unwrap(); - assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); - adapter.take(base, ActivationInput::Request).unwrap(); - assert_eq!(adapter.system.generated(), 1); - assert_eq!( - adapter.system.effects[expected.len()..], - [Effect::Spawn, Effect::Spawn] - ); - - // The image's own machine ID generates nothing: its view, which the entry binds nothing - // for, leaves the image's `/etc/machine-id` alone. - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &accepted(), &template(), None).unwrap(); - assert_eq!(system.generated(), 0); - assert_eq!(applied, activated(&system, MachineId::Image)); -} - -#[test] -fn the_view_s_entry_takes_the_record_s_hostname_and_machine_id() { - // The entry through which the application binds the runtime's files checks the record's - // hostname, which every execution's entry takes, and refuses one that no execution could - // set. - let view_entry = |hostname: &[u8]| { - let named = View { - hostname: hostname.to_vec(), - ..view(MachineId::Image) - }; - named.entry(Path::new(RUNTIME)) - }; - view_entry(HOSTNAME).check().unwrap(); - for refused in [&b""[..], &[b'h'; 65], b"a\0b"] { - let refusal = view_entry(refused).check(); - assert!( - matches!(refusal, Err(entry::Error::Hostname)), - "{refused:?}: {refusal:?}" - ); - } - - // It binds a generated machine ID, a resolver file, or both, and nothing for the image's own - // machine ID without a resolver file: only the former open the root, which a temporary - // directory, not the root of a mount, fails. - let directory = tempfile::tempdir().unwrap(); - let image = view(MachineId::Image).entry(directory.path()); - image.bind_runtime().unwrap().remove().unwrap(); - let resolver = View { - resolver: Some(resolver_path()), - ..view(MachineId::Image) - }; - for bound in [view(generated_id()), resolver, full_view()] { - let error = bound.entry(directory.path()).bind_runtime().unwrap_err(); - assert!(matches!(error, entry::Error::Root(_)), "{bound:?}: {error}"); - } -} - -#[test] -fn a_machine_id_or_a_view_that_fails_fails_the_activation_once_the_root_is_torn_down() { - let base = Instant::now(); - let bytes = with_generated_id().encode(); - // A file at the machine ID's path already, as once a boot generated its ID, and a bind that - // the image refuses, as one whose `/etc/machine-id` is a directory. - let generation = Effect::Generate(machine_id_path()); - let bind = Effect::Bind(view(generated_id())); - let cases = [ - ( - FakeSystem::generating(io::ErrorKind::AlreadyExists), - vec![generation.clone()], - ), - ( - FakeSystem::unbindable(|| entry::Error::MachineId(MappingError::TargetKind)), - vec![generation, bind], - ), - ]; - for (system, effects) in cases { - let mut adapter = Adapter::restricted(system, base); - let failure = adapter - .take(base, ActivationInput::Activate(&bytes)) - .unwrap_err(); - assert_eq!(failure.state(), ActivationState::Activating); - match apply_error(&failure) { - ApplyError::MachineId { - error, - teardown: None, - } => assert_eq!(error.kind(), io::ErrorKind::AlreadyExists), - ApplyError::View { - error: entry::Error::MachineId(MappingError::TargetKind), - teardown: None, - } => {} - error => panic!("{error}"), - } - // The root was assembled, then torn down, without a Ready, and behind the closed fence. - let mut expected = vec![Effect::Assemble(accepted_assembly())]; - expected.extend(effects); - expected.push(Effect::Teardown); - assert_eq!(adapter.system.effects, expected); - assert_eq!(adapter.activation.state(), ActivationState::Failed); - assert_eq!(adapter.applied, None); - assert_eq!(adapter.ready, None); - assert!(!adapter.fence.is_open()); - // The failure is final: no retry applies the record again. - assert!( - adapter - .take(base, ActivationInput::Activate(&bytes)) - .is_err() - ); - assert_eq!(adapter.system.effects, expected); - } -} - -#[test] -fn the_runtime_s_binds_are_removed_before_the_root_is_torn_down() { - // The binds keep the root's mount busy, so they go first, and the export's root, which keeps - // the export's mount busy, before them, unless the adapter took it, and the announcer before - // anything. - let mut system = FakeSystem::new(); - let root = system.root(); - teardown_with(&mut system, None, None, (), root).unwrap(); - assert_eq!(system.effects, [Effect::Remove, Effect::Teardown]); - let mut system = FakeSystem::new(); - let root = system.root(); - teardown_with(&mut system, None, Some(()), (), root).unwrap(); - assert_eq!( - system.effects, - [Effect::Close, Effect::Remove, Effect::Teardown] - ); - let mut system = FakeSystem::new(); - let root = system.root(); - teardown_with(&mut system, Some(GATEWAY), Some(()), (), root).unwrap(); - assert_eq!( - system.effects, - [ - Effect::Stop, - Effect::Close, - Effect::Remove, - Effect::Teardown - ] - ); - - // A removal that fails leaves those binds mounted, and the teardown is attempted all the - // same; the error reports both. - let mut system = FakeSystem::stuck(); - let root = system.root(); - let error = teardown_with(&mut system, None, Some(()), (), root).unwrap_err(); - assert_eq!( - system.effects, - [Effect::Close, Effect::Remove, Effect::Teardown] - ); - assert!(error.root().is_none()); - let binds = error.binds().unwrap(); - assert_eq!(binds.failures().len(), 1); - assert_eq!(binds.failures()[0].target(), Path::new("/etc/machine-id")); - assert_eq!(error.to_string(), binds.to_string()); - assert!( - error - .to_string() - .starts_with("failed to unmount the mapping at /etc/machine-id: "), - "{error}" - ); -} - -#[test] -fn an_export_is_mounted_through_the_template_s_device_and_handed_to_the_adapter() { - // A record's export, of either shape, is mounted once, before Ready, read-write, through the - // template's filesystem device, once the root was assembled, and the adapter gets its root, - // to map the host's mapping table from. - let base = Instant::now(); - for kind in [ExportKind::Directory, ExportKind::Aggregate] { - let mut configuration = accepted().configuration().clone(); - configuration.export = Some(export(kind)); - configuration.mappings = 2; - let record = clone_record(configuration).encode(); - let mut adapter = Adapter::restricted(FakeSystem::new(), base); - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - let expected = [ - Effect::Assemble(accepted_assembly()), - Effect::MountExport(TAG.to_owned(), Access::ReadWrite), - Effect::Bind(view(MachineId::Image)), - ]; - assert_eq!(adapter.system.effects, expected, "{kind:?}"); - assert_eq!( - adapter.applied, - Some(Activated { - export: Some(()), - ..activated(&adapter.system, MachineId::Image) - }) - ); - // A retry and an execution mount nothing again. - adapter - .take(base, ActivationInput::Activate(&record)) - .unwrap(); - adapter.take(base, ActivationInput::Request).unwrap(); - assert_eq!(adapter.system.effects[expected.len()..], [Effect::Spawn]); - } - - // A record without an export mounts none. - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &accepted(), &template(), None).unwrap(); - assert_eq!(applied.export, None); - assert!( - !system - .effects - .iter() - .any(|effect| matches!(effect, Effect::MountExport(..))), - "{:?}", - system.effects - ); -} - -#[test] -fn a_network_s_dns_server_is_written_to_a_resolver_file_that_every_execution_sees() { - // A network that names a DNS server, IPv4 or IPv6, has the application write a resolver file - // that names it in the template's runtime directory, and bind it with the runtime's other - // files, in the one bind; the network's proxy endpoint goes back to the adapter. - let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1)); - for dns in [DNS, ipv6] { - let mut configuration = accepted().configuration().clone(); - configuration.network = Some(unpublished(Some(dns), Some(PROXY))); - let record = clone_record(configuration); - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &record, &template(), None).unwrap(); - let bound = View { - resolver: Some(resolver_path()), - ..view(MachineId::Image) - }; - let expected = [ - Effect::Assemble(accepted_assembly()), - Effect::Resolver(resolver_path(), dns), - Effect::Bind(bound), - ]; - assert_eq!(system.effects, expected, "{dns}"); - assert_eq!( - applied, - Activated { - resolver: Some(resolver_path()), - proxy: Some(PROXY.to_owned()), - ..activated(&system, MachineId::Image) - } - ); - } - - // A network without a DNS server, whether or not it names a proxy, writes no resolver file, - // so that every execution sees the image's own, and the image's own machine ID binds - // nothing. - for proxy in [None, Some(PROXY)] { - let mut configuration = accepted().configuration().clone(); - configuration.network = Some(unpublished(None, proxy)); - let record = clone_record(configuration); - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &record, &template(), None).unwrap(); - let expected = [ - Effect::Assemble(accepted_assembly()), - Effect::Bind(view(MachineId::Image)), - ]; - assert_eq!(system.effects, expected, "{proxy:?}"); - assert_eq!( - applied, - Activated { - proxy: proxy.map(str::to_owned), - ..activated(&system, MachineId::Image) - } - ); - } -} - -#[test] -fn an_export_or_a_network_that_fails_fails_the_activation_once_the_root_is_torn_down() { - // The export's mount fails with the export's class, and the resolver file's write and bind, - // and the announcer's start, with the network's, while the machine ID's bind fails with the - // view's, each once the application stopped the announcer, if it started it, closed the - // export's root, if it mounted the export, and tore the root down, which unmounts the export. - let base = Instant::now(); - let bytes = with_everything().encode(); - let failed = |done: usize, stopped: bool, closed: bool| { - let mut effects = applied_everything()[..done].to_vec(); - if stopped { - effects.push(Effect::Stop); - } - if closed { - effects.push(Effect::Close); - } - effects.push(Effect::Teardown); - effects - }; - let cases: [(FakeSystem, Vec, Failed); 6] = [ - ( - FakeSystem::unmountable(), - failed(3, false, false), - |error| { - matches!( - error, - ApplyError::Export { - error: root::Error::Mount { - mount: root::Mount::Export { tag }, - .. - }, - teardown: None, - } if tag == TAG - ) - }, - ), - ( - FakeSystem::unwritable(io::ErrorKind::AlreadyExists), - failed(4, false, true), - |error| { - matches!( - error, - ApplyError::Network { - error: NetworkError::Write(error), - teardown: None, - } if error.kind() == io::ErrorKind::AlreadyExists - ) - }, - ), - ( - FakeSystem::unannounced(|| StartError::NoPrivilegedPort), - failed(5, false, true), - |error| { - matches!( - error, - ApplyError::Network { - error: NetworkError::Announcer(StartError::NoPrivilegedPort), - teardown: None, - } - ) - }, - ), - ( - FakeSystem::unbindable(|| entry::Error::Resolver(MappingError::TargetKind)), - failed(6, true, true), - |error| { - matches!( - error, - ApplyError::Network { - error: NetworkError::Bind(entry::Error::Resolver(MappingError::TargetKind)), - teardown: None, - } - ) - }, - ), - // A resolver's bind that failed once the machine ID's bind stayed mounted. - ( - FakeSystem::unbindable(|| entry::Error::Rollback { - error: Box::new(entry::Error::Resolver(MappingError::TargetKind)), - teardown: mappings::TeardownError::of("/etc/machine-id", MappingError::MountPoint), - }), - failed(6, true, true), - |error| { - matches!( - error, - ApplyError::Network { - error: NetworkError::Bind(entry::Error::Rollback { .. }), - teardown: None, - } - ) - }, - ), - ( - FakeSystem::unbindable(|| entry::Error::MachineId(MappingError::TargetKind)), - failed(6, true, true), - |error| { - matches!( - error, - ApplyError::View { - error: entry::Error::MachineId(MappingError::TargetKind), - teardown: None, - } - ) - }, - ), - ]; - for (system, effects, expected) in cases { - let mut adapter = Adapter::restricted(system, base); - let failure = adapter - .take(base, ActivationInput::Activate(&bytes)) - .unwrap_err(); - let error = apply_error(&failure); - assert!(expected(error), "{error}"); - assert_eq!(adapter.system.effects, effects, "{error}"); - assert_eq!(adapter.activation.state(), ActivationState::Failed); - assert_eq!(adapter.applied, None); - assert_eq!(adapter.ready, None); - assert!(!adapter.fence.is_open()); - } -} - -#[test] -fn a_network_that_publishes_the_workload_s_listeners_starts_their_announcer_to_its_gateway() { - // A network that publishes the workload's listeners has the application start their - // announcer, to the template's gateway, once it wrote the resolver file, and before it binds - // the runtime's files, and hand it to the adapter, which watches it; a network that publishes - // none, and a clone without a network, start none. - let mut configuration = accepted().configuration().clone(); - configuration.network = Some(network(None, Some(PROXY))); - let published = clone_record(configuration.clone()); - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &published, &template(), None).unwrap(); - let bound = Effect::Bind(view(MachineId::Image)); - let assembled = [Effect::Assemble(accepted_assembly())]; - assert_eq!( - system.effects, - [&assembled[..], &[Effect::Announce(GATEWAY), bound.clone()]].concat() - ); - assert_eq!( - applied, - Activated { - proxy: Some(PROXY.to_owned()), - announcer: Some(GATEWAY), - ..activated(&system, MachineId::Image) - } - ); - configuration.network = Some(unpublished(None, Some(PROXY))); - for record in [clone_record(configuration), accepted()] { - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &record, &template(), None).unwrap(); - assert_eq!( - system.effects, - [&assembled[..], std::slice::from_ref(&bound)].concat() - ); - assert_eq!(applied.announcer, None); - } - - // A template without a gateway, as one without a NIC, cannot announce: the record that - // publishes listeners fails with the network's class, once the root was torn down, and one - // that publishes none applies. - let gatewayless = RootTemplate { - gateway: None, - ..template() - }; - let mut system = FakeSystem::new(); - let error = apply_with(&mut system, &published, &gatewayless, None).unwrap_err(); - assert!( - matches!( - error, - ApplyError::Network { - error: NetworkError::Gateway, - teardown: None, - } - ), - "{error}" - ); - assert_eq!( - system.effects, - [&assembled[..], &[Effect::Teardown]].concat() - ); - let mut system = FakeSystem::new(); - let applied = apply_with(&mut system, &accepted(), &gatewayless, None).unwrap(); - assert_eq!(applied.announcer, None); -} - -/// A tmpfs that stands for a clone's root, mounted at `root` below a temporary directory, in a -/// private mount namespace of the calling thread, beside a directory that stands for the -/// template's runtime directory, `runtime`. -struct LiveRoot { - /// The temporary directory, which the drop removes once it detached the tmpfs. - directory: Option, - /// The tmpfs's mount point. - root: PathBuf, - /// The runtime directory. - runtime: PathBuf, -} - -impl LiveRoot { - /// Returns a root whose `/etc/machine-id` holds [`IMAGE_MACHINE_ID`], and whose - /// `/etc/resolv.conf` holds [`IMAGE_RESOLVER`], or `None` where the test cannot run: without - /// root, or without unique mount IDs, which Linux 6.8 added. - fn new() -> Option { - if !nix::unistd::geteuid().is_root() { - report("skipped: the test needs root"); - return None; - } - sys::enter_private_mount_namespace().unwrap(); - let directory = tempfile::tempdir().unwrap(); - let base = directory.path().canonicalize().unwrap(); - let live = Self { - directory: Some(directory), - root: base.join("root"), - runtime: base.join("runtime"), - }; - fs::create_dir(&live.root).unwrap(); - fs::create_dir(&live.runtime).unwrap(); - let root = sys::path_c_string(&live.root).unwrap(); - let tmpfs = Some(c"tmpfs"); - let options = Some(c"mode=0755"); - sys::mount(tmpfs, &root, tmpfs, MountFlags::empty(), options).unwrap(); - fs::create_dir(live.root.join("etc")).unwrap(); - fs::write(live.root.join("etc/machine-id"), IMAGE_MACHINE_ID).unwrap(); - fs::write(live.root.join("etc/resolv.conf"), IMAGE_RESOLVER).unwrap(); - let flags = OpenFlags::O_PATH | OpenFlags::O_CLOEXEC; - match sys::file_status(&sys::open(&root, flags, Mode::empty()).unwrap()) { - Err(Errno::EOPNOTSUPP) => { - report("skipped: the kernel has no unique mount IDs"); - None - } - status => { - assert!(status.unwrap().mount_root); - Some(live) - } - } - } -} - -impl Drop for LiveRoot { - /// Detaches the tmpfs, with what is mounted below it, then removes the directory. - fn drop(&mut self) { - let _ = nix::mount::umount2(&self.root, nix::mount::MntFlags::MNT_DETACH); - drop(self.directory.take()); - } -} - -/// The machine ID of the image of the privileged test's root. -const IMAGE_MACHINE_ID: &[u8] = b"0123456789abcdef0123456789abcdef\n"; - -/// The resolver file of the image of the privileged test's root. -const IMAGE_RESOLVER: &[u8] = b"nameserver 192.0.2.53\n"; - -/// Writes `message` to the standard error, past the test harness's capture, as the guest's -/// programs write theirs. -fn report(message: &str) { - use std::io::Write; - - let _ = writeln!(io::stderr(), "{message}"); -} - -#[test] -#[ignore = "needs root and Linux 6.8 or later"] -fn a_generated_machine_id_and_a_resolver_file_keep_the_root_busy_until_their_binds_are_removed() { - // With the kernel itself: the live system generates the machine ID in the runtime directory - // once, writes the resolver file there once, and binds both read-only over the root's - // `/etc/machine-id` and `/etc/resolv.conf` through the view's entry, in the calling thread's - // mount namespace, of which an execution's starts as a copy. - let Some(live) = LiveRoot::new() else { - return; - }; - let mut system = LiveSystem; - let path = live.runtime.join(MACHINE_ID_FILE); - let machine_id = system.generate(&path).unwrap(); - assert_eq!(machine_id, MachineId::Generated(path.clone())); - let generated = fs::read(&path).unwrap(); - assert_eq!(generated.len(), IMAGE_MACHINE_ID.len()); - assert_ne!(generated, IMAGE_MACHINE_ID); - let again = system.generate(&path).unwrap_err(); - assert_eq!(again.kind(), io::ErrorKind::AlreadyExists); - let resolver = live.runtime.join(RESOLVER_FILE); - system.write_resolver(&resolver, DNS).unwrap(); - let again = system.write_resolver(&resolver, DNS).unwrap_err(); - assert_eq!(again.kind(), io::ErrorKind::AlreadyExists); - let bound = View { - resolver: Some(resolver), - ..view(machine_id) - }; - let binds = bound.entry(&live.root).bind_runtime().unwrap(); - let targets = [ - ( - live.root.join("etc/machine-id"), - generated, - IMAGE_MACHINE_ID, - ), - ( - live.root.join("etc/resolv.conf"), - b"nameserver 10.0.0.1\n".to_vec(), - IMAGE_RESOLVER, - ), - ]; - for (target, shown, _) in &targets { - assert_eq!(fs::read(target).unwrap(), *shown, "{}", target.display()); - let writing = fs::OpenOptions::new().write(true).open(target); - assert_eq!( - writing.unwrap_err().raw_os_error(), - Some(Errno::EROFS as i32), - "{}", - target.display() - ); - } - - // The binds keep the root's mount busy, so that the root's teardown, which detaches no busy - // mount, fails until the binds are removed, which shows the image's own files again. - let root = sys::path_c_string(&live.root).unwrap(); - assert_eq!(sys::unmount(&root), Err(Errno::EBUSY)); - system.remove(binds).unwrap(); - for (target, _, image) in &targets { - assert_eq!(fs::read(target).unwrap(), *image, "{}", target.display()); - } - sys::unmount(&root).unwrap(); -} - -#[test] -fn the_application_refuses_without_privileges_what_it_cannot_assemble() { - // A template's runtime directory reaches the assembly, which refuses a relative one before it - // does anything else. - let relative = RootTemplate { - runtime: PathBuf::from("run/nvx"), - ..template() - }; - let Err(ApplyError::Assembly(error)) = apply(&accepted(), &relative, None) else { - panic!("a relative runtime directory was assembled"); - }; - assert!( - matches!(error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx")), - "{error}" - ); - - // A GPT disk's partition table is reread before that refusal: on a host without the - // template's distro device, the reread fails at its one poll, with nothing mounted. - let disk = clone_record(configuration( - gpt(None), - activation::Upper::Tmpfs { - max_memory_percent: 50, - }, - activation::OverlayMode::TrustedLowers, - )); - let at_once = RootTemplate { - timeout: Duration::ZERO, - ..relative - }; - let Err(ApplyError::Gpt(error)) = apply(&disk, &at_once, None) else { - panic!("a GPT disk was assembled before its partition table was reread"); - }; - // A host that hides its MMIO windows fails the lookup instead. - assert!( - matches!( - error, - root::Error::Missing { - role: root::Role::Distro, - mmio: DISTRO_MMIO_BASE, - } | root::Error::Lookup { - role: root::Role::Distro, - mmio: DISTRO_MMIO_BASE, - .. - } - ), - "{error}" - ); -} - -#[test] -fn application_errors_name_their_cause() { - let missing = root::Error::Missing { - role: root::Role::Distro, - mmio: DISTRO_MMIO_BASE, - }; - let refusal = missing.to_string(); - let error = ApplyError::Gpt(missing); - assert_eq!( - error.to_string(), - format!("the reread of the GPT disk's partition table failed: {refusal}") - ); - assert_eq!(error.source().unwrap().to_string(), refusal); - - let relative = Assembly { - runtime: PathBuf::from("run/nvx"), - ..accepted_assembly() - }; - let refusal = root::assemble(&relative).unwrap_err().to_string(); - let error = ApplyError::Assembly(root::assemble(&relative).unwrap_err()); - assert_eq!( - error.to_string(), - format!("the root's assembly failed: {refusal}") - ); - assert_eq!(error.source().unwrap().to_string(), refusal); - - let exists = io::Error::from(io::ErrorKind::AlreadyExists); - let error = ApplyError::MachineId { - error: io::ErrorKind::AlreadyExists.into(), - teardown: None, - }; - assert_eq!( - error.to_string(), - format!("the workload's machine ID cannot be generated: {exists}") - ); - assert_eq!(error.source().unwrap().to_string(), exists.to_string()); - - let error = ApplyError::View { - error: entry::Error::Hostname, - teardown: None, - }; - assert_eq!( - error.to_string(), - "the workload's hostname or machine ID cannot be set: the hostname is invalid" - ); - assert_eq!( - error.source().unwrap().to_string(), - "the hostname is invalid" - ); - - let unmounted = root::Error::Mount { - mount: root::Mount::Export { - tag: TAG.to_owned(), - }, - error: Errno::ENOENT.into(), - }; - let refusal = unmounted.to_string(); - let error = ApplyError::Export { - error: unmounted, - teardown: None, - }; - assert_eq!( - error.to_string(), - format!("the host export cannot be mounted: {refusal}") - ); - assert_eq!(error.source().unwrap().to_string(), refusal); - - let error = ApplyError::Network { - error: NetworkError::Write(io::ErrorKind::AlreadyExists.into()), - teardown: None, - }; - assert_eq!( - error.to_string(), - format!( - "the network settings cannot be applied: the resolver file cannot be written: {exists}" - ) - ); - let source = error.source().unwrap(); - assert_eq!( - source.to_string(), - format!("the resolver file cannot be written: {exists}") - ); - assert_eq!(source.source().unwrap().to_string(), exists.to_string()); - let unbound = entry::Error::Resolver(MappingError::TargetKind); - let refusal = unbound.to_string(); - let error = ApplyError::Network { - error: NetworkError::Bind(unbound), - teardown: None, - }; - assert!( - refusal.starts_with("cannot bind the resolver file: "), - "{refusal}" - ); - assert_eq!( - error.to_string(), - format!("the network settings cannot be applied: {refusal}") - ); - let source = error.source().unwrap(); - assert_eq!(source.to_string(), refusal); - assert_eq!( - source.source().unwrap().to_string(), - MappingError::TargetKind.to_string() - ); - let error = ApplyError::Network { - error: NetworkError::Gateway, - teardown: None, - }; - assert_eq!( - error.to_string(), - "the network settings cannot be applied: the network publishes the workload's listeners, \ - but the template names no IPv4 gateway to announce them to" - ); - assert!(error.source().unwrap().source().is_none()); - let denied = io::Error::from(Errno::EACCES); - let error = ApplyError::Network { - error: NetworkError::Announcer(StartError::Bind(Errno::EACCES.into())), - teardown: None, - }; - assert_eq!( - error.to_string(), - format!( - "the network settings cannot be applied: the announcer's socket cannot be bound to a \ - privileged source port: {denied}" - ) - ); - let source = error.source().unwrap(); - assert_eq!(source.source().unwrap().to_string(), denied.to_string()); - - // The adapter hands the error to the activation, whose failure names it. - let base = Instant::now(); - let generating = FakeSystem::generating(io::ErrorKind::AlreadyExists); - let mut adapter = Adapter::restricted(generating, base); - let failure = adapter - .take( - base, - ActivationInput::Activate(&with_generated_id().encode()), - ) - .unwrap_err(); - assert_eq!( - failure.to_string(), - format!( - "the activation failed in its activating state: the record's application failed: the \ - workload's machine ID cannot be generated: {exists}" - ) - ); - assert!(failure.source().unwrap().is::()); -} diff --git a/nvx-rs/src/guest/ingress/announcer.rs b/nvx-rs/src/guest/ingress/announcer.rs index d5c0cadde..c10866cfa 100644 --- a/nvx-rs/src/guest/ingress/announcer.rs +++ b/nvx-rs/src/guest/ingress/announcer.rs @@ -1,4 +1,4 @@ -//! The announcer of a clone's listeners, which the clone's application starts, as the +//! The announcer of a clone's listeners, which its caller starts after application, as the //! [module documentation](super) describes. use std::ffi::{CStr, CString}; diff --git a/nvx-rs/src/guest/ingress/mod.rs b/nvx-rs/src/guest/ingress/mod.rs index c91de1433..0e3c96d04 100644 --- a/nvx-rs/src/guest/ingress/mod.rs +++ b/nvx-rs/src/guest/ingress/mod.rs @@ -7,8 +7,8 @@ //! A clone of a template restores the template's NIC, which the template //! captured under a policy that publishes nothing, so its init started no //! announcer, with a policy of its own, which may publish the clone's -//! listeners. The clone's application then starts the announcer itself, with -//! [`Announcer::start`]: as root, it binds the announcer's UDP socket to a +//! listeners. After the process-free application succeeds, its caller starts +//! the announcer with [`Announcer::start`]: as root, it binds its UDP socket to a //! privileged source port, and runs `nvx-ingress inherit GATEWAY_IPV4`, which //! announces from that socket, its standard input, rather than from one of its //! own. A failure to bind the socket, or to run the program, is the start's diff --git a/nvx-rs/src/guest/mappings/mapper/tests.rs b/nvx-rs/src/guest/mappings/mapper/tests.rs index b4d5263d0..1300ca16c 100644 --- a/nvx-rs/src/guest/mappings/mapper/tests.rs +++ b/nvx-rs/src/guest/mappings/mapper/tests.rs @@ -23,19 +23,6 @@ impl Mapped { } } -impl TeardownError { - /// Returns the error of a teardown that failed to unmount the mapping at `target`, an - /// absolute path in the root, with `error`, for the tests of other modules. - pub(crate) fn of(target: impl Into, error: MappingError) -> Self { - Self { - failures: vec![UnmountFailure { - target: target.into(), - error, - }], - } - } -} - /// A target root at `root` and a source root at `export`, below a temporary directory, each on a /// mount of the fake kernel's. struct Fixture { diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 7a6286f09..b5833c5e4 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -8,7 +8,7 @@ //! mounts, and its account of a host's mapping table; [`exec`], which drives each program as one //! execution, with its launch, its bounded output and input, its ending, and its outcome, alone //! or, through its [`binding`](exec::binding), under the [execution engine](crate::exec); and, -//! on x86-64, `clone`, through which a template's guest prepares for and requests a snapshot's +//! on x86-64, `activation`, through which a template's guest prepares for and requests a snapshot's //! capture before its root's assembly, behind the caller's fence of workload admission, with //! nothing mounted from the devices that its clones replace, and through which each clone that a //! restore of the snapshot starts repairs its restore: it activates the restore's memory target, @@ -24,8 +24,8 @@ //! machine ID, and resolver file, whose binds the root owns; `gpt`, which reads the partition //! table of a GPT disk that the kernel attached as a block device; the mapper of `mappings`, which //! mounts host mappings beneath the descriptors of the roots that it names; and the activation's -//! application, `clone::apply` and its types, with which a clone assembles its root and checks -//! its workload identity before it admits any execution. The contracts that every space reads +//! application, `activation::apply` and its types, with which a clone assembles its root and +//! applies its VM-wide view without starting a process. The contracts that every space reads //! stay public: the mapping values and [`MappingTable`](mappings::MappingTable), and the tokens //! and records of [`protocol`](crate::protocol) and [`activation`](crate::activation). //! @@ -35,7 +35,7 @@ //! cold-boot step, capture, and restore finish, and [`reseed`] reseeds the kernel's random number //! generator with a restore's fresh entropy, which starts with the new [`GenerationId`]. And //! [`ingress`] starts its program, the announcer of the guest's listeners, from a socket that it -//! binds for it, as a clone's application does. The +//! binds for it. Activation reports the publication and gateway to its caller instead. The //! libraries need none of NVX's control protocol, managed agent, or workload identity, and NVX's //! managed agent opens its control terminal, applies its host mapping table, and supervises its //! workloads through them. On x86-64, the hidden `init` module holds @@ -102,9 +102,12 @@ mod kernel_text; pub use generation::GenerationId; +#[cfg(target_arch = "x86_64")] +pub mod activation; pub mod cgroup; +/// Compatibility name for the guest's activation and legacy capture interface. #[cfg(target_arch = "x86_64")] -pub mod clone; +pub use activation as clone; #[doc(hidden)] pub mod console_pending; #[cfg(target_arch = "x86_64")] diff --git a/nvx-rs/src/guest/root/tests/view.rs b/nvx-rs/src/guest/root/tests/view.rs index b0808f9fb..ad8d14411 100644 --- a/nvx-rs/src/guest/root/tests/view.rs +++ b/nvx-rs/src/guest/root/tests/view.rs @@ -4,6 +4,7 @@ use crate::{ ErrorKind, activation::{MachineIdMode, View}, }; +use std::error::Error as _; #[test] fn runtime_sources_are_the_directories_beside_their_files() { @@ -94,6 +95,38 @@ fn the_root_removes_its_view_binds_in_reverse_order_before_the_overlay() { ); } +#[cfg(target_arch = "x86_64")] +#[test] +fn an_applied_root_keeps_its_view_binds_when_transferred_into_parts() { + for switched in [false, true] { + let (guest, mut root, machine_id, resolver) = prepared(); + root.begin_view().unwrap(); + root.bind_view(&machine_id, Some(&resolver)).unwrap(); + if switched { + root.switch().unwrap(); + } + let calls = guest.kernel.calls(); + let applied = crate::guest::activation::Applied::from_test_root(root, machine_id.clone()); + let (root, returned_id) = applied.into_parts(); + assert_eq!(returned_id, machine_id); + assert_eq!(guest.kernel.calls(), calls); + assert_eq!(root.view_binds.as_ref().unwrap().1.len(), 2); + if switched { + assert!(root.teardown().unwrap_err().is_switched()); + assert!(guest.kernel.unmounts().is_empty()); + } else { + root.teardown().unwrap(); + assert_eq!( + &guest.kernel.unmounts()[..2], + [ + guest.text("run/nvx/rootfs/etc/resolv.conf"), + guest.text("run/nvx/rootfs/etc/machine-id"), + ] + ); + } + } +} + #[test] fn image_mode_without_dns_binds_nothing_and_a_second_view_is_refused() { let (guest, mut root, _, _) = prepared(); @@ -150,6 +183,65 @@ fn a_later_bind_failure_stays_owned_and_its_teardown_removes_the_earlier_bind() assert!(guest.kernel.mount_at(&machine).is_none()); } +#[cfg(target_arch = "x86_64")] +#[test] +fn actual_resolver_failures_keep_the_network_class_and_root_owned_bind() { + use crate::guest::activation::{ApplyError, NetworkError}; + + for write_failure in [false, true] { + let (guest, mut root, _, resolver) = prepared(); + fs::remove_file(root.runtime().join(MACHINE_ID_FILE)).unwrap(); + if !write_failure { + fs::remove_file(&resolver).unwrap(); + guest.kernel.fail( + Operation::MoveMount, + root.path().join("etc/resolv.conf"), + Errno::EPERM, + ); + } + root.begin_view().unwrap(); + let view = View { + hostname: b"clone".to_vec(), + machine_id: MachineIdMode::Generated, + }; + let failure = vm_view::apply_files(&mut root, &view, Some("192.0.2.53".parse().unwrap())) + .unwrap_err(); + let error = ApplyError::from_test_view_failure(failure); + match error { + ApplyError::Network { + error: NetworkError::Write(error), + teardown: None, + } => { + assert!(write_failure); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + } + ApplyError::Network { + error: NetworkError::Bind(error), + teardown: None, + } => { + assert!(!write_failure); + assert_eq!(error.kind(), ErrorKind::Io); + let cause = error + .source() + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!(cause.raw_os_error(), Errno::EPERM as i32); + assert!(root.is_latched()); + } + error => panic!("a real resolver failure lost its network class: {error}"), + } + let machine = root.path().join("etc/machine-id"); + assert_eq!(guest.kernel.mount_at(&machine).is_some(), !write_failure); + assert_eq!( + root.view_binds.as_ref().unwrap().1.len(), + usize::from(!write_failure) + ); + root.teardown().unwrap(); + assert!(guest.kernel.mount_at(&machine).is_none()); + } +} + #[test] fn a_switched_root_never_unmounts_or_detaches_its_view_binds() { for teardown in [false, true] { diff --git a/nvx-rs/src/guest/view.rs b/nvx-rs/src/guest/view.rs index f1ea51076..ad4503f1d 100644 --- a/nvx-rs/src/guest/view.rs +++ b/nvx-rs/src/guest/view.rs @@ -92,13 +92,61 @@ impl MachineId { /// or a root whose view has already been attempted. System failures have kind `Io`; refusals /// have kind `Unsupported`. An error does not restore the previous hostname or remove files. pub fn apply(root: &mut Root, view: &View, dns: Option) -> Result { + apply_staged(root, view, dns).map_err(ApplyFailure::into_error) +} + +/// A view failure with the resolver's original cause distinguished for activation. +#[derive(Debug)] +pub(crate) enum ApplyFailure { + /// A hostname, root, or machine-ID failure. + View(Error), + /// The resolver file could not be written. + ResolverWrite(io::Error), + /// The resolver file could not be bound. + ResolverBind(Error), +} + +impl From for ApplyFailure { + fn from(error: Error) -> Self { + Self::View(error) + } +} + +impl ApplyFailure { + /// Returns the public view API's original error. + fn into_error(self) -> Error { + match self { + Self::View(error) | Self::ResolverBind(error) => error, + Self::ResolverWrite(error) => { + Error::io(format!("failed to write the VM resolver file: {error}")) + .with_source(error) + } + } + } +} + +/// Applies the same view while retaining the resolver failure stage for activation. +pub(crate) fn apply_staged( + root: &mut Root, + view: &View, + dns: Option, +) -> std::result::Result { if !activation::is_hostname(&view.hostname) { - return Err(Error::unsupported("the view's hostname is not valid")); + return Err(Error::unsupported("the view's hostname is not valid").into()); } root.begin_view()?; sys::set_hostname(&view.hostname).map_err(|error| { Error::io(format!("failed to set the VM hostname: {error}")).with_source(error) })?; + apply_files(root, view, dns) +} + +/// Applies files after the root's view has been reserved and its hostname set. +pub(crate) fn apply_files( + root: &mut Root, + view: &View, + dns: Option, +) -> std::result::Result { let machine_id = match view.machine_id { MachineIdMode::Image => MachineId::Image, MachineIdMode::Generated => MachineId::generate(root.runtime().join(MACHINE_ID_FILE)) @@ -113,10 +161,10 @@ pub fn apply(root: &mut Root, view: &View, dns: Option) -> Result Result; - /// Tears the assembled root down, once it stopped the announcer that a clone's application, - /// through `apply`, started, if any, and removed the runtime's binds that the application made - /// on it, which keep the root's mount busy. + /// Stops the caller-owned announcer, if any, closes the export, and tears the assembled root + /// down. The root removes its view binds before unmounting the root. fn teardown(&mut self) -> Result<(), String>; - /// Returns the pidfd of the announcer of the workload's listeners that a clone's application, - /// through `apply`, started for a record whose network publishes them, which polls readable + /// Returns the pidfd of the caller-owned announcer of the workload's listeners, started after + /// application for a record whose network publishes them, which polls readable /// once the announcer ended, or `None` without one, or once its status was collected. fn announcer(&self) -> Option>; @@ -483,24 +481,25 @@ pub trait RootOperations: EntryOperations { /// Applies `record`, the record of the Activate that a clone's activation accepted, to the /// clone, as `nvx::guest::clone::apply` does, with `template`, the inputs of the root's /// assembly that the clone's template captured, by `deadline`, the activation's, if any, until - /// which it waits for each device at the latest, and keeps the root that it assembled, with the - /// runtime's binds that it made on it, which the [teardown](Self::teardown) removes first, the - /// root of the host export that it mounted, if the record has one, from which + /// which it waits for each device at the latest, and keeps the root that it assembled, which + /// owns its view binds, the root of the host export that it mounted, if the record has one, from which /// [`map`](Self::map) maps the host's mapping table, and the announcer of the workload's - /// listeners that it started, if the record's network publishes them, whose + /// listeners that this caller starts after application, if the record's network publishes + /// them, whose /// [pidfd](Self::announcer) the worker polls: in place of a root configuration's /// [layout](Self::command_line), assembly, and [export](Self::mount_export). /// Each execution of a clone names its own identity. The template's VM /// [bootstrapped](Self::bootstrap) the guest before its capture. /// - /// Fails as `nvx::guest::clone::apply` does, with an [`ApplyError`]. + /// Fails as `nvx::guest::clone::apply` does, with an [`ApplyError`], or with the caller's + /// announcer-start error after closing the export and tearing the applied root down. #[cfg(target_arch = "x86_64")] fn apply( &mut self, record: &ActivationRecord, template: &RootTemplate, deadline: Option, - ) -> Result<(), ApplyError>; + ) -> Result<(), Box>; } /// A mapping that [`RootOperations::map`] refused or failed to apply, once it applied those before @@ -524,12 +523,10 @@ pub enum Worked { /// The broker closed the stream at a record boundary. Closed, /// The broker closed the stream at a record boundary before a clone was ready, whose - /// activation can complete no more, which left the worker's fence of workload admission - /// closed. + /// activation can complete no more. Abandoned, - /// A clone's activation failed, finally: before the clone was ready, which left the worker's - /// fence of workload admission closed, or once it was ready, as once the announcer of the - /// workload's listeners that its application started ended. + /// A clone's activation failed, finally: before it was ready, or once it was ready, + /// as when the caller-owned announcer of the workload's listeners ended. Unactivated { /// The status of `nvx::protocol::status` of the failure's class, with which the worker /// ends. @@ -579,8 +576,9 @@ pub fn work( /// as the [module documentation](self#clones) describes, until the host stops the worker, the /// activation fails, or the broker closes the stream. /// -/// `activation` is `nvx::guest::clone::Activation::new` of the clone that the worker repaired, -/// behind the fence that its capture's barrier handed over closed, in its transport bootstrap. +/// `activation` is `nvx::guest::clone::Activation::new` of the clone that the worker repaired +/// in its transport bootstrap. Activation owns no admission fence: the capture's barrier handed +/// the legacy fence over closed, and the capture holds it closed until this function returns. /// `session` is the guest's role of the session that the template's VM attached on the same tty /// before its capture, and whose first epoch it acknowledged, which the reset that the restore /// queued starts again: the worker attaches no session. `template` holds the inputs of the root's @@ -599,7 +597,7 @@ pub fn work_clone( stream: S, session: GuestSession, operations: &mut dyn RootOperations, - activation: Activation, + activation: Activation, template: RootTemplate, acknowledged: Option, repair_delay: Option, @@ -1136,8 +1134,11 @@ fn run_probe(operations: &mut dyn RootOperations, body: &[u8]) -> Message { /// /// Dropping it closes the descriptors that probes keep open and the host export's root, then /// unmounts a root that it still holds, as `nvx::guest::root::Root` does when it drops, ignoring -/// failures, once it removed the runtime's binds that a clone's application made on it. +/// failures. It first stops its caller-owned announcer; the root owns and removes its view binds. pub struct LibraryRoot { + /// The temporary adapter's announcer, started by this caller only after library application. + #[cfg(target_arch = "x86_64")] + announcer: Option, /// The process ID of PID 1's unrelated child, which the probes check, if PID 1 named one. sibling: Option, /// What the bootstrap prepared, once it ran. @@ -1162,8 +1163,8 @@ pub struct LibraryRoot { enum Assembled { /// The root of a VM that boots cold. Cold(Root), - /// A clone's root and workload, with the runtime's binds that the application made on the - /// root, which its teardown, and its drop, remove before they unmount the root. + /// A clone's root and VM-wide view, with binds owned by the root and removed before its + /// teardown or drop unmounts it. #[cfg(target_arch = "x86_64")] Clone(clone::Applied), } @@ -1187,9 +1188,9 @@ impl Assembled { } } - /// Tears the root down: a clone's once it removed the runtime's binds, which keep the root's - /// mount busy, in the worker's mount namespace, where the application made them. Fails with - /// the problem, the binds that stay mounted and the unmounts that failed, whose mounts stay. + /// Tears the root down, removing its view binds first in the worker's mount namespace. + /// Fails with the problem, the binds that stay mounted and the unmounts that failed, whose + /// mounts stay. fn teardown(self) -> Result<(), String> { match self { Self::Cold(root) => root.teardown().map_err(|error| error.to_string()), @@ -1203,6 +1204,8 @@ impl LibraryRoot { /// Returns the operations, with the process ID of PID 1's unrelated child, if any. pub fn new(sibling: Option) -> Self { Self { + #[cfg(target_arch = "x86_64")] + announcer: None, sibling, bootstrap: None, held: Vec::new(), @@ -1379,35 +1382,44 @@ impl RootOperations for LibraryRoot { probes::run(probe, &context, &mut self.held) } - /// Runs `nvx::guest::root::Root::teardown`, once it closed the host export's root, which keeps - /// the export busy, and, for a clone, `nvx::guest::clone::Applied::teardown`, which stops the - /// announcer that the application started, if any, and removes the runtime's binds that the - /// application made on the root first, since they keep the root's mount busy too. A root whose - /// teardown failed keeps its mounts. + /// Stops this caller's announcer, closes the host export's descriptor, then tears the root + /// down with its view binds. A root whose teardown failed keeps its mounts. fn teardown(&mut self) -> Result<(), String> { + #[cfg(target_arch = "x86_64")] + let stopped = self.announcer.take().map(Announcer::stop).transpose(); self.export = None; - match self.root.take() { + let torn = match self.root.take() { Some(root) => root.teardown(), None => Ok(()), + }; + #[cfg(target_arch = "x86_64")] + if let Err(error) = stopped { + return Err(match torn { + Ok(()) => format!("cannot stop the announcer: {error}"), + Err(teardown) => format!("cannot stop the announcer: {error}; then {teardown}"), + }); } + torn } - /// Lends the pidfd of the announcer that a clone's application started, through - /// `nvx::guest::clone::Applied::announcer`. + /// Lends the pidfd of the announcer that this caller started after library application. fn announcer(&self) -> Option> { - match &self.root { - #[cfg(target_arch = "x86_64")] - Some(Assembled::Clone(applied)) => applied.announcer().map(Announcer::pidfd), - _ => None, + #[cfg(target_arch = "x86_64")] + { + self.announcer.as_ref().map(Announcer::pidfd) + } + #[cfg(not(target_arch = "x86_64"))] + { + None } } - /// Collects the status of the announcer that a clone's application started, with + /// Collects the status of the announcer that this caller started, with /// `nvx::guest::ingress::Announcer::try_wait`. fn announcer_ended(&mut self) -> Option { - match &mut self.root { - #[cfg(target_arch = "x86_64")] - Some(Assembled::Clone(applied)) => match applied.announcer_mut()?.try_wait() { + #[cfg(target_arch = "x86_64")] + { + match self.announcer.as_mut()?.try_wait() { Ok(None) => None, Ok(Some(status)) => Some(format!( "the announcer of the workload's listeners ended: {status}" @@ -1416,29 +1428,71 @@ impl RootOperations for LibraryRoot { "the status of the announcer of the workload's listeners cannot be collected: \ {error}" )), - }, - _ => None, + } + } + #[cfg(not(target_arch = "x86_64"))] + { + None } } - /// Runs `nvx::guest::clone::apply`, and keeps the root and the runtime's binds that it made, - /// the announcer that it started, if any, and the root of the host export that it mounted, if - /// the record has one, from which the worker maps the host's mapping table, as after a cold - /// boot. + /// Runs the process-free library application, then starts this temporary caller's announcer + /// from its returned publication and gateway. Keeps the applied root and the export's root. #[cfg(target_arch = "x86_64")] fn apply( &mut self, record: &ActivationRecord, template: &RootTemplate, deadline: Option, - ) -> Result<(), ApplyError> { + ) -> Result<(), Box> { let mut applied = clone::apply(record, template, deadline)?; + if applied.publication() == nvx::activation::Publication::Published { + let gateway = applied + .gateway() + .expect("application checked a published gateway"); + let announcer = match Announcer::start(gateway) { + Ok(announcer) => announcer, + Err(error) => { + let teardown = applied.teardown().err(); + return Err(Box::new(AnnounceFailure { error, teardown })); + } + }; + self.announcer = Some(announcer); + } self.export = applied.take_export(); self.root = Some(Assembled::Clone(applied)); Ok(()) } } +/// A temporary adapter caller's announcer failure, after successful library application. +#[cfg(target_arch = "x86_64")] +#[derive(Debug)] +struct AnnounceFailure { + /// Why the caller could not start ingress. + error: StartError, + /// Any failure while closing the export and tearing the applied root down. + teardown: Option, +} + +#[cfg(target_arch = "x86_64")] +impl std::fmt::Display for AnnounceFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(formatter)?; + match &self.teardown { + Some(teardown) => write!(formatter, "; then {teardown}"), + None => Ok(()), + } + } +} + +#[cfg(target_arch = "x86_64")] +impl std::error::Error for AnnounceFailure { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + impl EntryOperations for LibraryRoot { /// Runs `Entry::prepare` on the assembled root. fn prepare(&mut self, entrance: &Entrance) -> Result { diff --git a/tests/runtime-adapter/src/worker/activation.rs b/tests/runtime-adapter/src/worker/activation.rs index 97e45d260..fa907cea1 100644 --- a/tests/runtime-adapter/src/worker/activation.rs +++ b/tests/runtime-adapter/src/worker/activation.rs @@ -38,7 +38,6 @@ use nvx::guest::clone::{ use nvx::guest::entry::Publication; use nvx::protocol::status; -use super::capture::Admission; use super::connection::Connection; use super::executions::{Executions, Sandbox}; use super::order::Order; @@ -79,16 +78,18 @@ pub const ACTIVATION_PROBE: &str = "activation"; /// record's GPT disk before it, as [`ApplyError::Assembly`] and [`ApplyError::Gpt`] tell, or /// the application failed otherwise, as this build of the adapter does not know; /// - [`ACTIVATION_WORKLOAD`](status::ACTIVATION_WORKLOAD): the machine ID that the record asks the -/// guest to generate cannot be written, as [`ApplyError::MachineId`] tells, or the record's -/// hostname or that machine ID cannot be set for every execution, as [`ApplyError::View`] -/// tells; +/// guest to generate cannot be written, or the record's hostname or that machine ID cannot be +/// set for every execution, as [`ApplyError::View`] tells, or as the legacy +/// [`ApplyError::MachineId`] tells; /// - [`ACTIVATION_EXPORT`](status::ACTIVATION_EXPORT): the host export of the record cannot be /// mounted, as [`ApplyError::Export`] tells; /// - [`ACTIVATION_NETWORK`](status::ACTIVATION_NETWORK): the resolver file that names the DNS -/// server of the record's network cannot be written or set for every execution, or the -/// announcer of the workload's listeners, which the network publishes, cannot start, as -/// [`ApplyError::Network`] tells. The worker also ends a clone whose announcer ended with it, -/// whether the clone was ready or not, outside the activation; +/// server of the record's network cannot be written or set for every execution, or the network +/// publishes the workload's listeners but the template names no gateway, as +/// [`ApplyError::Network`] tells, or the announcer of those listeners, which the worker starts +/// once the application succeeded, cannot start, as its +/// [`StartError`](nvx::guest::ingress::StartError) tells. The worker also ends a clone whose +/// announcer ended with it, whether the clone was ready or not, outside the activation; /// - [`ACTIVATION_ORDER`](status::ACTIVATION_ORDER): the worker's own order completed an /// activation that did not run, or started the sandbox before the clone was ready, or again. /// @@ -102,6 +103,12 @@ pub fn failure_status(failure: &ActivationFailure) -> u8 { | ActivationCause::Changed => status::ACTIVATION_PROTOCOL, ActivationCause::Record(_) | ActivationCause::Cold => status::ACTIVATION_RECORD, ActivationCause::Resources { .. } => status::ACTIVATION_RESOURCES, + ActivationCause::Failed(error) + if error.is::() + || error.is::() => + { + status::ACTIVATION_NETWORK + } ActivationCause::Failed(error) => match error.downcast_ref::() { Some(ApplyError::Gpt(_) | ApplyError::Assembly(_)) => status::ACTIVATION_ROOT, Some(ApplyError::MachineId { .. } | ApplyError::View { .. }) => { @@ -121,8 +128,8 @@ pub fn failure_status(failure: &ActivationFailure) -> u8 { /// assembly that the clone's template captured, the application that awaits the activation's /// completion, if any, and the worker's order once the clone is ready. pub(super) struct CloneState { - /// The clone's activation, behind the worker's fence of workload admission. - activation: Activation, + /// The clone's fence-free activation, which admits no request before Ready. + activation: Activation, /// The inputs of the root's assembly that the clone's template captured. template: RootTemplate, /// The application of the accepted record, once it ran, until the worker @@ -138,7 +145,7 @@ pub(super) struct CloneState { /// and which awaits the activation's completion. struct Applied { /// Whether the application succeeded, or why it failed. - result: Result<(), ApplyError>, + result: Result<(), Box>, /// The number of mappings that the record announces. mappings: u32, /// The hostname of every execution: the record's. @@ -157,7 +164,7 @@ impl CloneState { /// the repair, if the template set a delay, and whose repair acknowledged the restore at /// `acknowledged`, if it did. pub(super) fn new( - activation: Activation, + activation: Activation, template: RootTemplate, acknowledged: Option, repair_delay: Option, @@ -337,7 +344,7 @@ impl CloneState { executions: &mut Executions, ) -> Option> { let applied = self.applied.take()?; - let result = applied.result.map_err(Into::into); + let result = applied.result; Some(match self.activation.complete(Instant::now(), result) { Ok(activation) => { self.timeline.ready = Some(Instant::now()); diff --git a/tests/runtime-adapter/src/worker/activation/tests.rs b/tests/runtime-adapter/src/worker/activation/tests.rs index ec555b0e9..1c62ee4ba 100644 --- a/tests/runtime-adapter/src/worker/activation/tests.rs +++ b/tests/runtime-adapter/src/worker/activation/tests.rs @@ -5,7 +5,7 @@ use nvx::activation::{ Configuration, ExpectedResources, Image, MachineIdMode, OverlayMode, RestoreSection, TransactionId, Upper, View, }; -use nvx::guest::clone::{AdmissionFence, NetworkError}; +use nvx::guest::clone::NetworkError; use nvx::guest::entry; use nvx::guest::ingress::StartError; use nvx::guest::mappings::MappingError; @@ -44,17 +44,15 @@ fn record(transaction: u8, hostname: &[u8]) -> ActivationRecord { ActivationRecord::new(Some(restore), configuration).unwrap() } -/// Returns a clone's activation, in its transport bootstrap, behind a closed fence, with +/// Returns a clone's activation, in its transport bootstrap, with /// `resources`, which the deadline ends `timeout` after now. -fn activation(resources: ExpectedResources, timeout: Duration) -> Activation { - let mut fence = Admission::default(); - fence.close().unwrap(); +fn activation(resources: ExpectedResources, timeout: Duration) -> Activation { let now = Instant::now(); - Activation::with_resources(resources, Some(now), fence, timeout, now) + Activation::with_resources(resources, Some(now), timeout, now) } /// Returns an activation that took the session's reset, and is restricted. -fn restricted() -> Activation { +fn restricted() -> Activation { let mut activation = activation(RESOURCES, ACTIVATION_TIMEOUT); let action = activation.input(Instant::now(), ActivationInput::Reset); assert_eq!(action.unwrap(), ActivationAction::Acknowledge); @@ -62,7 +60,7 @@ fn restricted() -> Activation { } /// Returns an activation that accepted the record of the transaction 1. -fn activating() -> Activation { +fn activating() -> Activation { let mut activation = restricted(); let bytes = record(1, b"clone").encode(); let action = activation.input(Instant::now(), ActivationInput::Activate(&bytes)); @@ -158,7 +156,7 @@ fn each_class_of_failure_ends_the_clone_with_its_own_status() { let workload = activating().complete(now(), Err(generated.into())); cases.push((failed(workload), status::ACTIVATION_WORKLOAD)); let view = ApplyError::View { - error: entry::Error::Hostname, + error: nvx::Error::unsupported("the hostname is invalid"), teardown: None, }; let workload = activating().complete(now(), Err(view.into())); @@ -181,22 +179,26 @@ fn each_class_of_failure_ends_the_clone_with_its_own_status() { let network = activating().complete(now(), Err(unwritten.into())); cases.push((failed(network), status::ACTIVATION_NETWORK)); let unbound = ApplyError::Network { - error: NetworkError::Bind(entry::Error::Resolver(MappingError::TargetKind)), + error: NetworkError::Bind( + nvx::Error::io("the resolver bind failed") + .with_source(entry::Error::Resolver(MappingError::TargetKind)), + ), teardown: None, }; let network = activating().complete(now(), Err(unbound.into())); cases.push((failed(network), status::ACTIVATION_NETWORK)); - for error in [ - NetworkError::Gateway, - NetworkError::Announcer(StartError::NoPrivilegedPort), - ] { - let unannounced = ApplyError::Network { - error, - teardown: None, - }; - let network = activating().complete(now(), Err(unannounced.into())); - cases.push((failed(network), status::ACTIVATION_NETWORK)); - } + let gateway = ApplyError::Network { + error: NetworkError::Gateway, + teardown: None, + }; + let network = activating().complete(now(), Err(gateway.into())); + cases.push((failed(network), status::ACTIVATION_NETWORK)); + let unannounced = super::super::AnnounceFailure { + error: StartError::NoPrivilegedPort, + teardown: None, + }; + let network = activating().complete(now(), Err(unannounced.into())); + cases.push((failed(network), status::ACTIVATION_NETWORK)); let other = activating().complete(now(), Err("another failure of the application".into())); cases.push((failed(other), status::ACTIVATION_ROOT)); // The worker's own order. diff --git a/tests/runtime-adapter/src/worker/capture.rs b/tests/runtime-adapter/src/worker/capture.rs index 11f476594..b8497a3d7 100644 --- a/tests/runtime-adapter/src/worker/capture.rs +++ b/tests/runtime-adapter/src/worker/capture.rs @@ -43,7 +43,7 @@ //! //! Once the worker repaired a clone, it serves the clone's session on the same tty, with //! [`work_clone`]: through the clone's activation, `nvx::guest::clone::Activation` of the repaired -//! clone and of the fence, whose deadline ends [`ACTIVATION_TIMEOUT`] after the repair's +//! clone, without an activation fence, whose deadline ends [`ACTIVATION_TIMEOUT`] after the repair's //! acknowledgement, it answers the reset that the restore queued for the session that the snapshot //! holds, takes the host's `Activate`, and applies its record with [`root_template`] of the //! template's configuration and its NIC's gateway, then serves the host's order as after a cold @@ -121,7 +121,8 @@ pub fn root_template(config: &RootConfig, gateway: Option) -> RootTemp /// preparation. /// /// The worker of a template's VM admits no execution before the capture, nor in a clone, which it -/// repairs behind the fence, before the clone's activation opens it, once the clone is ready. +/// repairs behind the capture's fence. Its activation then serves executions only after Ready; +/// the library activation does not hold or open this legacy capture fence. /// Closing the fence acquires it, as `nvx::guest::clone` requires: /// [`close`](AdmissionFence::close) fails while the fence is closed, whoever closed it, and leaves /// it closed. @@ -306,7 +307,7 @@ enum Capture { Repaired { /// The repaired clone, with its resources. repaired: Repaired, - /// The worker's fence of workload admission, still closed: opening it is the activation's. + /// The legacy capture fence, still closed; the worker's activation order gates executions. fence: Admission, }, } @@ -366,13 +367,13 @@ pub fn capture_on_terminal( Err(error) => Capture::Ended(Templated::Failed(error)), } })?; - let (repaired, fence) = match captured { + let (repaired, _fence) = match captured { Capture::Ended(templated) => return Ok((templated, None)), Capture::Repaired { repaired, fence } => (repaired, fence), }; // The activation's deadline starts at the repair's acknowledgement of the restore. let acknowledged = repaired.acknowledged_at(); - let activation = Activation::new(&repaired, fence, ACTIVATION_TIMEOUT, Instant::now()); + let activation = Activation::new(&repaired, ACTIVATION_TIMEOUT, Instant::now()); let template = root_template(&config, gateway); let worked = work_clone( open_tty(tty)?, diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index 1fcd34af2..c62e3fafb 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1129,11 +1129,10 @@ enum Operation { /// A clone's reread of its GPT disk's partition table, before its assembly. #[cfg(target_arch = "x86_64")] Reread, - /// A clone's generation of the machine ID that its record asks for, once its root's account - /// was checked. + /// A clone's generation of the machine ID that its view asks for, after its hostname. #[cfg(target_arch = "x86_64")] MachineId, - /// A clone's setting of its record's hostname and machine ID for every execution, last. + /// A clone's reservation of its root's view and setting of its VM-wide hostname. #[cfg(target_arch = "x86_64")] View, /// A clone's write of the resolver file that names its record's DNS server, once it mounted @@ -1201,6 +1200,9 @@ struct FakeRoot { gate: Option>, /// The worker's calls, in order. calls: Vec, + /// The operations attempted by the fake, in order. + #[cfg(target_arch = "x86_64")] + steps: Vec, /// The deadline of each of a clone's applications, in order, until which it waits for the /// devices at the latest. #[cfg(target_arch = "x86_64")] @@ -1208,10 +1210,10 @@ struct FakeRoot { /// The workload cgroup of the test's own, below which the worker runs its executions in /// cgroups of their own, where the test can create cgroups. workload: Option, - /// The read end of a pipe whose write end the test holds, which the application takes as the - /// announcer that it starts, if the test made one with [`ending`](Self::ending). + /// The read end of a pipe whose write end the test holds, which the fake takes as the + /// announcer that the worker starts, if the test made one with [`ending`](Self::ending). ending: Option, - /// The announcer that a clone's application started, for a record whose network publishes + /// The announcer that the worker started, for a record whose network publishes /// the workload's listeners, until the worker collected its end: the read end of a pipe, /// which polls readable, hung up, once its write end closes, as an announcer's pidfd polls /// readable once the announcer ended, with that write end, unless the test holds it. @@ -1252,6 +1254,8 @@ impl FakeRoot { gate: None, calls: Vec::new(), #[cfg(target_arch = "x86_64")] + steps: Vec::new(), + #[cfg(target_arch = "x86_64")] deadlines: Vec::new(), workload, ending: None, @@ -1281,7 +1285,7 @@ impl FakeRoot { self } - /// Makes the announcer that a clone's application starts end once the test drops the returned + /// Makes the announcer that the worker starts end once the test drops the returned /// write end of its pipe. fn ending(mut self) -> (Self, OwnedFd) { let (read, write) = announcer_pipe(); @@ -1298,7 +1302,9 @@ impl FakeRoot { } /// Fails if `operation` fails. - fn result(&self, operation: Operation) -> Result<(), String> { + fn result(&mut self, operation: Operation) -> Result<(), String> { + #[cfg(target_arch = "x86_64")] + self.steps.push(operation); match self.failing { Some((failing, problem)) if failing == operation => Err(problem.to_owned()), _ => Ok(()), @@ -1396,22 +1402,22 @@ impl RootOperations for FakeRoot { /// table first, which fails as the kernel does while a partition is open if the test fails /// [`Operation::Reread`]; it then takes as long as the assembly, but no longer than until /// `deadline`, as the waits for the devices do; like the application, it checks no account. - /// It then stands for the generation of the machine ID - /// that the record asks for, if any, the mount of the record's export through the template's - /// host filesystem device, if it has one, the write of the resolver file that names the DNS - /// server of the record's network, if it names one, the start of the announcer of the - /// workload's listeners, if the network publishes them, and the setting of the record's - /// hostname and machine ID for every execution, which fail if the test fails + /// It then stands for the mount of the record's export through the template's host filesystem + /// device, if any, the gateway refusal before view effects, the view's hostname, generation of + /// the machine ID, and write of the resolver file when requested. After application + /// succeeds, it stands for this caller's announcer start when listeners are published, and + /// fails with that start's bare `StartError`: unlike the worker, which wraps it with its + /// teardown of the applied root, the fake holds no root to tear down. + /// These fail if the test fails /// [`Operation::MachineId`], [`Operation::MountExport`], [`Operation::Resolver`], - /// [`Operation::Announce`], and [`Operation::View`]. A failure after the start stops the - /// announcer. + /// [`Operation::View`], and [`Operation::Announce`]. #[cfg(target_arch = "x86_64")] fn apply( &mut self, record: &nvx::activation::ActivationRecord, template: &nvx::guest::clone::RootTemplate, deadline: Option, - ) -> Result<(), nvx::guest::clone::ApplyError> { + ) -> Result<(), Box> { use nvx::activation::{self, MachineIdMode}; use nvx::guest::clone::{ApplyError, NetworkError}; use nvx::guest::ingress::StartError; @@ -1442,14 +1448,6 @@ impl RootOperations for FakeRoot { None => self.assembly, }; thread::sleep(assembly); - let view = &configuration.view; - if view.machine_id == MachineIdMode::Generated { - self.result(Operation::MachineId) - .map_err(|problem| ApplyError::MachineId { - error: io::Error::other(problem), - teardown: None, - })?; - } if configuration.export.is_some() { self.result(Operation::MountExport) .map_err(|problem| ApplyError::Export { @@ -1463,6 +1461,27 @@ impl RootOperations for FakeRoot { })?; } let network = configuration.network.as_ref(); + let published = network + .is_some_and(|network| network.publication == activation::Publication::Published); + if published && template.gateway.is_none() { + return Err(ApplyError::Network { + error: NetworkError::Gateway, + teardown: None, + } + .into()); + } + self.result(Operation::View) + .map_err(|problem| ApplyError::View { + error: nvx::Error::io(problem), + teardown: None, + })?; + if configuration.view.machine_id == MachineIdMode::Generated { + self.result(Operation::MachineId) + .map_err(|problem| ApplyError::View { + error: nvx::Error::io(problem), + teardown: None, + })?; + } if network.is_some_and(|network| network.dns.is_some()) { self.result(Operation::Resolver) .map_err(|problem| ApplyError::Network { @@ -1470,19 +1489,9 @@ impl RootOperations for FakeRoot { teardown: None, })?; } - if network.is_some_and(|network| network.publication == activation::Publication::Published) - { - if template.gateway.is_none() { - return Err(ApplyError::Network { - error: NetworkError::Gateway, - teardown: None, - }); - } + if published { self.result(Operation::Announce) - .map_err(|problem| ApplyError::Network { - error: NetworkError::Announcer(StartError::Spawn(io::Error::other(problem))), - teardown: None, - })?; + .map_err(|problem| StartError::Spawn(io::Error::other(problem)))?; self.announcer = Some(match self.ending.take() { Some(read) => (read, None), None => { @@ -1491,13 +1500,6 @@ impl RootOperations for FakeRoot { } }); } - self.result(Operation::View).map_err(|problem| { - self.announcer = None; - ApplyError::View { - error: entry::Error::Root(io::Error::other(problem)), - teardown: None, - } - })?; Ok(()) } } @@ -4130,13 +4132,13 @@ mod clones { self, ActivationRecord, Configuration, ExpectedResources, MachineIdMode, ReadyActivation, RestoreSection, TransactionId, View, }; - use nvx::guest::clone::{Activation, AdmissionFence, RootTemplate}; + use nvx::guest::clone::{Activation, RootTemplate}; use nvx::protocol::nvxs::{GuestEvent, GuestSession}; use nvx::protocol::status; use runtime_adapter::frame::MAX_ACTIVATE_BODY; use runtime_adapter::worker::UNACTIVATED_DRAIN; use runtime_adapter::worker::activation::{ACTIVATION_PROBE, ACTIVATION_TIMEOUT}; - use runtime_adapter::worker::capture::{self, Admission}; + use runtime_adapter::worker::capture; use super::*; @@ -4230,13 +4232,11 @@ mod clones { }) } - /// Returns the activation of a clone that the worker repaired, with [`RESOURCES`], behind the - /// worker's fence, which the capture's barrier handed over closed, and whose deadline ends - /// `timeout` after `acknowledged`, as from the repair's acknowledgement of the restore. - fn repaired(acknowledged: Instant, timeout: Duration) -> Activation { - let mut fence = Admission::default(); - fence.close().unwrap(); - Activation::with_resources(RESOURCES, Some(acknowledged), fence, timeout, acknowledged) + /// Returns the fence-free activation of a clone that the worker repaired, with + /// [`RESOURCES`], whose deadline ends `timeout` after `acknowledged`, as from the repair's + /// acknowledgement of the restore. + fn repaired(acknowledged: Instant, timeout: Duration) -> Activation { + Activation::with_resources(RESOURCES, Some(acknowledged), timeout, acknowledged) } /// Returns the inputs of the activation of the clones that their template captured: those of @@ -4561,7 +4561,7 @@ mod clones { ), ( generated, - FakeRoot::failing(Operation::View, "the image's machine ID is a directory"), + FakeRoot::failing(Operation::View, "the VM hostname cannot be set"), status::ACTIVATION_WORKLOAD, ), ( @@ -4589,6 +4589,60 @@ mod clones { } } + #[test] + fn application_failures_follow_export_gateway_and_view_order() { + use nvx::guest::clone::{ApplyError, NetworkError}; + + let record = changed(&published(&exported(&record(1), 1)), |configuration| { + configuration.view.machine_id = MachineIdMode::Generated; + }); + let mut root = FakeRoot::booting(&layout()); + root.apply(&record, &template(), None).unwrap(); + assert_eq!( + root.steps, + [ + Operation::MountExport, + Operation::View, + Operation::MachineId, + Operation::Resolver, + Operation::Announce, + ] + ); + let mut root = FakeRoot::failing(Operation::MachineId, "generation failed"); + let error = root.apply(&record, &template(), None).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ApplyError::View { .. }) + )); + assert_eq!( + root.steps, + [ + Operation::MountExport, + Operation::View, + Operation::MachineId + ] + ); + let mut root = FakeRoot::failing(Operation::View, "view must not run"); + let error = root + .apply( + &record, + &RootTemplate { + gateway: None, + ..template() + }, + None, + ) + .unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ApplyError::Network { + error: NetworkError::Gateway, + .. + }) + )); + assert_eq!(root.steps, [Operation::MountExport]); + } + #[test] fn a_clone_fails_on_an_activate_longer_than_a_record_before_it_takes_its_body() { // The framing refuses the header of an Activate that announces more bytes than a record @@ -4788,7 +4842,7 @@ mod clones { fn a_clone_whose_broker_closes_the_stream_before_it_is_ready_is_abandoned() { // Whether the restore's reset came or not, or part of an Activate, a broker that closes // the stream before the clone is ready ends the clone with the status of the host's - // session, with nothing applied, and its fence closed. + // session, with nothing applied. let record = record(1); let bytes = activate(&record).encode().unwrap(); let cases: [Option<&[u8]>; 3] = [None, Some(&[]), Some(&bytes[..bytes.len() / 2])]; @@ -4982,7 +5036,7 @@ mod clones { #[test] fn a_clone_whose_announcer_ends_ends_with_the_network_s_status() { - // The application started the announcer of the listeners that the record's network + // The worker started the announcer of the listeners that the record's network // publishes, whose end, once the clone is ready, before its sandbox started or after, and // with an execution running, ends the clone with the network's status, once the worker // dropped its executions: the gateway would keep publishing the listeners that the