diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 5d5d42286..5413b1ae1 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -120,7 +120,7 @@ on Linux and Windows. | `nvx::guest::exec` | `Execution`: drives one program that the caller spawned with the program's ends of its `Pipes`, or that the caller started itself and adopts with its own ends of the program's pipes and a `Child` that names it, without blocking, from the caller's own loop or reactor, and reports each step as an `Event`: the launch report, which an adopted program has at once, the output of each stream, kill failures, and the `Outcome`. It reads a stream only as far as the caller's `grant` allows, so a stalled sink stalls its own execution alone, writes the program's standard input without `SIGPIPE`, and ends the program on a cancellation, a timeout, or a disconnection, with `SIGKILL` through the program's pidfd to the program and, if the caller asks, its process group, while no collector has taken its status, and, through the opened directory of a cgroup that the caller names, to that cgroup. It closes the program's input once the program has ended. It waits for its program alone, by a pidfd, or takes the status from the caller's collector with `observe`; with `Options::caller_reaps`, that collector is the program's only one. NVX's managed agent is one caller | | `nvx::guest::exec::binding` | `Binding`: an `nvx::exec::Runner` bound to an `Execution`, the glue that a guest adapter would otherwise write. The runner's read allowances become grants, its discards discards, the input that it accepted writes and then a close, its terminations a cancellation, a disconnection, or a termination, and its settlement a kill of the execution's cgroup and checks until no process occupies it; without a cgroup, nothing proves that the program's descendants are gone, so the execution is fenced. It feeds the execution's events back to the runner, statuses that a caller who reaps hands over with `observe` included, and never blocks: the caller polls its descriptors and its input writer until its deadline, and each call takes a bounded number of steps, so that no execution holds the caller's loop | | `nvx::guest::time` | x86_64: `TimeAbi`, the steps of NVX time ABI v1 as typed calls in the caller's process, for any guest init or agent: the cold-boot time step and, separately, the start of the time daemon in a child process that the caller supervises, the VM generation ID, a `CaptureRequest` that repairs a `Restore`, and a `RestoreFinish`. The request's mode selects how a restore hands over its fresh entropy: the file mode, which `nvx-time` keeps, writes it to a file, and the in-memory mode returns it as the `Restore`'s owned `Seed`, and writes no file. A violation of the ABI stops the VM in enforcing mode, with the time ABI's status, as `nvx-time` does; in strict mode, `TimeAbi::strict`, which a caller that owns the VM's exit status and shutdown selects, the call fails with the `Violation`, whose `status` is the one with which enforcing mode stops the VM, and the time daemon and a capture's preparation enforce the ABI all the same. A call takes the permission for OpenVMM's ports on the calling thread for each port transaction alone, and fails with `TimeError::PortRelease` if the thread may still hold some of it, which a process that it started would inherit, and from which `nvx::guest::spawn` starts none. No call waits for a child that it did not start. `nvx-time` runs its steps through them | -| `nvx::guest::clone` | 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. `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: `prepare` closes the fence, so that no execution starts, refuses with a `Mounted` the first mount from those devices, a distro partition included, or from an overlay, bind mounts included, in a bounded read of `/proc/self/mountinfo` of at most `MOUNT_TABLE_LIMIT` bytes, then waits for the time ABI's boot checks, runs its capture checks, and suppresses the stall detectors, as `nvx-time pre-capture` does, and returns an owned `CaptureBarrier`. `CaptureBarrier::capture` requests the capture with the platform tier's request, in the in-memory mode, through `TimeAbi::capture`, and returns `Captured::Continued` once it opened the fence and restored the detectors' settings, or, in a clone, `Captured::Restored`, with the fence still closed, since nothing else may run before repair acknowledges the restore, and the detectors suppressed until the time daemon restores their settings: `Restored::into_parts` hands the `Restore` that repair takes next, which holds the restore's `Seed`, and the closed fence to the caller, which keeps it closed through the repair and opens it once the clone may admit executions, such as once it is activated. `CaptureBarrier::cancel`, a dropped barrier, and a failure to read the VM generation ID open the fence and restore the detectors' settings; a capture request that fails restores the settings but leaves the fence closed, since it may fail in a clone whose restore it could not repair. Each failure is a `CaptureError`, with its `CaptureStage` and `CaptureCause`. `repair`, or a `Repair` that the caller may retry, repairs the clone of a `Restore` next: it checks that the online CPUs are a prefix that meets the restore's processor target, since it brings no CPU online; with a memory target, it checks, before it adds any memory block, that the kernel's block size is 128 MiB and that the restore's ranges continue the captured RAM in logical order, with no gap, overlap, or range in the MMIO aperture, while the host bounds the target by the snapshot's capacity, then adds and onlines the blocks through sysfs, as `nvx-snapshot` does, and verifies them; it reseeds the kernel's random number generator with the `Seed`, having drawn no randomness before, and drops it; and it finishes the restore through `TimeAbi::restore_finish`, with the acknowledgement when the host gates its input until then, which hands the deferred time work to the time daemon that the caller supervises. It returns the `Repaired` clone, with its generation ID, its online CPUs and memory, and when it acknowledged the restore, and never touches the fence. It adds no memory block twice, and reseeds and finishes at most once, so a failure at or after the reseed is final. Each failure is a `RepairError`, with its `RepairStage`, its `RepairCause`, and the repair's `RepairProgress`, and `RepairError::status` selects the status, of `nvx::protocol::status`, with which the caller ends the VM. The capture and the repair take the time ABI in strict mode, so that a violation of it fails them, with its status, rather than stopping the VM, and only the capture's preparation and the time daemon enforce it. An `Activation` of the `Repaired` clone and of the caller's closed `AdmissionFence` 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 which returns the `ActivationAction` that the adapter takes. From its transport bootstrap, which takes the reset that the restore queued for the NVXS session, the restricted session takes only flow control and one Activate, and anything else, a request or another reset, fails it; the activation decodes the Activate's record with `ActivationRecord::decode`, and accepts only a clone's, with a restore section, that expects the CPU count and the memory size that the repair reports, before any side effect; the adapter applies the record, and `Activation::complete` then opens the fence and returns the `ReadyActivation` that Ready reports, 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. A retry with the same transaction ID and a byte-equal record joins the running activation, or gets its cached result, also in a later session once the clone is ready; a changed record, another transaction, another reset before Ready, or a failed activation is final. Its deadline, which the caller chooses, and which neither input nor retries extend, starts at the restore's acknowledgement, and the adapter ticks it however its input trickles and its output blocks. Each failure is an `ActivationFailure`, with its `ActivationState` and `ActivationCause`, and never powers the VM off: the adapter ends it | +| `nvx::guest::clone` | 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. `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: `prepare` closes the fence, so that no execution starts, refuses with a `Mounted` the first mount from those devices, a distro partition included, or from an overlay, bind mounts included, in a bounded read of `/proc/self/mountinfo` of at most `MOUNT_TABLE_LIMIT` bytes, then waits for the time ABI's boot checks, runs its capture checks, and suppresses the stall detectors, as `nvx-time pre-capture` does, and returns an owned `CaptureBarrier`. `CaptureBarrier::capture` requests the capture with the platform tier's request, in the in-memory mode, through `TimeAbi::capture`, and returns `Captured::Continued` once it opened the fence and restored the detectors' settings, or, in a clone, `Captured::Restored`, with the fence still closed, since nothing else may run before repair acknowledges the restore, and the detectors suppressed until the time daemon restores their settings: `Restored::into_parts` hands the `Restore` that repair takes next, which holds the restore's `Seed`, and the closed fence to the caller, which keeps it closed through the repair and opens it once the clone may admit executions, such as once it is activated. `CaptureBarrier::cancel`, a dropped barrier, and a failure to read the VM generation ID open the fence and restore the detectors' settings; a capture request that fails restores the settings but leaves the fence closed, since it may fail in a clone whose restore it could not repair. Each failure is a `CaptureError`, with its `CaptureStage` and `CaptureCause`. `repair`, or a `Repair` that the caller may retry, repairs the clone of a `Restore` next: it checks that the online CPUs are a prefix that meets the restore's processor target, since it brings no CPU online; with a memory target, it checks, before it adds any memory block, that the kernel's block size is 128 MiB and that the restore's ranges continue the captured RAM in logical order, with no gap, overlap, or range in the MMIO aperture, while the host bounds the target by the snapshot's capacity, then adds and onlines the blocks through sysfs, as `nvx-snapshot` does, and verifies them; it reseeds the kernel's random number generator with the `Seed`, having drawn no randomness before, and drops it; and it finishes the restore through `TimeAbi::restore_finish`, with the acknowledgement when the host gates its input until then, which hands the deferred time work to the time daemon that the caller supervises. It returns the `Repaired` clone, with its generation ID, its online CPUs and memory, and when it acknowledged the restore, and never touches the fence. It adds no memory block twice, and reseeds and finishes at most once, so a failure at or after the reseed is final. Each failure is a `RepairError`, with its `RepairStage`, its `RepairCause`, and the repair's `RepairProgress`, and `RepairError::status` selects the status, of `nvx::protocol::status`, with which the caller ends the VM. The capture and the repair take the time ABI in strict mode, so that a violation of it fails them, with its status, rather than stopping the VM, and only the capture's preparation and the time daemon enforce it. An `Activation` of the `Repaired` clone and of the caller's closed `AdmissionFence` 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 which returns the `ActivationAction` that the adapter takes. From its transport bootstrap, which takes the reset that the restore queued for the NVXS session, the restricted session takes only flow control and one Activate, and anything else, a request or another reset, fails it; the activation decodes the Activate's record with `ActivationRecord::decode`, and accepts only a clone's, with a restore section, that expects the CPU count and the memory size that the repair reports, before any side effect; the adapter applies the record with `apply`, as at a cold boot: it assembles the root with `root::assemble`, with the record's image, an EROFS image whose UUID it checks if the record expects one, its upper, a tmpfs or the scratch with the record's journal policy, and its overlay mode, and with the `RootTemplate` that the guest's own configuration holds, which its template captured: the runtime directory, the block devices, and the timeout. It refuses a GPT disk before any side effect, since the kernel created the block devices of its partitions for the template's placeholder, and, with the record's workload identity, checks its account in the assembled root before Ready, as NVX's init checks it at boot: `entry::Identity::new(uid, gid)`, without supplementary groups or capabilities, in existing-account mode, as which every execution runs, while without one each execution names its own identity, which `Entry::check` checks. It returns the `Applied` root and identity, or an `ApplyError`, after which nothing that it mounted stays mounted unless the error reports the unmounts that failed, and applies none of the record's export, network, hostname, or machine ID. `Activation::complete` then opens the fence and 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. A retry with the same transaction ID and a byte-equal record joins the running activation, or gets its cached result, also in a later session once the clone is ready; a changed record, another transaction, another reset before Ready, or a failed activation is final. Its deadline, which the caller chooses, and which neither input nor retries extend, starts at the restore's acknowledgement, and the adapter ticks it however its input trickles and its output blocks. Each failure is an `ActivationFailure`, with its `ActivationState` and `ActivationCause`, and never powers the VM off: the adapter ends it | | `nvx::guest::reseed` | `Seed`, `Seed::read`, and `reseed`: a restore's fresh entropy, which starts with its `GenerationId`, and the reseed of the kernel's random number generator with it, in the caller's process. A `Seed` keeps its bytes in one allocation, which no move copies, zeroes them with volatile writes when it is dropped, can't be cloned, and formats none of them. `nvx-reseed` runs them | | `nvx::guest::root` | The operations of a layered root, for a guest init or agent, whether or not it is PID 1. `bootstrap`: the agent's and the workload's sibling cgroups of a `Layout`, with every process of their parent moved into the agent's, the agent's `memory.low`, and the workload cgroup prepared through `ExecutionCgroup::prepare`, with its `memory.max` and `pids.max`, and user namespaces kept unavailable. `assemble`: the `Assembly`'s block devices, found by their MMIO windows, checked against their roles' access modes and their expected sizes, and flushed, and the `Root` that it mounts, at a runtime directory, a canonical path that no symbolic link leads through, that it holds exclusively until its teardown, where nothing is mounted already, once it has checked everything: the read-only layers of the `Image` that the caller names, which it never probes: an EROFS image, whose expected `nvx::uuid::Uuid` it checks in its superblock, or a GPT disk whose partition table it reads and checks with `nvx::guest::gpt::read_disk`, against the caller's `nvx::gpt::Expected` values, whose first partition it reads, up to the caller's bound, as opaque bytes that it never mounts, and whose later partitions, at least one, hold ext4 layers that it mounts `ro,nosuid,nodev` with `errors=remount-ro`, so that an error that the kernel finds in a layer fails the operation that found it, rather than panic the guest, as the layer's superblock may ask, from the block devices that the kernel created when it found the disk, once their superblocks show that no journal needs a recovery, which their read-only device can't take, partition 2's at the overlay's base and the last partition's at its top, under an overlay mounted `nosuid,nodev` with the one option set of the caller's `OverlayMode`, `metacopy=on,xino=on` or, for lowers that may carry untrusted overlay xattrs, `metacopy=off,redirect_dir=nofollow`, with no fallback, whose upper and work directories are on the `Upper`: an ext4 scratch mounted `nosuid,nodev`, whose superblock it checks for a journal that the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` at the kernel's default size. It refuses overlay options longer than `MOUNT_OPTIONS_LIMIT`, the most that a mount passes, which, below `/run/nvx`, fit up to 195 layers of a GPT disk, and an overlay that the kernel's mount table shows read-only or without the mode's options in effect, since the kernel mounts one so, rather than fail, when its upper can't hold its work directory or its extended attributes. The `Root` reports the first partition's bytes, its `Lower` layers, its `UpperKind`, and its `OverlayMode`. `Root::mount_share` and `Root::mount_aggregate`: virtio-fs live shares at targets in the root that it checks, against the targets that `protocol::share` reserves among others, with one aggregate per root, which it attaches through its `mappings::Mapper` beneath the overlay, with NVX's container root's reserved targets: a share's device mounted out of the root, then moved onto its target, and a copy of each child's entry of the aggregate. `Root::mount_export`: the host export, the aggregate device of `protocol::export`, one per root, mounted `nosuid,nodev` with the access of its `ExportMode` at `hostfs/root` in the runtime directory, out of the root, below a directory that only root may enter, whose root it returns as the `SourceRoot` of the caller's mappings. `Root::map`: a caller's host mappings, through the same mapper. A share, a child, the export, or a mapping that fails latches the root. `Root::teardown`: the shares and mappings in reverse order, then the mounts of an aggregate and of the export, the later first, the overlay, the layers from the top down, and the upper, with every failed unmount reported. Each failure is a typed `Error`. The operations collect no child's status, change neither the caller's root nor its credentials, start no async runtime, and never power the VM off | | `nvx::guest::gpt` | `read_disk`: the partition table of a GPT disk, read and checked with `nvx::gpt`'s `Disk::read` through the disk's block device, which the caller opened, at the logical block size and size that `BLKSSZGET` and `BLKGETSIZE64` report, with the caller's `Expected` values, such as the disk GUID that a boot parameter names. It only reads the device, at explicit offsets, so it never moves the file's position. It neither compares the partitions that the kernel created with the table nor asks the kernel to reread the table. Each failure is a typed `DeviceError` | diff --git a/nvx-rs/src/guest/clone.rs b/nvx-rs/src/guest/clone.rs index 37a8d7986..e0270c55e 100644 --- a/nvx-rs/src/guest/clone.rs +++ b/nvx-rs/src/guest/clone.rs @@ -116,13 +116,14 @@ //! record with [`ActivationRecord::decode`](crate::activation::ActivationRecord::decode), and //! accepts it only as a clone's, with a restore section, that expects the CPU count and the //! memory size that the repair reports, before anything has a side effect; -//! 3. activating: the adapter applies the accepted record, activating the root and the workload -//! with its settings, then reports the result to [`Activation::complete`]; +//! 3. activating: the adapter applies the accepted record with [`apply`](fn@apply), which +//! activates the root and the workload with its settings, as below, then reports the result +//! to [`Activation::complete`]; //! 4. ready, with the mappings pending: the activation completed, opened the fence, and returned -//! what Ready reports, 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 start of the sandbox follow in the adapter's order, as after a -//! cold boot; +//! 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 +//! start of the sandbox follow in the adapter's order, as after a cold boot; //! 5. started: the adapter's order started the sandbox, which [`Activation::start`] records. //! //! The host retries an Activate with the same transaction ID and a byte-equal record, and such a @@ -138,6 +139,23 @@ //! and its [`ActivationCause`], and which every later call returns again. The activation never //! powers the VM off: the adapter reports the failure, and ends the VM. //! +//! [`apply`](fn@apply) activates the clone as at a cold boot. It assembles the root with +//! [`root::assemble`], with the record's image, an EROFS image whose UUID it checks if the record +//! expects one, its upper, a tmpfs or the scratch, with the record's journal policy, and its +//! overlay mode, and with the inputs that the guest takes from its own configuration, which its +//! template captured, a [`RootTemplate`]: the runtime directory, the block devices, and how long +//! the assembly waits for them. It refuses a GPT disk before anything has a side effect: the +//! kernel created the block devices of a disk's partitions when it found the template's +//! placeholder. If the record has a workload identity, it then checks its account in the +//! assembled root, before Ready, as NVX's init checks it at boot: the identity is +//! [`Identity::new`](super::entry::Identity::new) of the record's user and group IDs, without +//! supplementary groups or capabilities, in existing-account mode, and every execution runs as +//! it. Without one, the adapter gives each execution its own identity, which +//! [`Entry::check`](super::entry::Entry::check) checks. It returns the [`Applied`] root and +//! identity, which the adapter keeps, or an [`ApplyError`], after which nothing that it mounted +//! stays mounted, unless the error holds the unmounts that failed. It applies none of the +//! record's other settings: its export, its network, its hostname, and its machine ID. +//! //! # Example //! //! ```no_run @@ -218,7 +236,10 @@ //! 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. +//! system call: of the clone, it changes only the caller's fence, which it opens. Its +//! application, [`apply`](fn@apply), needs the guest's root user, in the guest's initial mount +//! namespace, as [`root::assemble`] does: it mounts the root, reads the account databases below +//! it, and, if it fails, tears it down. It starts no process and generates no ID. mod activation; mod mounts; @@ -242,7 +263,7 @@ use super::time::{ pub use self::activation::{ Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, - ActivationState, + ActivationState, Applied, ApplyError, RootTemplate, apply, }; pub use self::mounts::{Mounted, Origin}; pub use self::repair::{ diff --git a/nvx-rs/src/guest/clone/activation.rs b/nvx-rs/src/guest/clone/activation.rs index bc1c9547e..063efe4e6 100644 --- a/nvx-rs/src/guest/clone/activation.rs +++ b/nvx-rs/src/guest/clone/activation.rs @@ -1,7 +1,9 @@ //! The activation of a repaired clone, in the caller's process: [`Activation`], the state machine //! of the clone's restricted control session, through which the clone takes its configuration from -//! the host in one Activate, as the [module](super#activation) describes. +//! the host in one Activate, and [`apply`](fn@apply), which activates the clone's root and workload +//! with that configuration, as the [module](super#activation) describes. +mod apply; #[cfg(test)] mod tests; @@ -15,6 +17,8 @@ use crate::activation::{ ActivationError, ActivationRecord, ExpectedResources, ReadyActivation, TransactionId, }; +pub use self::apply::{Applied, ApplyError, RootTemplate, 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 @@ -22,11 +26,12 @@ use crate::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 -/// adapter applies the record of the Activate that the activation accepted, and reports the -/// result through [`complete`](Self::complete), and it reports the start of the sandbox, which -/// its own order takes once the activation is ready, through [`start`](Self::start). The -/// adapter's loop wakes by the [`deadline`](Self::deadline), and [ticks](Self::tick) the -/// activation then, whether input arrives or not, and whether its output drains or not. +/// adapter applies the record of the Activate that the activation accepted, with +/// [`apply`](fn@apply), and reports the result through [`complete`](Self::complete), and it +/// reports the start of the sandbox, which its own order takes once the activation is ready, +/// through [`start`](Self::start). The adapter's loop wakes by the [`deadline`](Self::deadline), +/// and [ticks](Self::tick) the activation then, whether input arrives or not, and whether its +/// output drains or not. /// /// It goes through the [`ActivationState`]s in order, and each takes these inputs: /// @@ -45,12 +50,12 @@ use crate::activation::{ /// # Example /// /// ```no_run -/// use std::error::Error; /// use std::time::Instant; /// -/// use nvx::activation::{ActivationRecord, ReadyActivation}; +/// use nvx::activation::ReadyActivation; /// use nvx::guest::clone::{ -/// Activation, ActivationAction, ActivationFailure, ActivationInput, AdmissionFence, +/// self, Activation, ActivationAction, ActivationFailure, ActivationInput, AdmissionFence, +/// Applied, RootTemplate, /// }; /// /// /// What the agent's control session received from the host, in the agent's own framing. @@ -71,18 +76,22 @@ use crate::activation::{ /// fn receive(&mut self, deadline: Option) -> Option; /// /// Acknowledges the session's reset, and abandons the earlier epoch's work. /// fn acknowledge(&mut self); -/// /// Activates the root and the workload with the settings of `record`. -/// fn apply(&mut self, record: &ActivationRecord) -> Result<(), Box>; -/// /// Sends Ready, which reports `activation`. +/// /// Keeps the clone's activated root, and the identity, if any, in which it runs every +/// /// execution. +/// fn keep(&mut self, applied: Applied); +/// /// Sends Ready, which reports `activation` beside the agent's own identities, such as its +/// /// build. /// fn ready(&mut self, activation: ReadyActivation); /// /// Serves `message` in the agent's order, as after a cold boot, and returns whether the /// /// host started the sandbox with it. /// fn serve(&mut self, message: Vec) -> bool; /// } /// -/// /// Serves the control session of the clone whose activation is `activation`, until it fails. +/// /// 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( /// agent: &mut impl Agent, +/// template: &RootTemplate, /// mut activation: Activation, /// ) -> Result<(), ActivationFailure> { /// loop { @@ -103,8 +112,14 @@ use crate::activation::{ /// 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. -/// let result = agent.apply(&record); -/// let ready = activation.complete(Instant::now(), result)?; +/// let ready = match clone::apply(&record, template) { +/// Ok(applied) => { +/// agent.keep(applied); +/// activation.complete(Instant::now(), Ok(()))? +/// } +/// // A failed application fails the activation: the fence stays closed. +/// Err(error) => activation.complete(Instant::now(), Err(error.into()))?, +/// }; /// agent.ready(ready); /// } /// ActivationAction::Ready(ready) => agent.ready(ready), @@ -415,9 +430,9 @@ pub enum ActivationAction { /// The activation took the input, which needs nothing more of the adapter. Continue, /// The activation accepted the host's Activate, whose record decoded and validated, and - /// which nothing before had any side effect of: the adapter applies the record, activating - /// the root and the workload with its settings, then reports the result to - /// [`Activation::complete`]. + /// which nothing before had any side effect of: the adapter applies the record with + /// [`apply`](fn@apply), which activates the root and the workload with its settings, then + /// reports the result to [`Activation::complete`]. Apply(Box), /// A retry of the accepted Activate joined the activation that runs: what /// [`Activation::complete`] returns answers it too, as the adapter's framing answers each diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs new file mode 100644 index 000000000..431b69b98 --- /dev/null +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -0,0 +1,264 @@ +//! 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. + +#[cfg(test)] +mod tests; + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(doc)] +use super::Activation; +use crate::activation::{self, ActivationRecord, Configuration, WorkloadIdentity}; +#[cfg(doc)] +use crate::guest::entry::AccountMode; +use crate::guest::entry::{self, Entry, Identity}; +use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; + +/// The inputs of a clone's root assembly 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, and how long the assembly waits for each. [`apply`](fn@apply) takes the rest from the +/// activation record. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RootTemplate { + /// The runtime directory, below which the root is mounted, as [`Assembly::runtime`] tells. + 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. + pub timeout: Duration, +} + +/// A clone's root and workload, which [`apply`](fn@apply) activated: the [`Root`] that it +/// assembled, which owns the root's mounts, and the identity in which every execution runs, if +/// the record has a workload identity. +/// +/// Without one, the adapter gives each execution its own identity, which [`Entry::check`] checks +/// when the adapter prepares the execution's entry, as at a cold boot. Dropping it tears the root +/// down, as dropping a [`Root`] does. +#[derive(Debug)] +pub struct Applied { + /// The assembled root. + root: Root, + /// The identity in which every execution runs, if the record has a workload identity. + identity: 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 identity in which every execution runs: [`Identity::new`] of the record's + /// workload identity, without supplementary groups or capabilities, in existing-account mode, + /// whose account the root holds. Returns `None` if the record has no workload identity, so + /// that each execution names its own. + pub fn identity(&self) -> Option<&Identity> { + self.identity.as_ref() + } + + /// Returns the assembled root, and the identity in which every execution runs, if any. + pub fn into_parts(self) -> (Root, Option) { + (self.root, self.identity) + } +} + +/// Applies `record`, the record of the Activate that a clone's [`Activation`] accepted, to the +/// clone, as at a cold boot, 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; its upper, a tmpfs, or the scratch, +/// with the record's journal policy; and its overlay mode; and, from `template`, the runtime +/// directory, the devices, and the timeout. It refuses a GPT disk, with +/// [`ApplyError::Gpt`], before anything has a side effect; +/// 2. assembles the root with [`root::assemble`], which checks the devices, and the image as the +/// record expects it, and mounts the root, or fails with [`ApplyError::Assembly`], once it +/// unmounted what it mounted; +/// 3. if the record has a workload identity, checks its account in the assembled root, before +/// Ready, as NVX's init checks it at boot: the identity is [`Identity::new`] of the record's +/// user and group IDs, without supplementary groups or capabilities, in +/// [existing-account](AccountMode::Existing) mode, whose account [`Entry::prepare`] reads and +/// checks below the root. A refusal fails with [`ApplyError::Account`], once the application +/// tore the root down. +/// +/// It applies none of the record's other settings: its export, its network, its hostname, and +/// its machine ID. It starts no process and generates no ID, and it 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. +pub fn apply(record: &ActivationRecord, template: &RootTemplate) -> Result { + let (root, identity) = apply_with(&mut LiveSystem, record, template)?; + Ok(Applied { root, identity }) +} + +/// 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. +#[derive(Debug)] +#[non_exhaustive] +pub enum ApplyError { + /// The record's image is a GPT disk, which the application refuses before anything has a + /// side effect: the kernel created the block devices of a disk's partitions when it found the + /// template's placeholder, so they need not be the clone's. + Gpt, + /// The root's assembly failed, as this error tells, and unmounted what it had mounted, unless + /// the error holds the unmounts that failed. + Assembly(AssemblyError), + /// Existing-account mode refused the record's workload identity in the assembled root, which + /// the application then tore down. + Account { + /// Why the identity was refused: the root holds no account of its user ID whose primary + /// group is its group ID, and which its group database holds, or its account databases + /// cannot be read. + error: entry::Error, + /// 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 { + match self { + Self::Gpt => formatter + .write_str("the record's image is a GPT disk, which a clone does not assemble"), + Self::Assembly(error) => write!(formatter, "the root's assembly failed: {error}"), + Self::Account { error, teardown } => { + write!(formatter, "the root refuses the workload identity: {error}")?; + 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 => None, + Self::Assembly(error) => Some(error), + Self::Account { error, .. } => Some(error), + } + } +} + +/// 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; + + /// Assembles the root of `assembly`, as [`root::assemble`] does. + fn assemble(&mut self, assembly: &Assembly) -> Result; + + /// Reads and checks the account of `identity` in `root`, as [`check_account`] does. + fn account(&mut self, root: &Self::Root, identity: &Identity) -> Result<(), entry::Error>; + + /// 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; + + fn assemble(&mut self, assembly: &Assembly) -> Result { + root::assemble(assembly) + } + + fn account(&mut self, root: &Root, identity: &Identity) -> Result<(), entry::Error> { + check_account(root.path(), identity) + } + + fn teardown(&mut self, root: Root) -> Result<(), TeardownError> { + root.teardown() + } +} + +/// Applies `record` with `template` through `system`, as [`apply`](fn@apply) does, and returns +/// the root that it assembled, and the identity in which every execution runs, if any. +fn apply_with( + system: &mut S, + record: &ActivationRecord, + template: &RootTemplate, +) -> Result<(S::Root, Option), ApplyError> { + let configuration = record.configuration(); + let assembly = assembly(template, configuration)?; + let identity = configuration.workload.identity.map(workload_identity); + let root = system.assemble(&assembly).map_err(ApplyError::Assembly)?; + if let Some(identity) = &identity + && let Err(error) = system.account(&root, identity) + { + let teardown = system.teardown(root).err(); + return Err(ApplyError::Account { error, teardown }); + } + Ok((root, identity)) +} + +/// Returns the assembly of the root that `configuration` sets, with `template`'s runtime +/// directory, devices, and timeout, or refuses a GPT disk. +fn assembly( + template: &RootTemplate, + configuration: &Configuration, +) -> Result { + let image = match configuration.image { + activation::Image::Erofs { uuid, .. } => root::Image::Erofs { uuid }, + activation::Image::Gpt { .. } => return Err(ApplyError::Gpt), + }; + let upper = match configuration.upper { + activation::Upper::Tmpfs => root::Upper::Tmpfs, + 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, + }; + Ok(Assembly { + runtime: template.runtime.clone(), + distro: template.distro, + image, + upper, + overlay, + timeout: template.timeout, + }) +} + +/// Returns the identity in which every execution of a workload with `identity` runs: its user +/// and group IDs, without supplementary groups or capabilities, whose account an entry checks in +/// existing-account mode. +fn workload_identity(identity: WorkloadIdentity) -> Identity { + Identity::new(identity.uid, identity.gid) +} + +/// Reads and checks the account of `identity` below the root at `root`, as an entry does in +/// existing-account mode before each execution: through [`Entry::prepare`]. +fn check_account(root: &Path, identity: &Identity) -> Result<(), entry::Error> { + Entry::new(root, identity.clone()).prepare().map(drop) +} diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs new file mode 100644 index 000000000..02e78650d --- /dev/null +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -0,0 +1,824 @@ +//! 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 +//! reads and checks for real; 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. + +use std::cell::Cell; +use std::error::Error as _; +use std::fs; +use std::io; +use std::net::{IpAddr, Ipv4Addr}; +use std::num::NonZeroU64; +use std::rc::Rc; +use std::time::Instant; + +use super::*; +use crate::activation::{ + ExpectedResources, Export, ExportKind, MachineIdMode, Network, Publication, ReadyActivation, + RestoreSection, StoreGeneration, TemplateReference, TransactionId, WorkloadView, +}; +use crate::guest::clone::{ + Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, + ActivationState, AdmissionFence, +}; +use crate::guest::entry::{AccountError, AccountMode, Capabilities, Database}; +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"; +/// 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 tests' workload identity, whose account the tests' image holds. +const WORKLOAD: WorkloadIdentity = WorkloadIdentity { + uid: 1000, + gid: 1001, +}; +/// 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"; + +/// Returns whether a failure's cause is the one that a test expects. +type Expected = fn(&ActivationCause) -> bool; + +/// Returns whether an account's refusal is the one that a test expects. +type Refusal = fn(&AccountError) -> 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 assembled the root of this assembly, which mounts it. + Assemble(Assembly), + /// The application checked this identity's account in the root, which reads the image's + /// account databases. + Account(Identity), + /// 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, + /// What the clone did, in order. + effects: 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, + effects: 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 the path of the root that each assembly returns. + fn root(&self) -> PathBuf { + self.image.path().to_owned() + } +} + +impl System for FakeSystem { + type Root = PathBuf; + + fn assemble(&mut self, assembly: &Assembly) -> Result { + self.effects.push(Effect::Assemble(assembly.clone())); + 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 account(&mut self, root: &PathBuf, identity: &Identity) -> Result<(), entry::Error> { + self.effects.push(Effect::Account(identity.clone())); + check_account(root, identity) + } + + 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, and the identity in which every execution runs, + /// if any. + applied: Option<(PathBuf, 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::of( + 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 result = match apply_with(&mut self.system, &record, &template()) { + 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, and its timeout. +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, + } +} + +/// 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 { + binding: activation::Identity::from_bytes([0x11; activation::Identity::LEN]), + uuid, + } +} + +/// Returns a GPT disk, without a GUID that the record expects of it. +fn gpt() -> activation::Image { + activation::Image::Gpt { + binding: activation::Identity::from_bytes([0x11; activation::Identity::LEN]), + disk_guid: None, + first_partition_bound: 4096, + } +} + +/// Returns the scratch upper, with the journal policy `journal`. +fn scratch(journal: activation::Journal) -> activation::Upper { + activation::Upper::Scratch { + binding: activation::Identity::from_bytes([0x33; activation::Identity::LEN]), + journal, + } +} + +/// Returns the configuration of a root of `image` over `upper`, in `overlay` mode, whose workload +/// runs as `identity`, if any, which expects the tests' clone's resources, without an export or +/// a network. +fn configuration( + image: activation::Image, + upper: activation::Upper, + overlay: activation::OverlayMode, + identity: Option, +) -> Configuration { + Configuration { + image, + upper, + overlay, + export: None, + mappings: 0, + network: None, + workload: WorkloadView { + hostname: b"clone".to_vec(), + machine_id: MachineIdMode::Image, + identity, + }, + 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, + template: TemplateReference { + generation: StoreGeneration::new(NonZeroU64::MIN), + key: activation::Identity::from_bytes([0x44; activation::Identity::LEN]), + }, + }; + ActivationRecord::new(Some(restore), configuration).unwrap() +} + +/// Returns the record of the tests' Activate: an EROFS image, whose UUID it expects, over a +/// tmpfs, with untrusted lowers, whose workload runs as the tests' workload identity. +fn accepted() -> ActivationRecord { + clone_record(configuration( + erofs(Some(IMAGE_UUID)), + activation::Upper::Tmpfs, + activation::OverlayMode::UntrustedLowers, + Some(WORKLOAD), + )) +} + +/// 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, + overlay: root::OverlayMode::UntrustedLowers, + timeout: template.timeout, + } +} + +/// Returns the identity in which the executions of the tests' workload run. +fn workload() -> Identity { + Identity::new(WORKLOAD.uid, WORKLOAD.gid) +} + +/// 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 or generates an ID, and the adapter spawns each execution + // itself, once the activation serves its request. + let base = Instant::now(); + let record = accepted().encode(); + let settings = accepted().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(), + ..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); + } + + // The application refuses a GPT disk, which the activation accepted, before it mounts + // anything. + let mut adapter = Adapter::restricted(FakeSystem::new(), base); + let failure = adapter + .take(base, ActivationInput::Activate(&disk)) + .unwrap_err(); + adapter.assert_untouched(&failure, |cause| { + matches!(cause, ActivationCause::Failed(error) + if matches!(error.downcast_ref::(), Some(ApplyError::Gpt))) + }); + assert_eq!(failure.state(), ActivationState::Activating); + + // 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, and reads its account databases, 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, + [ + Effect::Assemble(accepted_assembly()), + Effect::Account(workload()), + ] + ); + 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, and checked the workload + // identity's account in it, 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((adapter.system.root(), Some(workload()))) + ); + assert!(adapter.fence.is_open()); + // Every execution runs as the workload identity, which has no supplementary groups or + // capabilities, and whose account an entry checks in existing-account mode. + let identity = workload(); + assert_eq!((identity.uid(), identity.gid()), (1000, 1001)); + assert!(identity.supplementary_groups().is_empty()); + assert_eq!(identity.capability_set(), Capabilities::EMPTY); + assert_eq!(identity.account_mode(), AccountMode::Existing); + + // 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::Account(workload()), + Effect::Spawn, + Effect::Spawn, + ] + ); +} + +#[test] +fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { + let template = template(); + let images = [ + (erofs(None), None), + (erofs(Some(IMAGE_UUID)), Some(IMAGE_UUID)), + ]; + let uppers = [ + (activation::Upper::Tmpfs, root::Upper::Tmpfs), + ( + 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, uuid) in images { + for (upper, root_upper) in uppers { + for (overlay, root_overlay) in overlays { + for identity in [None, Some(WORKLOAD)] { + let record = clone_record(configuration(image, upper, overlay, identity)); + let mut system = FakeSystem::new(); + let (root, applied) = apply_with(&mut system, &record, &template).unwrap(); + assert_eq!(root, system.root()); + let assembly = Assembly { + runtime: template.runtime.clone(), + distro: template.distro, + image: root::Image::Erofs { uuid }, + upper: root_upper, + overlay: root_overlay, + timeout: template.timeout, + }; + let identity = identity.map(|_| workload()); + let mut effects = vec![Effect::Assemble(assembly)]; + effects.extend(identity.clone().map(Effect::Account)); + assert_eq!(system.effects, effects, "{record:?}"); + assert_eq!(applied, identity); + } + } + } + } + + // The record's other settings, which the application leaves alone, change neither input: + // its bindings, export, network, hostname, machine ID, resources, and restore section. + let mut other = accepted().configuration().clone(); + other.image = activation::Image::Erofs { + binding: activation::Identity::from_bytes([0x66; activation::Identity::LEN]), + uuid: Some(IMAGE_UUID), + }; + other.export = Some(Export { + kind: ExportKind::Aggregate, + plan: activation::Identity::from_bytes([0x77; activation::Identity::LEN]), + }); + other.mappings = 3; + other.network = Some(Network { + policy: activation::Identity::from_bytes([0x88; activation::Identity::LEN]), + dns: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + publication: Publication::Published, + proxy: Some("http://10.0.0.1:3128".to_owned()), + }); + other.workload.hostname = b"another".to_vec(); + other.workload.machine_id = MachineIdMode::Generated; + 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_identity = apply_with(&mut first, &accepted(), &template).unwrap().1; + let second_identity = apply_with(&mut second, &other, &template).unwrap().1; + assert_eq!(first.effects, second.effects); + assert_eq!(first_identity, second_identity); +} + +#[test] +fn a_workload_identity_without_an_account_in_the_image_is_refused_before_ready() { + let base = Instant::now(); + let record = accepted().encode(); + // Images that hold no account of the workload identity's user ID whose primary group is its + // group ID, which the group database holds, and whose home is absolute. + let without_passwd = FakeSystem::new(); + fs::remove_file(without_passwd.image.path().join("etc/passwd")).unwrap(); + let systems: [(FakeSystem, Refusal); 5] = [ + ( + FakeSystem::with(b"root:x:0:0:root:/root:/bin/sh\n", GROUP), + |error| matches!(error, AccountError::Missing), + ), + ( + FakeSystem::with(b"workload:x:1000:1002::/home/workload:/bin/sh\n", GROUP), + |error| matches!(error, AccountError::PrimaryGroup(1002)), + ), + (FakeSystem::with(PASSWD, b"root:x:0:\n"), |error| { + matches!(error, AccountError::Group) + }), + ( + FakeSystem::with(b"workload:x:1000:1001::home:/bin/sh\n", GROUP), + |error| matches!(error, AccountError::Home), + ), + (without_passwd, |error| { + matches!(error, AccountError::Read(Database::Passwd, _)) + }), + ]; + for (system, refusal) in systems { + let mut adapter = Adapter::restricted(system, base); + let failure = adapter + .take(base, ActivationInput::Activate(&record)) + .unwrap_err(); + assert_eq!(failure.state(), ActivationState::Activating); + let ApplyError::Account { + error: entry::Error::Account(error), + teardown: None, + } = apply_error(&failure) + else { + panic!("{failure}"); + }; + assert!(refusal(error), "{error}"); + // The root was assembled, its account databases read, and the root torn down, without + // a Ready, and behind the closed fence. + assert_eq!( + adapter.system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::Account(workload()), + Effect::Teardown, + ] + ); + 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, and nothing is served. + for input in [ + ActivationInput::Activate(&record), + ActivationInput::Request, + ActivationInput::Reset, + ] { + assert!(adapter.take(base, input).is_err()); + } + assert_eq!(adapter.system.effects.len(), 3); + } + + // Without a workload identity, the application reads no account: each execution names its + // own identity, which the adapter's entry checks. + let anyone = clone_record(configuration( + erofs(Some(IMAGE_UUID)), + activation::Upper::Tmpfs, + activation::OverlayMode::UntrustedLowers, + None, + )); + let mut system = FakeSystem::with(b"", b""); + let (_, identity) = apply_with(&mut system, &anyone, &template()).unwrap(); + assert_eq!(identity, None); + assert_eq!(system.effects, [Effect::Assemble(accepted_assembly())]); +} + +#[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 the_application_refuses_without_privileges_what_it_cannot_assemble() { + // A GPT disk is refused before anything is mounted, and a template's runtime directory + // reaches the assembly, which refuses a relative one before it does anything else. + let disk = clone_record(configuration( + gpt(), + activation::Upper::Tmpfs, + activation::OverlayMode::TrustedLowers, + Some(WORKLOAD), + )); + assert!(matches!(apply(&disk, &template()), Err(ApplyError::Gpt))); + let relative = RootTemplate { + runtime: PathBuf::from("run/nvx"), + ..template() + }; + let Err(ApplyError::Assembly(error)) = apply(&accepted(), &relative) else { + panic!("a relative runtime directory was assembled"); + }; + assert!( + matches!(error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx")), + "{error}" + ); +} + +#[test] +fn application_errors_name_their_cause() { + assert_eq!( + ApplyError::Gpt.to_string(), + "the record's image is a GPT disk, which a clone does not assemble" + ); + assert!(ApplyError::Gpt.source().is_none()); + + 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 error = ApplyError::Account { + error: entry::Error::Account(AccountError::Missing), + teardown: None, + }; + assert_eq!( + error.to_string(), + "the root refuses the workload identity: the account is unavailable: no passwd entry has \ + the user ID" + ); + assert_eq!( + error.source().unwrap().to_string(), + "the account is unavailable: no passwd entry has the user ID" + ); + assert!(format!("{error:?}").starts_with("Account { error: Account(Missing)")); + + // The adapter hands the error to the activation, whose failure names it. + let base = Instant::now(); + let mut adapter = Adapter::restricted(FakeSystem::with(b"", GROUP), base); + let failure = adapter + .take(base, ActivationInput::Activate(&accepted().encode())) + .unwrap_err(); + assert_eq!( + failure.to_string(), + "the activation failed in its activating state: the record's application failed: the \ + root refuses the workload identity: the account is unavailable: no passwd entry has the \ + user ID" + ); + assert!(failure.source().unwrap().is::()); +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index c275b46f8..f75ad0e47 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -17,7 +17,8 @@ //! through which each clone that a restore of the snapshot starts repairs its restore: it //! activates the restore's memory target, reseeds the kernel's random number generator, and //! finishes the restore, with its acknowledgement, and then takes its own configuration from the -//! host, in one Activate on a restricted control session, before it admits any execution. +//! host, in one Activate on a restricted control session, with which it assembles its root and +//! checks its workload identity before it admits any execution. //! //! Each other module holds one program, and the crate's binary of the same name runs its `main`. //! Two of them also offer their steps as typed calls, which any guest init or agent runs in its