diff --git a/nvx-rs/README.md b/nvx-rs/README.md index f91486568..3c9385cb3 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -1203,9 +1203,10 @@ with the record's image, an EROFS image whose UUID it checks if the record expects one, or a GPT disk whose GUID 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, but waits for no device past the activation's deadline, which the -adapter passes, so that the application ends by then. +which its template captured: the runtime directory, the block devices, the +timeout, and the host filesystem device's virtio-fs tag, but waits for no device +past the activation's deadline, which the adapter passes, so that the +application ends by then. For a GPT disk, it first asks the kernel to reread the disk's partition table with `BLKRRPART`, since the kernel created the block devices of partitions for @@ -1219,15 +1220,24 @@ every execution runs, while without one each execution names its own identity, which `Entry::check` checks. 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, and it binds the runtime's files for every execution with -`Entry::bind_runtime` in the caller's mount namespace: that file read-only over -the root's `/etc/machine-id`, or, with the image's own machine ID, nothing; the -entry checks the record's hostname, which every execution's entry takes with -`Entry::hostname`. It returns the `Applied` root and workload, whose `teardown` +directory. 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. 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 +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, and `proxy` the network's proxy endpoint, which the adapter tells its +workloads as at a cold boot, and whose `teardown` closes the export's root and removes the binds before it tears the root down, as a drop does, or an -`ApplyError`, after which nothing that it mounted or bound stays mounted unless -the error reports the unmounts that failed, and applies none of the record's -export or network. +`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 applies none of the record's listener publication. Once the adapter passed the activation what the session received while the application ran, which came before Ready, `Activation::complete` opens the fence diff --git a/nvx-rs/src/guest/clone.rs b/nvx-rs/src/guest/clone.rs index 3f6116f8d..f3535d716 100644 --- a/nvx-rs/src/guest/clone.rs +++ b/nvx-rs/src/guest/clone.rs @@ -147,33 +147,41 @@ //! record expects one, or a GPT disk whose GUID 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 -//! each, but never past the activation's deadline, which the adapter passes, so that the -//! application ends by then. For a GPT disk, it first asks the kernel to reread the disk's -//! partition table, once the distro device appeared, by the same deadline: the kernel created the -//! block devices of partitions from the template's placeholder, when it found it, and creates those -//! of the clone's disk then, from the same read-only bytes that the assembly reads and checks, as -//! at a cold boot. The upper must be fresh: the assembly refuses one whose `upper` or `work` -//! directory exists and is not empty, since a clone's root starts as its image alone, while a cold -//! sandbox's scratch keeps its upper across boots; the check reads at most one entry of each, and -//! no file. 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 +//! [`RootTemplate`]: the runtime directory, the block devices, the host filesystem device's +//! virtio-fs tag, and how long the assembly waits for each block device, but never past the +//! activation's deadline, which the adapter passes, so that the application ends by then. For a GPT +//! disk, it first asks the kernel to reread the disk's partition table, once the distro device +//! appeared, by the same deadline: the kernel created the block devices of partitions from the +//! template's placeholder, when it found it, and creates those of the clone's disk then, from the +//! same read-only bytes that the assembly reads and checks, as at a cold boot. The upper must be +//! fresh: the assembly refuses one whose `upper` or `work` directory exists and is not empty, since +//! a clone's root starts as its image alone, while a cold sandbox's scratch keeps its upper across +//! boots; the check reads at most one entry of each, and no file. 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. 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, and it binds the runtime's files into the root for every execution, with -//! [`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`, or, with the image's -//! own machine ID, nothing. The entry checks the record's hostname first, which every execution's -//! entry then takes. It returns the [`Applied`] root and workload, which the adapter keeps, and -//! whose [`teardown`](Applied::teardown) removes the binds before it tears the root down, or an -//! [`ApplyError`], after which nothing that it mounted or bound stays mounted, unless the error -//! holds the unmounts that failed. It applies none of the record's other settings: its export, -//! and its network. +//! directory. If the record has an export, it mounts the host export through the template's +//! filesystem device, with [`Root::mount_export`](root::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. 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 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, and the network's proxy endpoint, which the adapter tells its workloads as at a cold +//! boot, and whose [`teardown`](Applied::teardown) closes the export's root and removes the binds +//! before it tears the root down, or an [`ApplyError`], after which nothing that it mounted or +//! bound stays mounted, unless the error holds the unmounts that failed. It applies none of the +//! record's other settings: the publication of the workload's listeners. //! //! # Example //! @@ -260,8 +268,9 @@ //! 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, and generates at most one ID, -//! a machine ID that the record asks for, whose bind over the root's `/etc/machine-id` needs -//! Linux 6.8 or later. +//! 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; @@ -285,8 +294,8 @@ use super::time::{ pub use self::activation::{ Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, - ActivationState, Applied, AppliedTeardownError, ApplyError, MACHINE_ID_FILE, RootTemplate, - apply, + ActivationState, Applied, AppliedTeardownError, ApplyError, MACHINE_ID_FILE, NetworkError, + RESOLVER_FILE, 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 9b88f5804..e4956c96a 100644 --- a/nvx-rs/src/guest/clone/activation.rs +++ b/nvx-rs/src/guest/clone/activation.rs @@ -18,7 +18,8 @@ use crate::activation::{ }; pub use self::apply::{ - Applied, AppliedTeardownError, ApplyError, MACHINE_ID_FILE, RootTemplate, apply, + Applied, AppliedTeardownError, ApplyError, MACHINE_ID_FILE, NetworkError, RESOLVER_FILE, + RootTemplate, apply, }; /// The activation of a repaired clone: the state machine, without I/O and independent of any @@ -88,8 +89,10 @@ pub use self::apply::{ /// /// work. /// fn acknowledge(&mut self); /// /// Keeps the clone's activated root and workload: the identity, if any, in which it runs -/// /// every execution, with the hostname of every execution, and the root, with the runtime's -/// /// binds on it, which `Applied::teardown` removes before it tears the root down. +/// /// every execution, with the hostname of every execution, the root, with the runtime's +/// /// binds on it, which `Applied::teardown` removes before it tears the root down, and the +/// /// export's root, which `Applied::take_export` hands over, from which it maps the host's +/// /// mapping table. /// fn keep(&mut self, applied: Applied); /// /// Queues Ready, which reports `activation` beside the agent's own identities, such as its /// /// build. diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index 349e35714..6de3b9522 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -6,10 +6,15 @@ mod tests; use std::fmt; +use std::fs; use std::io; +use std::net::IpAddr; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; 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, WorkloadIdentity}; @@ -17,24 +22,33 @@ use crate::gpt::Expected; #[cfg(doc)] use crate::guest::entry::AccountMode; use crate::guest::entry::{self, Entry, Identity, MachineId, RuntimeBinds}; -use crate::guest::mappings; +use crate::guest::mappings::{self, Access, SourceRoot}; use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; +use crate::protocol::cmdline::write_resolver; /// The name of the file, in the template's [runtime directory](RootTemplate::runtime), to which /// [`apply`](fn@apply) writes the machine ID that it generates for a record that asks for one, as /// NVX's managed agent writes its own to `/run/nvx/workload-machine-id`. pub const MACHINE_ID_FILE: &str = "workload-machine-id"; +/// The name of the file, in the template's [runtime directory](RootTemplate::runtime), to which +/// [`apply`](fn@apply) writes the resolver file that names the DNS server of a record's network, +/// which every execution sees read-only at `/etc/resolv.conf`. +pub const RESOLVER_FILE: &str = "resolv.conf"; + +/// The mode of the resolver file, whatever the umask: every execution may read it. +const RESOLVER_MODE: u32 = 0o444; + /// 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. +/// have, how long the assembly waits for each, and the host filesystem device, which its +/// virtio-fs tag names. [`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, /// and in which the application writes a machine ID that it generates, as - /// [`MACHINE_ID_FILE`]. + /// [`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, @@ -44,22 +58,34 @@ pub struct RootTemplate { /// 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, } /// A clone's root and workload, which [`apply`](fn@apply) activated: the [`Root`] that it -/// assembled, which owns the root's mounts, the runtime's binds that it made on the root, in the -/// caller's mount namespace, the identity in which every execution runs, if the record has a -/// workload identity, and the workload's hostname and machine ID, the record's. +/// 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 identity in which every execution runs, if the record has a workload +/// identity, 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, and the endpoint of its proxy, if any. /// /// Without a workload identity, 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. /// Every execution's entry takes the [hostname](Self::hostname), through [`Entry::hostname`], and -/// sees the [machine ID](Self::machine_id) through the runtime's binds, which each execution's -/// mount namespace copies from the caller's. [`teardown`](Self::teardown) removes the binds, then -/// tears the root down, and dropping it does the same, ignoring failures, as dropping a [`Root`] -/// does. +/// 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. [`teardown`](Self::teardown) 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. #[derive(Debug)] pub struct Applied { + /// 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, @@ -71,6 +97,12 @@ pub struct Applied { 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 { @@ -107,30 +139,66 @@ impl Applied { &self.machine_id } - /// Removes the runtime's binds, then tears the root down, as [`Root::teardown`] does, in the - /// caller's mount namespace, which must be the one where the application made them: the binds - /// keep the root's mount busy, and a teardown detaches no busy mount. + /// 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() + } + + /// 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. /// - /// It attempts both steps, whatever failed before, and fails with the binds that stay - /// mounted, and the unmounts of the root's teardown that failed, whose mounts stay. + /// 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() + } + + /// 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 { - mut binds, root, .. + export, + mut binds, + root, + .. } = self; - teardown_with(&mut LiveSystem, binds.take(), root) + teardown_with(&mut LiveSystem, export, binds.take(), root) } /// Returns the assembled root, the runtime's binds on it, and the identity in which every /// execution runs, if any, 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. + /// since dropping them leaves them mounted. It closes the root of the host export, unless the + /// adapter [took](Self::take_export) it. pub fn into_parts(self) -> (Root, RuntimeBinds, Option) { let Self { + export, mut binds, root, identity, .. } = self; + drop(export); (root, binds.take(), identity) } } @@ -206,7 +274,8 @@ impl std::error::Error for AppliedTeardownError {} /// the record expects of it, if any, and the bound of its first partition; 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 takes the workload's -/// hostname and machine-ID mode from the record too; +/// hostname and machine-ID mode, whether it has an export, and its network's DNS server and +/// proxy endpoint from the record too; /// 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 @@ -237,26 +306,48 @@ impl std::error::Error for AppliedTeardownError {} /// 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; -/// 6. binds the runtime's files into the root, for every execution, with +/// 6. 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; +/// 7. 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; +/// 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`, and, -/// with the image's own machine ID, nothing. The entry checks the record's hostname first, -/// which every execution's entry then takes, through [`Entry::hostname`]. A failure fails with +/// 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, and the application 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. A failure after the generation leaves -/// the machine ID's file, since the activation fails finally, and the adapter ends the VM. +/// 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 applies none of the record's other settings: its export, and its network. It starts no -/// 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 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, and the endpoint of the +/// network's proxy, which it leaves to the adapter, which tells its workloads as at a cold boot. +/// It applies none of the record's other settings: the publication of the workload's listeners. +/// It starts no 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 bind of a generated machine ID needs Linux 6.8 or later, as -/// [`Entry::bind_runtime`] does. +/// [`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. pub fn apply( record: &ActivationRecord, template: &RootTemplate, @@ -264,11 +355,14 @@ pub fn apply( ) -> Result { let activated = apply_with(&mut LiveSystem, record, template, deadline)?; Ok(Applied { + export: activated.export, binds: Binds(Some(activated.binds)), root: activated.root, identity: activated.identity, hostname: activated.hostname, machine_id: activated.machine_id, + resolver: activated.resolver, + proxy: activated.proxy, }) } @@ -319,6 +413,24 @@ pub enum ApplyError { /// 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`. The application then 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 { @@ -351,6 +463,14 @@ impl fmt::Display for ApplyError { )?; 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}"), @@ -362,10 +482,46 @@ impl fmt::Display for ApplyError { impl std::error::Error for ApplyError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Gpt(error) => Some(error), + Self::Gpt(error) | Self::Export { error, .. } => Some(error), Self::Assembly(error) => Some(error), Self::Account { 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. +#[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), +} + +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), + } + } +} + +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. + Self::Bind(error) => error.source(), } } } @@ -376,6 +532,9 @@ 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; @@ -404,6 +563,22 @@ trait System { /// 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<()>; + /// 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; @@ -420,6 +595,7 @@ struct LiveSystem; impl System for LiveSystem { type Root = Root; + type Export = SourceRoot; type Binds = RuntimeBinds; fn reread( @@ -447,6 +623,23 @@ impl System for LiveSystem { 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 bind(&mut self, root: &Root, view: &View) -> Result { view.entry(root.path()).bind_runtime() } @@ -461,8 +654,8 @@ impl System for LiveSystem { } /// The workload's view of its root, which the application sets for every execution: the -/// identity of the entry through which it binds the runtime's files, the hostname, and the -/// machine ID. +/// identity of the entry through which it binds the runtime's files, the hostname, the machine +/// ID, and the resolver file. #[derive(Clone, Debug, PartialEq, Eq)] struct View { /// The identity of the entry that binds the runtime's files: the record's workload identity, @@ -473,26 +666,36 @@ struct View { 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`]. fn entry(&self, root: &Path) -> Entry { - Entry::new(root, self.identity.clone()) + let entry = Entry::new(root, self.identity.clone()) .hostname(self.hostname.clone()) - .machine_id(self.machine_id.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 runtime's binds on it, the identity in which every execution runs, if any, and the -/// workload's hostname and machine ID. +/// root, the root of the host export, if any, the runtime's binds on the root, the identity in +/// which every execution runs, if any, the workload's hostname and machine ID, the resolver file, +/// if any, and the proxy's endpoint, if any. #[derive(Debug)] #[cfg_attr(test, derive(PartialEq, Eq))] -struct Activated { +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 identity in which every execution runs, if the record has a workload identity. @@ -501,8 +704,15 @@ struct Activated { 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, } +/// What an application through a system of type `S` activated. +type ActivatedBy = Activated<::Root, ::Export, ::Binds>; + /// Applies `record` with `template` by `deadline`, if any, through `system`, as /// [`apply`](fn@apply) does, and returns what it activated. fn apply_with( @@ -510,7 +720,7 @@ fn apply_with( record: &ActivationRecord, template: &RootTemplate, deadline: Option, -) -> Result, ApplyError> { +) -> Result, ApplyError> { let configuration = record.configuration(); let assembly = assembly(template, configuration); let workload = &configuration.workload; @@ -520,7 +730,7 @@ fn apply_with( .reread(assembly.distro, assembly.timeout, deadline) .map_err(ApplyError::Gpt)?; } - let root = system + let mut root = system .assemble(&assembly, deadline) .map_err(ApplyError::Assembly)?; if let Some(identity) = &identity @@ -541,34 +751,99 @@ fn apply_with( } } }; + // 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 view = View { identity: identity.clone().unwrap_or_else(|| Identity::new(0, 0)), hostname: workload.hostname.clone(), machine_id, + resolver, }; let binds = match system.bind(&root, &view) { Ok(binds) => binds, Err(error) => { - let teardown = system.teardown(root).err(); - return Err(ApplyError::View { error, teardown }); + 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, identity, hostname: view.hostname, machine_id: view.machine_id, + resolver: view.resolver, + proxy: network.and_then(|network| network.proxy.clone()), }) } -/// Removes `binds`, then tears `root` down, through `system`, as [`Applied::teardown`] does, -/// attempting both whatever failed before. +/// 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, + } +} + +/// 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, + export: Option, binds: S::Binds, root: S::Root, ) -> Result<(), AppliedTeardownError> { + 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() { @@ -577,6 +852,24 @@ fn teardown_with( Err(AppliedTeardownError { binds, root }) } +/// Writes a resolver file that names `server` as the guest's one DNS server, as [`write_resolver`] +/// writes one, to a new file at `path`, with mode 0444 whatever the process's umask, so that +/// every execution may read it. +/// +/// A file, or a symbolic link, at `path` already fails the call with `AlreadyExists`, and is left +/// alone, so each activation writes its resolver file once. +fn write_resolver_file(path: &Path, server: IpAddr) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(RESOLVER_MODE) + .open(path)?; + // The umask narrows the mode that creates the file, which could keep executions of other + // users from reading it. + file.set_permissions(fs::Permissions::from_mode(RESOLVER_MODE))?; + write_resolver(&mut file, server) +} + /// Returns the assembly of the root that `configuration` sets, with `template`'s runtime /// directory, devices, and timeout. fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly { diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index 0717339bb..8e0faed6b 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -1,22 +1,26 @@ //! 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 which generates no machine ID, and 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. +//! reads and checks for real, and which generates no machine ID, writes no resolver file, 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 and bind of a machine ID 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. +//! 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}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::num::NonZeroU64; +use std::os::unix::fs::{PermissionsExt, symlink}; use std::rc::Rc; use std::time::Instant; @@ -37,6 +41,8 @@ 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. @@ -65,6 +71,10 @@ workload:x:1000:1001::/home/workload:/bin/sh\n"; 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"; /// Returns whether a failure's cause is the one that a test expects. type Expected = fn(&ActivationCause) -> bool; @@ -72,6 +82,9 @@ type Expected = fn(&ActivationCause) -> bool; /// Returns whether an account's refusal is the one that a test expects. type Refusal = fn(&AccountError) -> 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)] @@ -86,9 +99,17 @@ enum Effect { Account(Identity), /// 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 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. @@ -109,9 +130,14 @@ struct FakeSystem { refusing: Option, /// The kind of the error with which each generation of a machine ID fails, if it does. generating: Option, - /// Whether each bind of the runtime's files fails, as when the image's `/etc/machine-id` is a - /// directory. - unbindable: bool, + /// 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 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. @@ -133,7 +159,9 @@ impl FakeSystem { failing: false, refusing: None, generating: None, - unbindable: false, + unmountable: false, + unwritable: None, + unbindable: None, stuck: false, effects: Vec::new(), deadlines: Vec::new(), @@ -173,11 +201,30 @@ impl FakeSystem { } } + /// 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 binds - /// of the runtime's files fails. - fn unbindable() -> Self { + /// 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: true, + unbindable: Some(error), ..Self::new() } } @@ -205,6 +252,7 @@ impl FakeSystem { impl System for FakeSystem { type Root = PathBuf; + type Export = (); type Binds = (); fn reread( @@ -258,15 +306,48 @@ impl System for FakeSystem { } } + 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 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()?; - if self.unbindable { - return Err(entry::Error::MachineId(MappingError::TargetKind)); + match self.unbindable { + Some(error) => Err(error()), + None => Ok(()), } - Ok(()) } fn remove(&mut self, (): ()) -> Result<(), mappings::TeardownError> { @@ -323,9 +404,10 @@ struct Adapter { 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 runtime's binds on it, the identity in - /// which every execution runs, if any, and the workload's hostname and machine ID. - applied: Option>, + /// What the application activated: the root, the export's root, if any, the runtime's binds + /// on the root, the identity in which every execution runs, if any, the workload's hostname + /// and machine ID, the resolver file, if any, and the proxy's endpoint, if any. + applied: Option>, /// What the adapter's latest Ready reported. ready: Option, } @@ -408,7 +490,7 @@ impl Adapter { } /// Returns the tests' template: its runtime directory, its devices at NVX's MMIO windows, with -/// the sizes that they must have, and its timeout. +/// the sizes that they must have, its timeout, and its host filesystem device's tag. fn template() -> RootTemplate { RootTemplate { runtime: PathBuf::from(RUNTIME), @@ -421,6 +503,7 @@ fn template() -> RootTemplate { sectors: Some(1 << 22), }, timeout: DEVICE_TIMEOUT, + filesystem_tag: TAG.to_owned(), } } @@ -543,6 +626,35 @@ fn with_generated_id() -> ActivationRecord { clone_record(configuration) } +/// Returns an export of `kind`, of the tests' plan. +fn export(kind: ExportKind) -> Export { + Export { + kind, + plan: activation::Identity::from_bytes([0x77; activation::Identity::LEN]), + } +} + +/// 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 { + policy: activation::Identity::from_bytes([0x88; activation::Identity::LEN]), + dns, + publication: Publication::Published, + proxy: proxy.map(str::to_owned), + } +} + +/// 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`]. +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 { @@ -554,32 +666,64 @@ 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 runs as `identity`, if any, and sees `machine_id`. +/// workload runs as `identity`, if any, and sees `machine_id`, without a resolver file. fn view(identity: Option, machine_id: MachineId) -> View { View { identity: identity.unwrap_or_else(|| Identity::new(0, 0)), hostname: HOSTNAME.to_vec(), machine_id, + resolver: None, + } +} + +/// Returns the view that the application binds for [`with_everything`]'s record: of its +/// workload identity, with the generated machine ID and the resolver file. +fn full_view() -> View { + View { + resolver: Some(resolver_path()), + ..view(Some(workload()), generated_id()) } } /// Returns what the application activates through `system` for a record of the tests' hostname, -/// whose workload runs as `identity`, if any, and sees `machine_id`. +/// whose workload runs as `identity`, if any, and sees `machine_id`, without an export or a +/// network. fn activated( system: &FakeSystem, identity: Option, machine_id: MachineId, -) -> Activated { +) -> Activated { Activated { root: system.root(), + export: None, binds: (), identity, hostname: HOSTNAME.to_vec(), machine_id, + resolver: None, + proxy: 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::Account(workload()), + Effect::Generate(machine_id_path()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::Resolver(resolver_path(), DNS), + Effect::Bind(full_view()), + ] +} + /// Returns the application's error that `failure`, an activation's, holds. fn apply_error(failure: &ActivationFailure) -> &ApplyError { match failure.cause() { @@ -592,11 +736,12 @@ fn apply_error(failure: &ActivationFailure) -> &ApplyError { 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, and the adapter spawns each execution itself, once the - // activation serves its request. Every record asks for a generated machine ID, which only - // the application of an accepted record generates. + // activation serves its request. Every record asks for a generated machine ID, has an + // export, and names a DNS server, which only the application of an accepted record + // generates, mounts, and writes. let base = Instant::now(); - let record = with_generated_id().encode(); - let settings = with_generated_id().configuration().clone(); + let record = with_everything().encode(); + let settings = with_everything().configuration().clone(); let cold = ActivationRecord::new(None, settings.clone()) .unwrap() .encode(); @@ -683,22 +828,15 @@ fn no_mount_spawn_or_id_generation_happens_before_the_gate() { adapter.assert_untouched(&failure, |cause| matches!(cause, ActivationCause::Expired)); // Only a valid Activate mounts the root, reads its account databases, generates the machine - // ID, and binds it, before Ready, and an execution spawns only once Ready opened the fence. + // ID, mounts the export, writes the resolver file, 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, - [ - Effect::Assemble(accepted_assembly()), - Effect::Account(workload()), - Effect::Generate(machine_id_path()), - Effect::Bind(view(Some(workload()), generated_id())), - ] - ); + 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)); @@ -838,22 +976,23 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { } // The record's other settings, which the application leaves alone, change no input: its - // bindings, export, network, resources, and restore section. - let mut other = accepted().configuration().clone(); + // bindings, its export's shape and plan, the number of its mappings, which the adapter's + // order takes, its network's policy and the publication of its listeners, its resources, + // and its restore section. + let mut other = with_everything().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]), + kind: ExportKind::Directory, + plan: activation::Identity::from_bytes([0x99; activation::Identity::LEN]), }); - other.mappings = 3; + other.mappings = 7; 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()), + policy: activation::Identity::from_bytes([0xaa; activation::Identity::LEN]), + publication: Publication::Unpublished, + ..network(Some(DNS), Some(PROXY)) }); other.resources = ExpectedResources { cpus: 8, @@ -861,8 +1000,9 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { }; let other = ActivationRecord::new(None, other).unwrap(); let (mut first, mut second) = (FakeSystem::new(), FakeSystem::new()); - let first_applied = apply_with(&mut first, &accepted(), &template, None).unwrap(); + 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, @@ -1114,14 +1254,20 @@ fn the_view_s_entry_takes_the_record_s_hostname_and_machine_id() { ); } - // It binds a generated machine ID, and nothing for the image's own: only the former opens - // the root, which a temporary directory, not the root of a mount, fails. + // 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(None, MachineId::Image).entry(directory.path()); image.bind_runtime().unwrap().remove().unwrap(); - let generated = view(None, generated_id()).entry(directory.path()); - let error = generated.bind_runtime().unwrap_err(); - assert!(matches!(error, entry::Error::Root(_)), "{error}"); + let resolver = View { + resolver: Some(resolver_path()), + ..view(None, MachineId::Image) + }; + for bound in [view(None, generated_id()), resolver, full_view()] { + let error = bound.entry(directory.path()).bind_runtime().unwrap_err(); + assert!(matches!(error, entry::Error::Root(_)), "{bound:?}: {error}"); + } } #[test] @@ -1137,7 +1283,10 @@ fn a_machine_id_or_a_view_that_fails_fails_the_activation_once_the_root_is_torn_ FakeSystem::generating(io::ErrorKind::AlreadyExists), vec![generation.clone()], ), - (FakeSystem::unbindable(), vec![generation, bind]), + ( + FakeSystem::unbindable(|| entry::Error::MachineId(MappingError::TargetKind)), + vec![generation, bind], + ), ]; for (system, effects) in cases { let mut adapter = Adapter::restricted(system, base); @@ -1181,18 +1330,29 @@ fn a_machine_id_or_a_view_that_fails_fails_the_activation_once_the_root_is_torn_ #[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. + // 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. let mut system = FakeSystem::new(); let root = system.root(); - teardown_with(&mut system, (), root).unwrap(); + teardown_with(&mut system, None, (), root).unwrap(); assert_eq!(system.effects, [Effect::Remove, Effect::Teardown]); + let mut system = FakeSystem::new(); + let root = system.root(); + teardown_with(&mut system, Some(()), (), root).unwrap(); + assert_eq!( + system.effects, + [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, (), root).unwrap_err(); - assert_eq!(system.effects, [Effect::Remove, Effect::Teardown]); + let error = teardown_with(&mut system, 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); @@ -1206,6 +1366,252 @@ fn the_runtime_s_binds_are_removed_before_the_root_is_torn_down() { ); } +#[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 workload's identity was checked, 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::Account(workload()), + Effect::MountExport(TAG.to_owned(), Access::ReadWrite), + Effect::Bind(view(Some(workload()), MachineId::Image)), + ]; + assert_eq!(adapter.system.effects, expected, "{kind:?}"); + assert_eq!( + adapter.applied, + Some(Activated { + export: Some(()), + ..activated(&adapter.system, Some(workload()), 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(network(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(Some(workload()), MachineId::Image) + }; + let expected = [ + Effect::Assemble(accepted_assembly()), + Effect::Account(workload()), + 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, Some(workload()), 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(network(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::Account(workload()), + Effect::Bind(view(Some(workload()), MachineId::Image)), + ]; + assert_eq!(system.effects, expected, "{proxy:?}"); + assert_eq!( + applied, + Activated { + proxy: proxy.map(str::to_owned), + ..activated(&system, Some(workload()), MachineId::Image) + } + ); + } +} + +#[test] +fn a_resolver_file_names_its_dns_server_once_and_every_execution_may_read_it() { + // The file names the server in its canonical text, as NVX's init writes the initramfs's, + // with mode 0444, whatever the umask. + let directory = tempfile::tempdir().unwrap(); + let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1)); + let cases = [ + ("v4", DNS, &b"nameserver 10.0.0.1\n"[..]), + ("v6", ipv6, &b"nameserver fd00::a00:1\n"[..]), + ]; + for (name, server, text) in cases { + let path = directory.path().join(name); + sys::with_thread_umask(0o077, || write_resolver_file(&path, server)).unwrap(); + assert_eq!(fs::read(&path).unwrap(), text); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o7777, 0o444, "{mode:o}"); + } + + // A file there already fails the write, and is left alone, and so is the file that a + // symbolic link there names, or the path where a dangling one leads. + let written = directory.path().join("v4"); + let error = write_resolver_file(&written, ipv6).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + let link = directory.path().join("link"); + symlink(&written, &link).unwrap(); + let error = write_resolver_file(&link, ipv6).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&written).unwrap(), b"nameserver 10.0.0.1\n"); + let absent = directory.path().join("absent"); + let dangling = directory.path().join("dangling"); + symlink(&absent, &dangling).unwrap(); + let error = write_resolver_file(&dangling, DNS).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(!absent.exists()); +} + +#[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 + // with the network's, while the machine ID's bind fails with the view's, each once the + // application 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, closed: bool| { + let mut effects = applied_everything()[..done].to_vec(); + if closed { + effects.push(Effect::Close); + } + effects.push(Effect::Teardown); + effects + }; + let cases: [(FakeSystem, Vec, Failed); 5] = [ + (FakeSystem::unmountable(), failed(4, 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(5, true), + |error| { + matches!( + error, + ApplyError::Network { + error: NetworkError::Write(error), + teardown: None, + } if error.kind() == io::ErrorKind::AlreadyExists + ) + }, + ), + ( + FakeSystem::unbindable(|| entry::Error::Resolver(MappingError::TargetKind)), + failed(6, 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), + |error| { + matches!( + error, + ApplyError::Network { + error: NetworkError::Bind(entry::Error::Rollback { .. }), + teardown: None, + } + ) + }, + ), + ( + FakeSystem::unbindable(|| entry::Error::MachineId(MappingError::TargetKind)), + failed(6, 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()); + } +} + /// 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`. @@ -1219,8 +1625,9 @@ struct LiveRoot { } impl LiveRoot { - /// Returns a root whose `/etc/machine-id` holds [`IMAGE_MACHINE_ID`], or `None` where the - /// test cannot run: without root, or without unique mount IDs, which Linux 6.8 added. + /// 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"); @@ -1242,6 +1649,7 @@ impl LiveRoot { 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) => { @@ -1267,6 +1675,9 @@ impl Drop for LiveRoot { /// 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) { @@ -1277,10 +1688,11 @@ fn report(message: &str) { #[test] #[ignore = "needs root and Linux 6.8 or later"] -fn a_generated_machine_id_keeps_the_root_busy_until_its_bind_is_removed() { +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, and binds it read-only over the root's `/etc/machine-id` through the view's entry, - // in the calling thread's mount namespace, of which an execution's starts as a copy. + // 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; }; @@ -1293,24 +1705,46 @@ fn a_generated_machine_id_keeps_the_root_busy_until_its_bind_is_removed() { assert_ne!(generated, IMAGE_MACHINE_ID); let again = system.generate(&path).unwrap_err(); assert_eq!(again.kind(), io::ErrorKind::AlreadyExists); - let binds = view(Some(workload()), machine_id) - .entry(&live.root) - .bind_runtime() - .unwrap(); - let target = live.root.join("etc/machine-id"); - assert_eq!(fs::read(&target).unwrap(), generated); - let writing = fs::OpenOptions::new().write(true).open(&target); - assert_eq!( - writing.unwrap_err().raw_os_error(), - Some(Errno::EROFS as i32) - ); + 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(Some(workload()), 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 bind keeps the root's mount busy, so that the root's teardown, which detaches no busy - // mount, fails until the bind is removed, which shows the image's own ID again. + // 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(); - assert_eq!(fs::read(&target).unwrap(), IMAGE_MACHINE_ID); + for (target, _, image) in &targets { + assert_eq!(fs::read(target).unwrap(), *image, "{}", target.display()); + } sys::unmount(&root).unwrap(); } @@ -1427,6 +1861,60 @@ fn application_errors_name_their_cause() { "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() + ); + // 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); diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 7b72ce7ae..cb4a2b035 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -613,10 +613,12 @@ credit that it needs, never keeps the others' output from the host. `nvx-sandbox`, after a cold boot, and the record's in a clone, and no time status, and with the publication of listeners that the root configuration names, published unless it names them unpublished. After a cold boot, it sees - the image's own machine ID, so that the worker binds nothing into the root, - and in a clone, the machine ID that the record names, which the clone's - application bound for every execution if the record asks for a generated - one. It runs as + the image's own machine ID and `/etc/resolv.conf`, so that the worker binds + nothing into the root, and in a clone, the machine ID that the record names, + which the clone's application bound for every execution if the record asks + for a generated one, and, if the record's network names a DNS server, the + resolver file that names it, which the application bound over the root's + `/etc/resolv.conf`. It runs as the identity that exactly one source names: the sandbox's workload identity, with no supplementary groups or capabilities, or, in a sandbox without one, the identity that its start names, with its supplementary groups, its @@ -801,39 +803,52 @@ after the repair's acknowledgement of the restore: side effect. 3. It applies the record with `nvx::guest::clone::apply`, with `worker::capture::root_template` of the template's configuration: its - runtime directory, the `distro` and scratch block devices, and its device - timeout. The application assembles the root, an EROFS image or a GPT disk - over a tmpfs or the scratch, in the record's overlay mode, waiting for each - device until the activation's deadline at the latest, so that it ends by - then, and checks the account of the record's workload identity, if it has - one, in the assembled root, in existing-account mode. For a GPT disk, it - first asks the kernel to reread the disk's partition table, so that the + runtime directory, the `distro` and scratch block devices, its device + timeout, and the tag of the host filesystem device, + `worker::capture::FILESYSTEM_TAG`. The application assembles the root, an + EROFS image or a GPT disk over a tmpfs or the scratch, in the record's + overlay mode, waiting for each device until the activation's deadline at the + latest, so that it ends by then, and checks the account of the record's + workload identity, if it has one, in the assembled root, in existing-account + mode. For a GPT disk, it first asks the kernel to reread the disk's partition + table, so that the block devices of its partitions are the clone's disk's, not the template's placeholder's. The upper must be fresh: the assembly refuses one whose `upper` or `work` directory exists and is not empty. If the record asks for a generated machine ID, the application then generates one, once, after the repair's reseed, in `nvx::guest::clone::MACHINE_ID_FILE` of the runtime - directory, and binds it read-only over the root's `/etc/machine-id`, in the - worker's mount namespace, for every execution; with the image's own machine - ID, it binds nothing. It applies none of the record's other settings: its - export, and its network. -4. Once it took what the session received while the application ran, which - came before `Ready`, it completes the activation, which opens the fence, - and answers `Ready`, which reports the activation after the guest agent's - build, to the `Activate` and to each retry of it, byte for byte, that came - meanwhile, which joins the application. Anything else that came meanwhile, - a reset included, fails the activation, as before the application, so the - host waits for `Ready` before it sends the mapping table or the start. -5. It then serves the host's order, as after a cold boot: the mapping table - that the record announces, the start, the executions, the probes, and the - stop. The record's workload identity, if it has one, is the whole - sandbox's, as a root configuration's is: every execution runs as it, and a - start that names an identity is refused. Every execution takes the record's - hostname. Without an export, the clone refuses each part of a mapping table - with `MapFailed`. On the stop, the worker removes the application's binds, - which keep the root's mount busy, before it tears the root down, through - `nvx::guest::clone::Applied::teardown`. It also answers the - [`activation` probe](#probes), with when the events of its activation and + directory. If the record has an export, it mounts the host export through the + host filesystem device, read-write, at `hostfs/root` of the runtime + directory, outside the root, where only the guest's root user reaches it, as + the worker of a VM that boots cold mounts the one that its kernel command + line names. If the record's network names a DNS server, it writes a resolver + file that names it, of mode 0444, in `nvx::guest::clone::RESOLVER_FILE` of + the runtime directory. It then binds the generated machine ID read-only over + the root's `/etc/machine-id`, and the resolver file read-only over its + `/etc/resolv.conf`, in the worker's mount namespace, for every execution; + with the image's own machine ID and without a DNS server, it binds nothing. +4. Once it took what the session received while the application ran, which came + before `Ready`, it completes the activation, which opens the fence, and + answers `Ready`, which reports the activation after the guest agent's build, + to the `Activate` and to each retry of it, byte for byte, that came + meanwhile, which joins the application. Anything else that came meanwhile, a + reset included, fails the activation, as before the application, so the host + waits for `Ready` before it sends the mapping table or the start. +5. It then serves the host's order, as after a cold boot: the mapping table that + the record announces, which it applies from the record's export, the start, + the executions, the probes, and the stop. The record's workload identity, if + it has one, is the whole sandbox's, as a root configuration's is: every + execution runs as it, and a start that names an identity is refused. Every + execution takes the record's hostname. The host tells the workloads the + endpoint of the network's proxy, as after a cold boot: the host program's + `exec` adds it to each execution's environment, so the worker leaves each + execution's environment exactly its start's. The application does not apply + the publication of the record's listeners, so a clone's executions enter the + root with listeners published, the entry's default, which refuses the most. + On the stop, the worker closes the export's root and removes the + application's binds, which keep the root's mounts busy, before it tears the + root down, through `nvx::guest::clone::Applied::teardown`. It also answers + the [`activation` probe](#probes), with when the events of its activation and order happened after the repair's acknowledgement of the restore. Until the clone is ready, its session takes nothing but that `Activate`: @@ -884,6 +899,8 @@ ends with one of these statuses, after a diagnostic on standard error: | 53 | The record expects other resources than the clone has | | 54 | The root's assembly failed, its upper not fresh among its causes, or the kernel did not reread the partition table of the record's GPT disk before it | | 55 | The clone's workload cannot be activated: the image refuses the record's workload identity, as it holds no account of the identity, or not its group, or its account databases cannot be read, or the machine ID that the record asks for cannot be generated, or the record's hostname or that machine ID cannot be set for every execution | +| 56 | The record's host export cannot be mounted | +| 57 | The record's network settings cannot be applied: the resolver file that names its DNS server cannot be written, or set for every execution | | 58 | The worker's own order misused the activation: it completed an activation that did not run, or started the sandbox before the clone was ready, or again | | 193 | A conformance check of the time ABI failed in the clone's repair, such as the finish's, which needs a running time daemon | | 195 | The time ABI's part of the clone's repair failed, such as the validation of the restore packet, or the record of the restore | @@ -896,14 +913,12 @@ the time ABI could not repair, ends with the violation's status instead, as `worker::capture::capture_status` tells. `nvx::protocol::status` documents the statuses of a clone's repair, from 40 to 45, which `nvx::guest::clone::RepairError::status` selects, the time ABI's own, from 193 -to 195, and those of a clone's activation, from 50 to 58, of which the worker -leaves 56 and 57, the export's and the network's, to the activation of those -settings. A -stage whose failure may leave the worker some of the permission for the time -ABI's ports fails with the time ABI's port error, and the worker then starts no -thread or process before it ends. A violation of the time ABI in enforcing -mode, in the capture's preparation or in the time daemon, stops the VM with the -time ABI's own status, from 193 to 195, instead. +to 195, and those of a clone's activation, from 50 to 58. A stage whose +failure may leave the worker some of the permission for the time ABI's ports +fails with the time ABI's port error, and the worker then starts no thread or +process before it ends. A violation of the time ABI in enforcing mode, in the +capture's preparation or in the time daemon, stops the VM with the time ABI's +own status, from 193 to 195, instead. ## Host program @@ -1772,7 +1787,8 @@ process's status, the outcomes of attempts, the entry's refusals, and the probe's arguments. Those of `worker::capture` check that closing the worker's fence acquires it, the status of each stage of a capture, which no other outcome of the worker shares, a template's devices, the template's own root -configuration, and the inputs of a clone's root that it gives, and that the +configuration, and the inputs of a clone's root that it gives, the tag of the +host filesystem device among them, and that the worker attaches its session, takes the broker's reset, and acknowledges it, which the stream sent, before the capture runs, without reading anything more of the session, and keeps the session, active in the broker's first epoch, and @@ -2006,9 +2022,10 @@ a GPT disk, it stands for the kernel's reread of the disk's partition table, which a test may fail; it takes as long as a test asks, but no longer than until the activation's deadline, as the waits for the devices do, checks the account of the record's workload identity in the tests' directory of account databases, -and stands for the generation of a machine ID that the record asks for, and the -setting of the record's hostname and machine ID for every execution, which a -test may fail. The tests cover: +and stands for the generation of a machine ID that the record asks for, the +mount of the record's export, the write of the resolver file that names its +network's DNS server, and the setting of the record's hostname and machine ID +for every execution, each of which a test may fail. The tests cover: - the reset that the restore queued, of another instance, which the worker acknowledges without an attachment, and the `Activate`, whose record it @@ -2024,9 +2041,10 @@ test may fail. The tests cover: - a record that does not decode, a cold boot's, and one that expects other resources, which fail the activation with nothing applied, and a GPT disk whose partition table the kernel does not reread, a workload identity whose - account or group the image lacks, a machine ID that cannot be generated, and - a hostname or machine ID that cannot be set, which fail its application, - each with the status of its class, and no `Ready`; + account or group the image lacks, a machine ID that cannot be generated, a + hostname or machine ID that cannot be set, an export that cannot be mounted, + and a resolver file that cannot be written, which fail its application, each + with the status of its class, and no `Ready`; - an `Activate` whose header announces more than 1 KiB, which the framing refuses before it takes any byte of its body, and which fails the activation with the record's status, before the clone is ready and once it is; @@ -2065,7 +2083,12 @@ test may fail. The tests cover: - the `activation` probe, which the worker answers itself, with no root operation, executions running or not: the restore's reset, the host's first message, the clone's `Ready`, and its start, in that order, after the - repair's acknowledgement, and the start of the first execution after them. + repair's acknowledgement, and the start of the first execution after them; +- the mapping table of the record's export, which the host sends in parts once + the clone is ready, and which the worker applies in order, from the export + that the application mounted, before it starts the sandbox, and the endpoint + of the network's proxy, which the worker leaves to the host: an execution's + environment is exactly its start's. `tests/clone_start.rs` checks, in the host-only configuration, on Linux and Windows, without a hypervisor: diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index cd97f379d..0c45c1e67 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -81,34 +81,43 @@ //! 3. The worker applies the record through `RootOperations::apply`, which runs //! `nvx::guest::clone::apply`: it assembles the root from the template's devices, with the //! record's image, upper, and overlay mode, once the kernel reread the partition table of a -//! GPT disk, over a fresh upper, waiting for each device until the activation's deadline at -//! the latest, so that the application ends by then, and checks the account of the record's -//! workload identity, if it has one, in the assembled root, as the worker of a VM that boots -//! cold does for a root configuration. If the record asks for a generated machine ID, it then -//! generates one, once, after the repair's reseed, and binds it read-only over the root's -//! `/etc/machine-id`, in the worker's mount namespace, which each execution's copies. The -//! template bootstrapped the guest for its root with its own configuration before its capture, -//! so the clone has its cgroups. -//! 4. Once it took what the session received while the application ran, which came before -//! `Ready`, it completes the activation, which opens the fence, and answers `Ready`, which -//! reports that the activation applied, with the record's transaction, after the guest agent's -//! build, to the `Activate` and to each retry of it, byte for byte, that came meanwhile, which -//! joins the application. Anything else that came meanwhile, a reset included, fails the -//! activation, as before the application, so the host waits for `Ready` before it sends the -//! mapping table or the start. +//! GPT disk, over a fresh upper, waiting for each device until the activation's deadline at the +//! latest, so that the application ends by then, and checks the account of the record's workload +//! identity, if it has one, in the assembled root, as the worker of a VM that boots cold does +//! for a root configuration. If the record asks for a generated machine ID, it then generates +//! one, once, after the repair's reseed. If the record has an export, it mounts the host export +//! out of the root, through the template's host filesystem device, `capture::FILESYSTEM_TAG`, as +//! the worker of a VM that boots cold mounts the one that its command line names, and if the +//! record's network names a DNS server, it writes a resolver file that names it. It then binds +//! the generated machine ID read-only over the root's `/etc/machine-id`, and the resolver file +//! over its `/etc/resolv.conf`, in the worker's mount namespace, which each execution's copies. +//! The template bootstrapped the guest for its root with its own configuration before its +//! capture, so the clone has its cgroups. +//! 4. Once it took what the session received while the application ran, which came before `Ready`, +//! it completes the activation, which opens the fence, and answers `Ready`, which reports that +//! the activation applied, with the record's transaction, after the guest agent's build, to the +//! `Activate` and to each retry of it, byte for byte, that came meanwhile, which joins the +//! application. Anything else that came meanwhile, a reset included, fails the activation, as +//! before the application, so the host waits for `Ready` before it sends the mapping table or +//! the start. //! 5. It then serves the host's order as after a cold boot: the mapping table that the record -//! announces, the start, the executions, the probes, and the stop. The record's workload -//! identity, if it has one, is the whole sandbox's, as a root configuration's is, so that -//! executions name none, every execution takes the record's hostname, and a clone's -//! executions run in the cgroups of the template's bootstrap. On the stop, the worker removes -//! the binds of the application before it tears the root down, since they keep the root's -//! mount busy, through `nvx::guest::clone::Applied::teardown`. It also answers the probe +//! announces, which it maps from the record's export, the start, the executions, the probes, and +//! the stop. The record's workload identity, if it has one, is the whole sandbox's, as a root +//! configuration's is, so that executions name none, every execution takes the record's +//! hostname, and a clone's executions run in the cgroups of the template's bootstrap. On the +//! stop, the worker closes the export's root and removes the binds of the application before it +//! tears the root down, since they keep the root's mounts busy, through +//! `nvx::guest::clone::Applied::teardown`. It also answers the probe //! `activation::ACTIVATION_PROBE`, with when the events of the clone's activation and order //! happened, after the repair's acknowledgement of the restore. //! -//! The application applies none of the record's other settings: its export, and its network. -//! Without an export, a clone refuses each part of a mapping table with `MapFailed`, as the worker -//! of a VM that boots cold does without one. +//! The application leaves out two of the record's settings. The endpoint of its network's proxy, +//! which `nvx::guest::clone::Applied::proxy` returns, is the host's to tell the workloads, as +//! after a cold boot: the host program's `exec` gives each execution the proxy's URL in its +//! environment, from the plan of the VM's network, so that an execution's environment stays +//! exactly its start's. And the publication of the record's listeners is not applied, so a +//! clone's executions enter the root with listeners published, the entry's default, which refuses +//! the most. //! //! Until the clone is ready, its session takes nothing but that `Activate`: another reset, any //! other message, a `Stop` and a `Ping` included, an `Activate` that the activation refuses, one @@ -200,9 +209,11 @@ //! sandbox's hostname, the entry's default, `nvx-sandbox`, after a cold boot, and the record's in //! a clone, and no time status, and with the publication of listeners that the root //! configuration names, published unless it names them unpublished, as the entry's default is. -//! After a cold boot, it sees the image's own machine ID, so that the worker binds nothing into -//! the root, and in a clone, the machine ID that the record names, which the clone's -//! application bound for every execution if the record asks for a generated one. It runs as +//! After a cold boot, it sees the image's own machine ID and `/etc/resolv.conf`, so that the +//! worker binds nothing into the root, and in a clone, the machine ID that the record names, +//! which the clone's application bound for every execution if the record asks for a generated +//! one, and, if the record's network names a DNS server, the resolver file that names it, which +//! the application bound over the root's `/etc/resolv.conf`. It runs as //! the identity that exactly one source names: the sandbox's workload identity, which the root //! configuration names, with no supplementary groups or capabilities, or, in a sandbox without //! one, the identity that its start names, with its supplementary groups, its capabilities, and @@ -363,10 +374,11 @@ const COMMAND_LINE: &str = "/proc/cmdline"; /// How one execution enters the assembled root: the inputs of its `nvx::guest::entry::Entry` that /// the worker chooses. The entry keeps its defaults for the rest: new mount, PID, and UTS -/// namespaces, with the root moved over `/`, the image's own machine ID and no time status, which -/// leave `Entry::bind_runtime` nothing to bind, and every device node. A clone's application binds -/// the machine ID that its record asks the guest to generate once, for every execution, before -/// any execution enters the root. +/// namespaces, with the root moved over `/`, the image's own machine ID and `/etc/resolv.conf`, +/// and no time status, which leave `Entry::bind_runtime` nothing to bind, and every device node. +/// A clone's application binds the machine ID that its record asks the guest to generate once, +/// and the resolver file that names the DNS server of its record's network, if any, for every +/// execution, before any execution enters the root. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Entrance { /// The identity: the sandbox's workload identity, or the one that the execution's start names. @@ -446,8 +458,10 @@ pub trait RootOperations: EntryOperations { /// 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: in - /// place of a root configuration's [layout](Self::command_line), assembly, and account check. + /// runtime's binds that it made on it, which the [teardown](Self::teardown) removes first, and + /// 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: in place of a root configuration's + /// [layout](Self::command_line), assembly, [export](Self::mount_export), and account check. /// The template's VM [bootstrapped](Self::bootstrap) the guest before its capture. /// /// Returns the identity in which every execution runs, that of the record's workload identity, @@ -1078,8 +1092,9 @@ pub struct LibraryRoot { /// They close before the root drops. held: Vec, /// The root of the host export, from which the worker maps the host's mapping table, once the - /// root mounted the export. It keeps the export busy, so it closes before the root drops, and - /// before its teardown. + /// root mounted the export, as the worker of a VM that boots cold mounts it, or as a clone's + /// application does. It keeps the export busy, so it closes before the root drops, and before + /// its teardown. export: Option, /// The root, from its assembly until its teardown. root: Option, @@ -1087,6 +1102,9 @@ pub struct LibraryRoot { /// A root that the worker assembled: a cold boot's, from its root configuration, or a clone's, /// which the application of its activation record assembled. +// The worker holds one root for its life, so the larger variant's size costs nothing, and boxing +// either would add an allocation for nothing. +#[allow(clippy::large_enum_variant)] enum Assembled { /// The root of a VM that boots cold. Cold(Root), @@ -1317,7 +1335,9 @@ impl RootOperations for LibraryRoot { } } - /// Runs `nvx::guest::clone::apply`, and keeps the root and the runtime's binds that it made. + /// Runs `nvx::guest::clone::apply`, and keeps the root and the runtime's binds that it made, + /// 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. #[cfg(target_arch = "x86_64")] fn apply( &mut self, @@ -1325,8 +1345,9 @@ impl RootOperations for LibraryRoot { template: &RootTemplate, deadline: Option, ) -> Result, ApplyError> { - let applied = clone::apply(record, template, deadline)?; + let mut applied = clone::apply(record, template, deadline)?; let identity = applied.identity().cloned(); + self.export = applied.take_export(); self.root = Some(Assembled::Clone(applied)); Ok(identity) } diff --git a/tests/runtime-adapter/src/worker/activation.rs b/tests/runtime-adapter/src/worker/activation.rs index 9d9a0e87d..5aac7dfbc 100644 --- a/tests/runtime-adapter/src/worker/activation.rs +++ b/tests/runtime-adapter/src/worker/activation.rs @@ -81,14 +81,15 @@ pub const ACTIVATION_PROBE: &str = "activation"; /// 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; +/// - [`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, as +/// [`ApplyError::Network`] tells; /// - [`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. /// -/// The statuses of the export's and the network's classes, -/// [`ACTIVATION_EXPORT`](status::ACTIVATION_EXPORT) and -/// [`ACTIVATION_NETWORK`](status::ACTIVATION_NETWORK), are left to the activation of those -/// settings. A cause that this build of the adapter does not know ends it with -/// `ACTIVATION_PROTOCOL`. +/// A cause that this build of the adapter does not know ends it with `ACTIVATION_PROTOCOL`. pub fn failure_status(failure: &ActivationFailure) -> u8 { match failure.cause() { ActivationCause::Expired => status::ACTIVATION_DEADLINE, @@ -103,6 +104,8 @@ pub fn failure_status(failure: &ActivationFailure) -> u8 { Some( ApplyError::Account { .. } | ApplyError::MachineId { .. } | ApplyError::View { .. }, ) => status::ACTIVATION_WORKLOAD, + Some(ApplyError::Export { .. }) => status::ACTIVATION_EXPORT, + Some(ApplyError::Network { .. }) => status::ACTIVATION_NETWORK, // A failure of the application that this build does not know. _ => status::ACTIVATION_ROOT, }, @@ -437,11 +440,13 @@ mod tests { RestoreSection, StoreGeneration, TemplateReference, TransactionId, Upper, WorkloadIdentity, WorkloadView, }; - use nvx::guest::clone::AdmissionFence; + use nvx::guest::clone::{AdmissionFence, NetworkError}; use nvx::guest::entry; + use nvx::guest::mappings::MappingError; use nvx::guest::root::{self, Assembly, Device, Error as RootError, Role}; use nvx::protocol::root::DISTRO_MMIO_BASE; + use super::super::capture::FILESYSTEM_TAG; use super::*; /// The resources of the clones of the tests. @@ -563,7 +568,8 @@ mod tests { more.input(now(), ActivationInput::Reset).unwrap(); let failure = failed(more.input(now(), ActivationInput::Activate(&bytes))); cases.push((failure, status::ACTIVATION_RESOURCES)); - // The application: of the root, of the workload, and any other. + // The application: of the root, of the workload, of the export, of the network, and any + // other. let reread = ApplyError::Gpt(RootError::Missing { role: Role::Distro, mmio: DISTRO_MMIO_BASE, @@ -603,6 +609,29 @@ mod tests { }; let workload = activating().complete(now(), Err(view.into())); cases.push((failed(workload), status::ACTIVATION_WORKLOAD)); + let unmounted = ApplyError::Export { + error: RootError::Mount { + mount: root::Mount::Export { + tag: FILESYSTEM_TAG.to_owned(), + }, + error: io::ErrorKind::NotFound.into(), + }, + teardown: None, + }; + let export = activating().complete(now(), Err(unmounted.into())); + cases.push((failed(export), status::ACTIVATION_EXPORT)); + let unwritten = ApplyError::Network { + error: NetworkError::Write(io::ErrorKind::AlreadyExists.into()), + teardown: None, + }; + 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)), + teardown: None, + }; + let network = activating().complete(now(), Err(unbound.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. @@ -625,15 +654,14 @@ mod tests { } ); } - // Every class that the adapter's clones reach has its status; the export's and the - // network's are left to the activation of those settings. + // Every class that the adapter's clones reach has its status. let mut statuses = cases .iter() .map(|(failure, _)| failure_status(failure)) .collect::>(); statuses.sort_unstable(); statuses.dedup(); - assert_eq!(statuses, [50, 51, 52, 53, 54, 55, 58]); + assert_eq!(statuses, [50, 51, 52, 53, 54, 55, 56, 57, 58]); assert!( statuses .iter() diff --git a/tests/runtime-adapter/src/worker/capture.rs b/tests/runtime-adapter/src/worker/capture.rs index 025030fd8..195296c1b 100644 --- a/tests/runtime-adapter/src/worker/capture.rs +++ b/tests/runtime-adapter/src/worker/capture.rs @@ -74,7 +74,7 @@ use crate::template::{CAPTURE_STATUS_BASE, CONTINUED_STATUS}; /// The virtio-fs tag of the VM's host filesystem device, OpenVMM's one filesystem slot: dormant in /// a template's VM, and the device of the host export that the worker of a VM that boots cold -/// mounts. +/// mounts, and that the application of a clone's record mounts if the record has an export. pub const FILESYSTEM_TAG: &str = "microvm"; /// Returns the root configuration that a template's VM holds as its own, since the host sends it @@ -89,8 +89,9 @@ pub fn template_config() -> RootConfig { /// Returns the inputs of a clone's root's assembly that its template captured, from `config`, the /// template's own root configuration: its runtime directory; the distro and the scratch block /// devices, at the MMIO windows from which the worker of a VM that boots cold assembles its root, -/// with the sizes that the configuration names, if any; and how long the assembly waits for each. -/// The record of the clone's `Activate` holds the rest. +/// with the sizes that the configuration names, if any; how long the assembly waits for each; +/// and the host filesystem device, by [`FILESYSTEM_TAG`], through which the host attaches the +/// export of a clone's record. The record of the clone's `Activate` holds the rest. pub fn root_template(config: &RootConfig) -> RootTemplate { RootTemplate { runtime: PathBuf::from(&config.runtime), @@ -103,6 +104,7 @@ pub fn root_template(config: &RootConfig) -> RootTemplate { sectors: config.scratch_sectors, }, timeout: config.device_timeout, + filesystem_tag: FILESYSTEM_TAG.to_owned(), } } @@ -511,10 +513,13 @@ mod tests { let template = root_template(&config); assert_eq!(template.runtime, Path::new(&config.runtime)); assert_eq!(template.timeout, config.device_timeout); - // The clone's root comes from the devices whose mounts the template's capture checks. + // The clone's root comes from the devices whose mounts the template's capture checks, and + // its export, if its record has one, through the host filesystem device that the capture + // found unmounted. let devices = devices(); assert_eq!(template.distro, devices.distro); assert_eq!(template.scratch, devices.scratch); + assert_eq!(template.filesystem_tag, devices.filesystem_tag); // A configuration that sizes the devices sizes the clone's. let sized = RootConfig { distro_sectors: Some(8), diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index e26d44a09..e7e996936 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -29,7 +29,9 @@ //! activation, retries of the `Activate`, the order that the worker serves once the clone is //! ready, the status of each class of the activation's failures, which the worker answers with //! nothing, before any side effect where the class allows, its deadline, also once the stream -//! takes nothing more, and the record's workload identity, as which every execution runs. +//! takes nothing more, the record's workload identity, as which every execution runs, and the +//! mapping table of the record's export, which the worker applies from the export that the +//! application mounted, while it leaves the endpoint of the network's proxy to the host. //! //! The worker's executions run behind the test broker too, whose host side drives the engine's //! peers through the host program's own `exec::HostExecution`: the tests cover several executions @@ -1131,7 +1133,11 @@ enum Operation { /// A clone's setting of its record's hostname and machine ID for every execution, last. #[cfg(target_arch = "x86_64")] View, - /// The host export's mount. + /// A clone's write of the resolver file that names its record's DNS server, once it mounted + /// its record's export, if any. + #[cfg(target_arch = "x86_64")] + Resolver, + /// The host export's mount: the one that the kernel command line names, or a clone's record's. MountExport, /// A probe. Probe, @@ -1347,12 +1353,14 @@ impl RootOperations for FakeRoot { /// the gate, if any: for a GPT disk, it stands for the kernel's reread of the disk's partition /// 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, and checks the account of the record's - /// workload identity, if it has one, in existing-account mode, in the - /// [accounts' root](accounts), which stands for the root that it assembled. It then stands for - /// the generation of the machine ID that the record asks for, if any, and the setting of the - /// record's hostname and machine ID for every execution, which fail if the test fails - /// [`Operation::MachineId`] and [`Operation::View`]. + /// `deadline`, as the waits for the devices do, and checks the account of the record's workload + /// identity, if it has one, in existing-account mode, in the [accounts' root](accounts), which + /// stands for the root that it assembled. 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, and the setting of the record's hostname + /// and machine ID for every execution, which fail if the test fails [`Operation::MachineId`], + /// [`Operation::MountExport`], [`Operation::Resolver`], and [`Operation::View`]. #[cfg(target_arch = "x86_64")] fn apply( &mut self, @@ -1361,8 +1369,8 @@ impl RootOperations for FakeRoot { deadline: Option, ) -> Result, nvx::guest::clone::ApplyError> { use nvx::activation::MachineIdMode; - use nvx::guest::clone::ApplyError; - use nvx::guest::root::{DeviceStep, Error, Role}; + use nvx::guest::clone::{ApplyError, NetworkError}; + use nvx::guest::root::{DeviceStep, Error, Mount, Role}; let applied = (record.clone(), template.clone()); self.calls.push(Call::Apply(Box::new(applied))); @@ -1408,6 +1416,26 @@ impl RootOperations for FakeRoot { teardown: None, })?; } + if configuration.export.is_some() { + self.result(Operation::MountExport) + .map_err(|problem| ApplyError::Export { + error: Error::Mount { + mount: Mount::Export { + tag: template.filesystem_tag.clone(), + }, + error: io::Error::other(problem), + }, + teardown: None, + })?; + } + let network = configuration.network.as_ref(); + if network.is_some_and(|network| network.dns.is_some()) { + self.result(Operation::Resolver) + .map_err(|problem| ApplyError::Network { + error: NetworkError::Write(io::Error::other(problem)), + teardown: None, + })?; + } self.result(Operation::View) .map_err(|problem| ApplyError::View { error: entry::Error::Root(io::Error::other(problem)), @@ -4009,6 +4037,7 @@ fn the_worker_runs_executions_on_a_tty() { /// clone is ready, applies its record, and then serves the host's order, as after a cold boot. #[cfg(target_arch = "x86_64")] mod clones { + use std::net::{IpAddr, Ipv4Addr}; use std::num::NonZeroU64; use nvx::activation::{ @@ -4090,6 +4119,31 @@ mod clones { ActivationRecord::new(record.restore().cloned(), configuration).unwrap() } + /// Returns the record of `record`'s transaction with an aggregate export, whose mapping table + /// holds `mappings` mappings. + fn exported(record: &ActivationRecord, mappings: usize) -> ActivationRecord { + changed(record, |configuration| { + configuration.export = Some(activation::Export { + kind: activation::ExportKind::Aggregate, + plan: activation::Identity::from_bytes([0x33; 32]), + }); + configuration.mappings = mappings; + }) + } + + /// Returns the record of `record`'s transaction with a network whose DNS server is + /// `10.0.0.3`, which publishes no listener, and whose proxy's endpoint is `proxy`, if any. + fn networked(record: &ActivationRecord, proxy: Option<&str>) -> ActivationRecord { + changed(record, |configuration| { + configuration.network = Some(activation::Network { + policy: activation::Identity::from_bytes([0x55; 32]), + dns: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 3))), + publication: activation::Publication::Unpublished, + proxy: proxy.map(str::to_owned), + }); + }) + } + /// 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. @@ -4241,12 +4295,23 @@ mod clones { /// worker activated the clone with `record`, which announces no mapping, and started the /// sandbox. The host waits for Ready before it starts the sandbox. fn activated(root: FakeRoot, record: &ActivationRecord) -> Self { + Self::mapped(root, record, &[]) + } + + /// Starts a clone's worker with the root operations `root`, and returns the host, once the + /// worker activated the clone with `record`, applied `parts`, the parts of the mapping + /// table that the record announces, in order, and started the sandbox. + fn mapped(root: FakeRoot, record: &ActivationRecord, parts: &[Vec]) -> Self { let (mut broker, working) = start_clone(root, ACTIVATION_TIMEOUT); broker.reset(RESTORED); let replies = broker.exchange(&[activate(record)], OUTER_MAX_PAYLOAD, 1); assert_eq!(replies, [activated(record)]); - let replies = broker.exchange(&[Message::Start], OUTER_MAX_PAYLOAD, 1); - assert_eq!(replies, [Message::Started]); + let mut messages = parts.iter().cloned().map(Message::Maps).collect::>(); + messages.push(Message::Start); + let mut expected = parts.iter().map(|_| Message::Mapped).collect::>(); + expected.push(Message::Started); + let replies = broker.exchange(&messages, OUTER_MAX_PAYLOAD, expected.len()); + assert_eq!(replies, expected); Self { broker, working: Some(working), @@ -4385,7 +4450,8 @@ mod clones { // The application fails once the kernel refused to reread a GPT disk's partition table, // and refuses a workload identity whose account the image lacks, or whose primary group // it lacks, a machine ID that it cannot generate, and a hostname or machine ID that it - // cannot set for every execution, before Ready. + // cannot set for every execution, an export that it cannot mount, and a resolver file + // that it cannot write, before Ready. let gpt = changed(&clone, |configuration| { configuration.image = activation::Image::Gpt { binding: activation::Identity::from_bytes([0x11; 32]), @@ -4422,6 +4488,16 @@ mod clones { FakeRoot::failing(Operation::View, "the image's machine ID is a directory"), status::ACTIVATION_WORKLOAD, ), + ( + exported(&clone, 1), + FakeRoot::failing(Operation::MountExport, "the export's device is missing"), + status::ACTIVATION_EXPORT, + ), + ( + networked(&clone, None), + FakeRoot::failing(Operation::Resolver, "a resolver file exists already"), + status::ACTIVATION_NETWORK, + ), ]; for (record, root, expected) in cases { let (mut broker, working) = start_clone(root, ACTIVATION_TIMEOUT); @@ -4826,4 +4902,36 @@ mod clones { root.calls ); } + + #[test] + fn a_clone_maps_its_record_s_table_from_its_export_and_leaves_the_proxy_to_the_host() { + // The application mounted the record's export, and wrote the resolver file of its + // network's DNS server, before Ready. The host then sends the mapping table that the + // record announces, in parts, as after a cold boot: the worker applies each part's + // mappings from the export, and starts the sandbox once it applied every one. + let table = table(3); + let parts = parts(&table, 2); + assert_eq!(parts.len(), 2); + let record = exported(&record(1, Some(WORKLOAD)), table.len()); + let record = networked(&record, Some("http://10.0.0.1:3128")); + let mut host = Host::mapped(FakeRoot::booting(&layout()), &record, &parts); + // The host tells the workloads the endpoint of the network's proxy, as after a cold boot: + // an execution's environment is exactly its start's, which names no proxy here. + let printing = start_of(1, vec![b"/usr/bin/env".to_vec()]).with_environment(["NAME=value"]); + host.start(printing, Vec::new()); + host.run_until(|host| host.outcome(1).is_some()); + check_lossless(&host, 1, Status::Exited(0)); + assert_eq!(host.executions[&1].output(Stream::Stdout), b"NAME=value\n"); + let (root, worked) = host.stop(); + assert_eq!(worked.unwrap(), Worked::Stopped); + let identity = entry::Identity::new(WORKLOAD.0, WORKLOAD.1); + let expected = [ + applied(&record), + Call::Map(mappings(&table[..2])), + Call::Map(mappings(&table[2..])), + Call::Prepare(cloned(identity, b"clone")), + Call::Teardown, + ]; + assert_eq!(root.calls, expected); + } }