diff --git a/nvx-rs/README.md b/nvx-rs/README.md index b384530b9..f91486568 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -1216,10 +1216,18 @@ the record's workload identity, it checks its account in the assembled root before Ready, as NVX's init checks it at boot: `entry::Identity::new(uid, gid)`, without supplementary groups or capabilities, in existing-account mode, as which every execution runs, while without one each execution names its own identity, -which `Entry::check` checks. It returns the `Applied` root and identity, or an -`ApplyError`, after which nothing that it mounted stays mounted unless the error -reports the unmounts that failed, and applies none of the record's export, -network, hostname, or machine ID. +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` +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. 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 51f5f0b36..3f6116f8d 100644 --- a/nvx-rs/src/guest/clone.rs +++ b/nvx-rs/src/guest/clone.rs @@ -161,10 +161,19 @@ //! [`Identity::new`](super::entry::Identity::new) of the record's user and group IDs, without //! supplementary groups or capabilities, in existing-account mode, and every execution runs as //! it. Without one, the adapter gives each execution its own identity, which -//! [`Entry::check`](super::entry::Entry::check) checks. It returns the [`Applied`] root and -//! identity, which the adapter keeps, or an [`ApplyError`], after which nothing that it mounted -//! stays mounted, unless the error holds the unmounts that failed. It applies none of the -//! record's other settings: its export, its network, its hostname, and its machine ID. +//! [`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. //! //! # Example //! @@ -250,7 +259,9 @@ //! application, [`apply`](fn@apply), needs the guest's root user, in the guest's initial mount //! namespace, as [`root::assemble`] does: for a GPT disk, it asks the kernel to reread the disk's //! partition table, which needs `CAP_SYS_ADMIN`; it mounts the root, reads the account databases -//! below it, and, if it fails, tears it down. It starts no process and generates no ID. +//! 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. mod activation; mod mounts; @@ -274,7 +285,8 @@ use super::time::{ pub use self::activation::{ Activation, ActivationAction, ActivationCause, ActivationFailure, ActivationInput, - ActivationState, Applied, ApplyError, RootTemplate, apply, + ActivationState, Applied, AppliedTeardownError, ApplyError, MACHINE_ID_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 dbab2363d..9b88f5804 100644 --- a/nvx-rs/src/guest/clone/activation.rs +++ b/nvx-rs/src/guest/clone/activation.rs @@ -17,7 +17,9 @@ use crate::activation::{ ActivationError, ActivationRecord, ExpectedResources, ReadyActivation, TransactionId, }; -pub use self::apply::{Applied, ApplyError, RootTemplate, apply}; +pub use self::apply::{ + Applied, AppliedTeardownError, ApplyError, MACHINE_ID_FILE, RootTemplate, apply, +}; /// The activation of a repaired clone: the state machine, without I/O and independent of any /// framing, of the clone's control session, which takes the clone's configuration from the host @@ -85,8 +87,9 @@ pub use self::apply::{Applied, ApplyError, RootTemplate, apply}; /// /// Queues the acknowledgement of the session's reset, and abandons the earlier epoch's /// /// work. /// fn acknowledge(&mut self); -/// /// Keeps the clone's activated root, and the identity, if any, in which it runs every -/// /// execution. +/// /// 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. /// 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 c9d06bcfb..349e35714 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -6,18 +6,25 @@ mod tests; use std::fmt; +use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; #[cfg(doc)] use super::Activation; -use crate::activation::{self, ActivationRecord, Configuration, WorkloadIdentity}; +use crate::activation::{self, ActivationRecord, Configuration, MachineIdMode, WorkloadIdentity}; use crate::gpt::Expected; #[cfg(doc)] use crate::guest::entry::AccountMode; -use crate::guest::entry::{self, Entry, Identity}; +use crate::guest::entry::{self, Entry, Identity, MachineId, RuntimeBinds}; +use crate::guest::mappings; use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; +/// 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 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 @@ -25,7 +32,9 @@ use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownEr /// activation record. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct RootTemplate { - /// The runtime directory, below which the root is mounted, as [`Assembly::runtime`] tells. + /// 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`]. pub runtime: PathBuf, /// The `distro` block device, the template's placeholder, which holds each clone's image. pub distro: Device, @@ -38,18 +47,30 @@ pub struct RootTemplate { } /// A clone's root and workload, which [`apply`](fn@apply) activated: the [`Root`] that it -/// assembled, which owns the root's mounts, and the identity in which every execution runs, if -/// the record has a workload identity. +/// 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. /// -/// Without one, the adapter gives each execution its own identity, which [`Entry::check`] checks -/// when the adapter prepares the execution's entry, as at a cold boot. Dropping it tears the root -/// down, as dropping a [`Root`] does. +/// 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. #[derive(Debug)] pub struct Applied { + /// The runtime's binds on the root, which a drop removes before the root drops: the binds + /// keep the root's mount busy. + binds: Binds, /// The assembled root. root: Root, /// The identity in which every execution runs, if the record has a workload identity. identity: Option, + /// The hostname of every execution: the record's. + hostname: Vec, + /// The machine ID that every execution sees at `/etc/machine-id`. + machine_id: MachineId, } impl Applied { @@ -71,12 +92,108 @@ impl Applied { self.identity.as_ref() } - /// Returns the assembled root, and the identity in which every execution runs, if any. - pub fn into_parts(self) -> (Root, Option) { - (self.root, self.identity) + /// Returns the hostname of every execution, the record's, which the adapter gives each + /// execution's entry through [`Entry::hostname`], and which each execution then sets in a UTS + /// namespace of its own. + pub fn hostname(&self) -> &[u8] { + &self.hostname + } + + /// Returns the machine ID that every execution sees at `/etc/machine-id`: the image's own, + /// [`MachineId::Image`], or [`MachineId::Generated`], with the file of [`MACHINE_ID_FILE`] + /// that the application generated once, and bound read-only over the root's + /// `/etc/machine-id`, for every execution. + pub fn machine_id(&self) -> &MachineId { + &self.machine_id + } + + /// 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. + /// + /// 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. + pub fn teardown(self) -> Result<(), AppliedTeardownError> { + let Self { + mut binds, root, .. + } = self; + teardown_with(&mut LiveSystem, 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. + pub fn into_parts(self) -> (Root, RuntimeBinds, Option) { + let Self { + mut binds, + root, + identity, + .. + } = self; + (root, binds.take(), identity) + } +} + +/// The runtime's binds on a clone's root, which a drop removes, ignoring failures, before the root +/// that they keep busy drops. +#[derive(Debug)] +struct Binds(Option); + +impl Binds { + /// Takes the binds, which the drop then leaves alone. + fn take(&mut self) -> RuntimeBinds { + self.0 + .take() + .expect("an application's binds are taken at most once") + } +} + +impl Drop for Binds { + fn drop(&mut self) { + if let Some(binds) = self.0.take() { + let _ = binds.remove(); + } } } +/// Why [`Applied::teardown`] failed: the runtime's binds that it failed to remove, which stay +/// mounted and keep the root's mount busy, and the unmounts of the root's teardown that failed, +/// whose mounts stay. At least one of them failed. +#[derive(Debug)] +pub struct AppliedTeardownError { + /// The binds that stay mounted, if any. + binds: Option, + /// The unmounts of the root's teardown that failed, if any. + root: Option, +} + +impl AppliedTeardownError { + /// Returns the runtime's binds that the teardown failed to remove, if any, which stay mounted. + pub fn binds(&self) -> Option<&mappings::TeardownError> { + self.binds.as_ref() + } + + /// Returns the unmounts of the root's teardown that failed, if any, whose mounts stay. + pub fn root(&self) -> Option<&TeardownError> { + self.root.as_ref() + } +} + +impl fmt::Display for AppliedTeardownError { + /// Writes the binds' failures, then the root's, separated by `; `. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.binds, &self.root) { + (Some(binds), Some(root)) => write!(formatter, "{binds}; {root}"), + (Some(binds), None) => binds.fmt(formatter), + (None, Some(root)) => root.fmt(formatter), + (None, None) => Ok(()), + } + } +} + +impl std::error::Error for AppliedTeardownError {} + /// Applies `record`, the record of the Activate that a clone's [`Activation`] accepted, to the /// clone, as at a cold boot, by `deadline`, the activation's [deadline](Activation::deadline), if /// any, and returns the root and the workload that it activated, which the adapter keeps once it @@ -88,7 +205,8 @@ impl Applied { /// with the UUID that the record expects of it, if any, or a GPT disk, with the disk GUID that /// the record expects of it, if any, and the bound of its first partition; its upper, a tmpfs, /// or the scratch, with the record's journal policy; and its overlay mode; and, from -/// `template`, the runtime directory, the devices, and the timeout; +/// `template`, the runtime directory, the devices, and the timeout. It takes the workload's +/// hostname and machine-ID mode 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 @@ -112,22 +230,46 @@ impl Applied { /// user and group IDs, without supplementary groups or capabilities, in /// [existing-account](AccountMode::Existing) mode, whose account [`Entry::prepare`] reads and /// checks below the root. A refusal fails with [`ApplyError::Account`], once the application -/// tore the root down. +/// tore the root down; +/// 5. if the record asks for a generated machine ID, generates it, once, with +/// [`MachineId::generate`], from the kernel's random number generator, which the clone's +/// repair reseeded before the activation accepted the record, so that no two clones of a +/// template share it, to [`MACHINE_ID_FILE`] in the template's runtime directory. A file there +/// already, as once a boot generated its ID, or one that cannot be created or written, fails +/// with [`ApplyError::MachineId`], once the application tore the root down; +/// 6. binds the runtime's files into the root, for every execution, with +/// [`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 +/// [`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. /// -/// It applies none of the record's other settings: its export, its network, its hostname, and -/// its machine ID. It starts no process and generates no ID, and it never powers the VM off: a -/// failure, which the adapter reports to [`Activation::complete`], fails the activation finally, -/// and the adapter ends the VM. +/// It 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 needs the guest's root user, in the guest's initial mount namespace, as -/// [`root::assemble`] does. +/// [`root::assemble`] does, and the bind of a generated machine ID needs Linux 6.8 or later, as +/// [`Entry::bind_runtime`] does. pub fn apply( record: &ActivationRecord, template: &RootTemplate, deadline: Option, ) -> Result { - let (root, identity) = apply_with(&mut LiveSystem, record, template, deadline)?; - Ok(Applied { root, identity }) + let activated = apply_with(&mut LiveSystem, record, template, deadline)?; + Ok(Applied { + binds: Binds(Some(activated.binds)), + root: activated.root, + identity: activated.identity, + hostname: activated.hostname, + machine_id: activated.machine_id, + }) } /// Why [`apply`](fn@apply) failed, which the adapter reports to [`Activation::complete`]. @@ -157,23 +299,62 @@ pub enum ApplyError { /// The unmounts of the root's teardown that failed, if any, whose mounts stay. teardown: Option, }, + /// The machine ID that the record asks the guest to generate could not be written to + /// [`MACHINE_ID_FILE`] in the template's runtime directory, as this error tells: a file is + /// there already, as once a boot generated its ID, or the file cannot be created or written. + /// The application then tore the root down. + MachineId { + /// Why the machine ID could not be written. + error: io::Error, + /// The unmounts of the root's teardown that failed, if any, whose mounts stay. + teardown: Option, + }, + /// The workload's view could not be set in the assembled root, as this error tells: the + /// entry refused the record's hostname, or could not bind the generated machine ID's file + /// over the root's `/etc/machine-id`, after which it removed the binds that it made, unless + /// the error names those that stay. The application then tore the root down. + View { + /// Why the view could not be set, as [`Entry::bind_runtime`] tells. + error: entry::Error, + /// The unmounts of the root's teardown that failed, if any, whose mounts stay. + teardown: Option, + }, } impl fmt::Display for ApplyError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Gpt(error) => write!( - formatter, - "the reread of the GPT disk's partition table failed: {error}" - ), - Self::Assembly(error) => write!(formatter, "the root's assembly failed: {error}"), + let teardown = match self { + Self::Gpt(error) => { + return write!( + formatter, + "the reread of the GPT disk's partition table failed: {error}" + ); + } + Self::Assembly(error) => { + return write!(formatter, "the root's assembly failed: {error}"); + } Self::Account { error, teardown } => { write!(formatter, "the root refuses the workload identity: {error}")?; - match teardown { - Some(teardown) => write!(formatter, "; then {teardown}"), - None => Ok(()), - } + teardown + } + Self::MachineId { error, teardown } => { + write!( + formatter, + "the workload's machine ID cannot be generated: {error}" + )?; + teardown } + Self::View { error, teardown } => { + write!( + formatter, + "the workload's hostname or machine ID cannot be set: {error}" + )?; + teardown + } + }; + match teardown { + Some(teardown) => write!(formatter, "; then {teardown}"), + None => Ok(()), } } } @@ -183,7 +364,8 @@ impl std::error::Error for ApplyError { match self { Self::Gpt(error) => Some(error), Self::Assembly(error) => Some(error), - Self::Account { error, .. } => Some(error), + Self::Account { error, .. } | Self::View { error, .. } => Some(error), + Self::MachineId { error, .. } => Some(error), } } } @@ -194,6 +376,9 @@ trait System { /// What an assembly mounted: a [`Root`], in a VM. type Root; + /// The runtime's binds that a bind made: [`RuntimeBinds`], in a VM. + type Binds; + /// Asks the kernel to reread the partition table of the GPT disk on the distro device /// `distro`, once it appeared, waiting for it for as long as `timeout` allows, but until /// `deadline` at the latest, if any, as [`root::reread_partitions`] does. @@ -215,6 +400,17 @@ trait System { /// Reads and checks the account of `identity` in `root`, as [`check_account`] does. fn account(&mut self, root: &Self::Root, identity: &Identity) -> Result<(), entry::Error>; + /// Generates a machine ID, and writes it to a new file at `path`, as [`MachineId::generate`] + /// does. + fn generate(&mut self, path: &Path) -> io::Result; + + /// Binds the runtime's files of `view` into `root`, for every execution, in the caller's + /// mount namespace, as [`Entry::bind_runtime`] does with the view's [entry](View::entry). + fn bind(&mut self, root: &Self::Root, view: &View) -> Result; + + /// Removes `binds`, as [`RuntimeBinds::remove`] does. + fn remove(&mut self, binds: Self::Binds) -> Result<(), mappings::TeardownError>; + /// Tears `root` down, as [`Root::teardown`] does. fn teardown(&mut self, root: Self::Root) -> Result<(), TeardownError>; } @@ -224,6 +420,7 @@ struct LiveSystem; impl System for LiveSystem { type Root = Root; + type Binds = RuntimeBinds; fn reread( &mut self, @@ -246,23 +443,78 @@ impl System for LiveSystem { check_account(root.path(), identity) } + fn generate(&mut self, path: &Path) -> io::Result { + MachineId::generate(path) + } + + fn bind(&mut self, root: &Root, view: &View) -> Result { + view.entry(root.path()).bind_runtime() + } + + fn remove(&mut self, binds: RuntimeBinds) -> Result<(), mappings::TeardownError> { + binds.remove() + } + fn teardown(&mut self, root: Root) -> Result<(), TeardownError> { root.teardown() } } +/// The workload's view of its root, which the application sets for every execution: the +/// identity of the entry through which it binds the runtime's files, the hostname, and the +/// machine ID. +#[derive(Clone, Debug, PartialEq, Eq)] +struct View { + /// The identity of the entry that binds the runtime's files: the record's workload identity, + /// or, without one, the user and group ID 0, since the entry binds the same files whatever + /// its identity. + identity: Identity, + /// The hostname of every execution: the record's. + hostname: Vec, + /// The machine ID that every execution sees. + machine_id: MachineId, +} + +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()) + .hostname(self.hostname.clone()) + .machine_id(self.machine_id.clone()) + } +} + +/// 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. +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq, Eq))] +struct Activated { + /// The assembled root. + root: R, + /// The runtime's binds on the root. + binds: B, + /// The identity in which every execution runs, if the record has a workload identity. + identity: Option, + /// The hostname of every execution. + hostname: Vec, + /// The machine ID that every execution sees. + machine_id: MachineId, +} + /// Applies `record` with `template` by `deadline`, if any, through `system`, as -/// [`apply`](fn@apply) does, and returns the root that it assembled, and the identity in which -/// every execution runs, if any. +/// [`apply`](fn@apply) does, and returns what it activated. fn apply_with( system: &mut S, record: &ActivationRecord, template: &RootTemplate, deadline: Option, -) -> Result<(S::Root, Option), ApplyError> { +) -> Result, ApplyError> { let configuration = record.configuration(); let assembly = assembly(template, configuration); - let identity = configuration.workload.identity.map(workload_identity); + let workload = &configuration.workload; + let identity = workload.identity.map(workload_identity); if let root::Image::Gpt { .. } = assembly.image { system .reread(assembly.distro, assembly.timeout, deadline) @@ -277,7 +529,52 @@ fn apply_with( let teardown = system.teardown(root).err(); return Err(ApplyError::Account { error, teardown }); } - Ok((root, identity)) + let machine_id = match workload.machine_id { + MachineIdMode::Image => MachineId::Image, + MachineIdMode::Generated => { + match system.generate(&template.runtime.join(MACHINE_ID_FILE)) { + Ok(machine_id) => machine_id, + Err(error) => { + let teardown = system.teardown(root).err(); + return Err(ApplyError::MachineId { error, teardown }); + } + } + } + }; + let view = View { + identity: identity.clone().unwrap_or_else(|| Identity::new(0, 0)), + hostname: workload.hostname.clone(), + machine_id, + }; + let binds = match system.bind(&root, &view) { + Ok(binds) => binds, + Err(error) => { + let teardown = system.teardown(root).err(); + return Err(ApplyError::View { error, teardown }); + } + }; + Ok(Activated { + root, + binds, + identity, + hostname: view.hostname, + machine_id: view.machine_id, + }) +} + +/// Removes `binds`, then tears `root` down, through `system`, as [`Applied::teardown`] does, +/// attempting both whatever failed before. +fn teardown_with( + system: &mut S, + binds: S::Binds, + root: S::Root, +) -> Result<(), AppliedTeardownError> { + let binds = system.remove(binds).err(); + let root = system.teardown(root).err(); + if binds.is_none() && root.is_none() { + return Ok(()); + } + Err(AppliedTeardownError { binds, root }) } /// Returns the assembly of the root that `configuration` sets, with `template`'s runtime diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index 333489885..0717339bb 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -1,10 +1,15 @@ //! Tests of the application of a clone's activation record, through a fake system, which records //! every call of the application, and whose assembly returns a temporary directory that stands //! for the root, the image's view through the overlay, whose account databases the application -//! reads and checks for real; and, with the clone's activation, through an adapter of the tests' -//! own, which takes what the host sends, applies the record of the Activate that the activation -//! accepted through the fake system, and spawns an execution for each request that the -//! activation serves. +//! 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. +//! +//! 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. use std::cell::Cell; use std::error::Error as _; @@ -25,7 +30,8 @@ use crate::guest::clone::{ ActivationState, AdmissionFence, }; use crate::guest::entry::{AccountError, AccountMode, Capabilities, Database}; -use crate::guest::sys::Errno; +use crate::guest::mappings::MappingError; +use crate::guest::sys::{self, Errno, Mode, MountFlags, OpenFlags}; use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; use crate::uuid::Uuid; @@ -57,6 +63,8 @@ const PASSWD: &[u8] = b"root:x:0:0:root:/root:/bin/sh\n\ workload:x:1000:1001::/home/workload:/bin/sh\n"; /// The group database of the tests' image, which holds the workload identity's group. const GROUP: &[u8] = b"root:x:0:\nworkload:x:1001:\n"; +/// The hostname of the tests' records. +const HOSTNAME: &[u8] = b"clone"; /// Returns whether a failure's cause is the one that a test expects. type Expected = fn(&ActivationCause) -> bool; @@ -76,6 +84,13 @@ enum Effect { /// The application checked this identity's account in the root, which reads the image's /// account databases. Account(Identity), + /// The application generated a machine ID, which it wrote to a new file at this path. + Generate(PathBuf), + /// The application bound the runtime's files of this view into the root, for every + /// execution. + Bind(View), + /// The runtime's binds were removed. + Remove, /// The application tore the root down, which unmounts it. Teardown, /// The adapter spawned an execution. @@ -92,6 +107,13 @@ struct FakeSystem { failing: bool, /// The error with which the kernel refuses each reread of a partition table, if it does. refusing: Option, + /// The kind of the error with which each generation of a machine ID fails, if it does. + generating: Option, + /// Whether each bind of the runtime's files fails, as when the image's `/etc/machine-id` is a + /// directory. + unbindable: bool, + /// Whether each removal of the runtime's binds fails, as when another mount covers one. + stuck: bool, /// What the clone did, in order. effects: Vec, /// The deadline of each reread and assembly, in order, by which it waits for its devices. @@ -110,6 +132,9 @@ impl FakeSystem { image, failing: false, refusing: None, + generating: None, + unbindable: false, + stuck: false, effects: Vec::new(), deadlines: Vec::new(), } @@ -139,14 +164,48 @@ impl FakeSystem { } } + /// Returns a system whose image holds the tests' account databases, and each of whose + /// generations of a machine ID fails with an error of `kind`. + fn generating(kind: io::ErrorKind) -> Self { + Self { + generating: Some(kind), + ..Self::new() + } + } + + /// Returns a system whose image holds the tests' account databases, and each of whose binds + /// of the runtime's files fails. + fn unbindable() -> Self { + Self { + unbindable: true, + ..Self::new() + } + } + + /// Returns a system whose image holds the tests' account databases, and each of whose + /// removals of the runtime's binds fails. + fn stuck() -> Self { + Self { + stuck: true, + ..Self::new() + } + } + /// Returns the path of the root that each assembly returns. fn root(&self) -> PathBuf { self.image.path().to_owned() } + + /// Returns how many machine IDs the application generated. + fn generated(&self) -> usize { + let generated = |effect: &&Effect| matches!(effect, Effect::Generate(_)); + self.effects.iter().filter(generated).count() + } } impl System for FakeSystem { type Root = PathBuf; + type Binds = (); fn reread( &mut self, @@ -191,6 +250,36 @@ impl System for FakeSystem { check_account(root, identity) } + fn generate(&mut self, path: &Path) -> io::Result { + self.effects.push(Effect::Generate(path.to_owned())); + match self.generating { + Some(kind) => Err(kind.into()), + None => Ok(MachineId::Generated(path.to_owned())), + } + } + + fn 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)); + } + Ok(()) + } + + fn remove(&mut self, (): ()) -> Result<(), mappings::TeardownError> { + self.effects.push(Effect::Remove); + if self.stuck { + return Err(mappings::TeardownError::of( + "/etc/machine-id", + MappingError::MountPoint, + )); + } + Ok(()) + } + fn teardown(&mut self, root: PathBuf) -> Result<(), TeardownError> { assert_eq!(root, self.root()); self.effects.push(Effect::Teardown); @@ -234,9 +323,9 @@ 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, and the identity in which every execution runs, - /// if any. - applied: Option<(PathBuf, Option)>, + /// 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 adapter's latest Ready reported. ready: Option, } @@ -390,7 +479,7 @@ fn configuration( mappings: 0, network: None, workload: WorkloadView { - hostname: b"clone".to_vec(), + hostname: HOSTNAME.to_vec(), machine_id: MachineIdMode::Image, identity, }, @@ -446,6 +535,51 @@ fn workload() -> Identity { Identity::new(WORKLOAD.uid, WORKLOAD.gid) } +/// Returns the record of the tests' Activate, as [`accepted`] returns it, but whose workload sees +/// a machine ID that the guest generates. +fn with_generated_id() -> ActivationRecord { + let mut configuration = accepted().configuration().clone(); + configuration.workload.machine_id = MachineIdMode::Generated; + clone_record(configuration) +} + +/// Returns where the application writes the machine ID that it generates with the tests' +/// template. +fn machine_id_path() -> PathBuf { + Path::new(RUNTIME).join(MACHINE_ID_FILE) +} + +/// Returns the machine ID that the application generates with the tests' template. +fn generated_id() -> MachineId { + MachineId::Generated(machine_id_path()) +} + +/// Returns the view that the application binds for a record of the tests' hostname, whose +/// workload runs as `identity`, if any, and sees `machine_id`. +fn view(identity: Option, machine_id: MachineId) -> View { + View { + identity: identity.unwrap_or_else(|| Identity::new(0, 0)), + hostname: HOSTNAME.to_vec(), + machine_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`. +fn activated( + system: &FakeSystem, + identity: Option, + machine_id: MachineId, +) -> Activated { + Activated { + root: system.root(), + binds: (), + identity, + hostname: HOSTNAME.to_vec(), + machine_id, + } +} + /// Returns the application's error that `failure`, an activation's, holds. fn apply_error(failure: &ActivationFailure) -> &ApplyError { match failure.cause() { @@ -457,11 +591,12 @@ fn apply_error(failure: &ActivationFailure) -> &ApplyError { #[test] fn no_mount_spawn_or_id_generation_happens_before_the_gate() { // The application reaches the system only through its calls, which the fake records in - // full: none starts a process or generates an ID, and the adapter spawns each execution - // itself, once the activation serves its request. + // 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. let base = Instant::now(); - let record = accepted().encode(); - let settings = accepted().configuration().clone(); + let record = with_generated_id().encode(); + let settings = with_generated_id().configuration().clone(); let cold = ActivationRecord::new(None, settings.clone()) .unwrap() .encode(); @@ -547,8 +682,8 @@ fn no_mount_spawn_or_id_generation_happens_before_the_gate() { .unwrap_err(); adapter.assert_untouched(&failure, |cause| matches!(cause, ActivationCause::Expired)); - // Only a valid Activate mounts the root, and reads its account databases, before Ready, and - // an execution spawns only once Ready opened the fence. + // 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. let mut adapter = Adapter::restricted(FakeSystem::new(), base); adapter.take(base, ActivationInput::Flow).unwrap(); assert!(adapter.system.effects.is_empty()); @@ -560,6 +695,8 @@ fn no_mount_spawn_or_id_generation_happens_before_the_gate() { [ Effect::Assemble(accepted_assembly()), Effect::Account(workload()), + Effect::Generate(machine_id_path()), + Effect::Bind(view(Some(workload()), generated_id())), ] ); assert!(adapter.fence.is_open()); @@ -587,7 +724,11 @@ fn a_valid_activate_activates_the_root_and_the_workload_once_before_ready() { ); assert_eq!( adapter.applied, - Some((adapter.system.root(), Some(workload()))) + Some(activated( + &adapter.system, + Some(workload()), + MachineId::Image + )) ); assert!(adapter.fence.is_open()); // Every execution runs as the workload identity, which has no supplementary groups or @@ -613,6 +754,7 @@ fn a_valid_activate_activates_the_root_and_the_workload_once_before_ready() { [ Effect::Assemble(accepted_assembly()), Effect::Account(workload()), + Effect::Bind(view(Some(workload()), MachineId::Image)), Effect::Spawn, Effect::Spawn, ] @@ -671,9 +813,7 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { for identity in [None, Some(WORKLOAD)] { let record = clone_record(configuration(image, upper, overlay, identity)); let mut system = FakeSystem::new(); - let (root, applied) = - apply_with(&mut system, &record, &template, None).unwrap(); - assert_eq!(root, system.root()); + let applied = apply_with(&mut system, &record, &template, None).unwrap(); let assembly = Assembly { runtime: template.runtime.clone(), distro: template.distro, @@ -689,15 +829,16 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { } effects.push(Effect::Assemble(assembly)); effects.extend(identity.clone().map(Effect::Account)); + effects.push(Effect::Bind(view(identity.clone(), MachineId::Image))); assert_eq!(system.effects, effects, "{record:?}"); - assert_eq!(applied, identity); + assert_eq!(applied, activated(&system, identity, MachineId::Image)); } } } } - // The record's other settings, which the application leaves alone, change neither input: - // its bindings, export, network, hostname, machine ID, resources, and restore section. + // 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(); other.image = activation::Image::Erofs { binding: activation::Identity::from_bytes([0x66; activation::Identity::LEN]), @@ -714,20 +855,22 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { publication: Publication::Published, proxy: Some("http://10.0.0.1:3128".to_owned()), }); - other.workload.hostname = b"another".to_vec(); - other.workload.machine_id = MachineIdMode::Generated; other.resources = ExpectedResources { cpus: 8, memory: 1 << 33, }; let other = ActivationRecord::new(None, other).unwrap(); let (mut first, mut second) = (FakeSystem::new(), FakeSystem::new()); - let first_identity = apply_with(&mut first, &accepted(), &template, None) - .unwrap() - .1; - let second_identity = apply_with(&mut second, &other, &template, None).unwrap().1; + let first_applied = apply_with(&mut first, &accepted(), &template, None).unwrap(); + let second_applied = apply_with(&mut second, &other, &template, None).unwrap(); assert_eq!(first.effects, second.effects); - assert_eq!(first_identity, second_identity); + assert_eq!( + first_applied, + Activated { + root: first.root(), + ..second_applied + } + ); } #[test] @@ -764,6 +907,7 @@ fn a_gpt_disk_s_partition_table_is_reread_before_its_root_is_assembled() { Effect::Reread(template.distro), Effect::Assemble(assembly), Effect::Account(workload()), + Effect::Bind(view(Some(workload()), MachineId::Image)), ] ); // The reread waited for the distro device, and the assembly for each device, until the @@ -842,7 +986,8 @@ fn a_workload_identity_without_an_account_in_the_image_is_refused_before_ready() } // Without a workload identity, the application reads no account: each execution names its - // own identity, which the adapter's entry checks. + // own identity, which the adapter's entry checks. The entry that binds the runtime's files + // binds the same files whatever its identity. let anyone = clone_record(configuration( erofs(Some(IMAGE_UUID)), activation::Upper::Tmpfs, @@ -850,9 +995,15 @@ fn a_workload_identity_without_an_account_in_the_image_is_refused_before_ready() None, )); let mut system = FakeSystem::with(b"", b""); - let (_, identity) = apply_with(&mut system, &anyone, &template(), None).unwrap(); - assert_eq!(identity, None); - assert_eq!(system.effects, [Effect::Assemble(accepted_assembly())]); + let applied = apply_with(&mut system, &anyone, &template(), None).unwrap(); + assert_eq!(applied.identity, None); + assert_eq!( + system.effects, + [ + Effect::Assemble(accepted_assembly()), + Effect::Bind(view(None, MachineId::Image)), + ] + ); } #[test] @@ -886,6 +1037,283 @@ fn a_failed_assembly_is_final_and_reads_and_tears_down_nothing() { assert_eq!(adapter.system.effects.len(), 1); } +#[test] +fn a_generated_machine_id_is_generated_once_after_the_gate_for_every_execution() { + // The application generates the machine ID once the activation accepted the record, after + // the root's assembly and the account's check, in the template's runtime directory, and + // binds it for every execution, through the entry of the record's hostname. + let base = Instant::now(); + let record = with_generated_id(); + let bytes = record.encode(); + let mut adapter = Adapter::restricted(FakeSystem::new(), base); + adapter + .take(base, ActivationInput::Activate(&bytes)) + .unwrap(); + let expected = [ + Effect::Assemble(accepted_assembly()), + Effect::Account(workload()), + Effect::Generate(machine_id_path()), + Effect::Bind(view(Some(workload()), generated_id())), + ]; + assert_eq!(adapter.system.effects, expected); + assert_eq!( + adapter.applied, + Some(activated(&adapter.system, Some(workload()), generated_id())) + ); + assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); + assert_eq!(machine_id_path(), Path::new("/run/nvx/workload-machine-id")); + + // Every execution, a retry of the Activate, and a retry in a later session reuse it: nothing + // generates or binds it again. + adapter.take(base, ActivationInput::Request).unwrap(); + adapter + .take(base, ActivationInput::Activate(&bytes)) + .unwrap(); + adapter.take(base, ActivationInput::Reset).unwrap(); + adapter.ready = None; + adapter + .take(base, ActivationInput::Activate(&bytes)) + .unwrap(); + assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); + adapter.take(base, ActivationInput::Request).unwrap(); + assert_eq!(adapter.system.generated(), 1); + assert_eq!( + adapter.system.effects[expected.len()..], + [Effect::Spawn, Effect::Spawn] + ); + + // The image's own machine ID generates nothing: its view, which the entry binds nothing + // for, leaves the image's `/etc/machine-id` alone. + let mut system = FakeSystem::new(); + let applied = apply_with(&mut system, &accepted(), &template(), None).unwrap(); + assert_eq!(system.generated(), 0); + assert_eq!( + applied, + activated(&system, Some(workload()), MachineId::Image) + ); +} + +#[test] +fn the_view_s_entry_takes_the_record_s_hostname_and_machine_id() { + // The entry through which the application binds the runtime's files checks the record's + // hostname, which every execution's entry takes, and refuses one that no execution could + // set. + let view_entry = |hostname: &[u8]| { + let named = View { + hostname: hostname.to_vec(), + ..view(None, MachineId::Image) + }; + named.entry(Path::new(RUNTIME)) + }; + view_entry(HOSTNAME).check().unwrap(); + for refused in [&b""[..], &[b'h'; 65], b"a\0b"] { + let refusal = view_entry(refused).check(); + assert!( + matches!(refusal, Err(entry::Error::Hostname)), + "{refused:?}: {refusal:?}" + ); + } + + // It binds a generated machine ID, and nothing for the image's own: only the former opens + // 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}"); +} + +#[test] +fn a_machine_id_or_a_view_that_fails_fails_the_activation_once_the_root_is_torn_down() { + let base = Instant::now(); + let bytes = with_generated_id().encode(); + // A file at the machine ID's path already, as once a boot generated its ID, and a bind that + // the image refuses, as one whose `/etc/machine-id` is a directory. + let generation = Effect::Generate(machine_id_path()); + let bind = Effect::Bind(view(Some(workload()), generated_id())); + let cases = [ + ( + FakeSystem::generating(io::ErrorKind::AlreadyExists), + vec![generation.clone()], + ), + (FakeSystem::unbindable(), vec![generation, bind]), + ]; + for (system, effects) in cases { + let mut adapter = Adapter::restricted(system, base); + let failure = adapter + .take(base, ActivationInput::Activate(&bytes)) + .unwrap_err(); + assert_eq!(failure.state(), ActivationState::Activating); + match apply_error(&failure) { + ApplyError::MachineId { + error, + teardown: None, + } => assert_eq!(error.kind(), io::ErrorKind::AlreadyExists), + ApplyError::View { + error: entry::Error::MachineId(MappingError::TargetKind), + teardown: None, + } => {} + error => panic!("{error}"), + } + // The root was assembled, its account checked, and the root torn down, without a Ready, + // and behind the closed fence. + let mut expected = vec![ + Effect::Assemble(accepted_assembly()), + Effect::Account(workload()), + ]; + expected.extend(effects); + expected.push(Effect::Teardown); + assert_eq!(adapter.system.effects, expected); + assert_eq!(adapter.activation.state(), ActivationState::Failed); + assert_eq!(adapter.applied, None); + assert_eq!(adapter.ready, None); + assert!(!adapter.fence.is_open()); + // The failure is final: no retry applies the record again. + assert!( + adapter + .take(base, ActivationInput::Activate(&bytes)) + .is_err() + ); + assert_eq!(adapter.system.effects, expected); + } +} + +#[test] +fn the_runtime_s_binds_are_removed_before_the_root_is_torn_down() { + // The binds keep the root's mount busy, so they go first. + let mut system = FakeSystem::new(); + let root = system.root(); + teardown_with(&mut system, (), root).unwrap(); + assert_eq!(system.effects, [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]); + assert!(error.root().is_none()); + let binds = error.binds().unwrap(); + assert_eq!(binds.failures().len(), 1); + assert_eq!(binds.failures()[0].target(), Path::new("/etc/machine-id")); + assert_eq!(error.to_string(), binds.to_string()); + assert!( + error + .to_string() + .starts_with("failed to unmount the mapping at /etc/machine-id: "), + "{error}" + ); +} + +/// A tmpfs that stands for a clone's root, mounted at `root` below a temporary directory, in a +/// private mount namespace of the calling thread, beside a directory that stands for the +/// template's runtime directory, `runtime`. +struct LiveRoot { + /// The temporary directory, which the drop removes once it detached the tmpfs. + directory: Option, + /// The tmpfs's mount point. + root: PathBuf, + /// The runtime directory. + runtime: PathBuf, +} + +impl LiveRoot { + /// Returns a root whose `/etc/machine-id` holds [`IMAGE_MACHINE_ID`], or `None` where the + /// test cannot run: without root, or without unique mount IDs, which Linux 6.8 added. + fn new() -> Option { + if !nix::unistd::geteuid().is_root() { + report("skipped: the test needs root"); + return None; + } + sys::enter_private_mount_namespace().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let base = directory.path().canonicalize().unwrap(); + let live = Self { + directory: Some(directory), + root: base.join("root"), + runtime: base.join("runtime"), + }; + fs::create_dir(&live.root).unwrap(); + fs::create_dir(&live.runtime).unwrap(); + let root = sys::path_c_string(&live.root).unwrap(); + let tmpfs = Some(c"tmpfs"); + let options = Some(c"mode=0755"); + sys::mount(tmpfs, &root, tmpfs, MountFlags::empty(), options).unwrap(); + fs::create_dir(live.root.join("etc")).unwrap(); + fs::write(live.root.join("etc/machine-id"), IMAGE_MACHINE_ID).unwrap(); + let flags = OpenFlags::O_PATH | OpenFlags::O_CLOEXEC; + match sys::file_status(&sys::open(&root, flags, Mode::empty()).unwrap()) { + Err(Errno::EOPNOTSUPP) => { + report("skipped: the kernel has no unique mount IDs"); + None + } + status => { + assert!(status.unwrap().mount_root); + Some(live) + } + } + } +} + +impl Drop for LiveRoot { + /// Detaches the tmpfs, with what is mounted below it, then removes the directory. + fn drop(&mut self) { + let _ = nix::mount::umount2(&self.root, nix::mount::MntFlags::MNT_DETACH); + drop(self.directory.take()); + } +} + +/// The machine ID of the image of the privileged test's root. +const IMAGE_MACHINE_ID: &[u8] = b"0123456789abcdef0123456789abcdef\n"; + +/// Writes `message` to the standard error, past the test harness's capture, as the guest's +/// programs write theirs. +fn report(message: &str) { + use std::io::Write; + + let _ = writeln!(io::stderr(), "{message}"); +} + +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn a_generated_machine_id_keeps_the_root_busy_until_its_bind_is_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. + let Some(live) = LiveRoot::new() else { + return; + }; + let mut system = LiveSystem; + let path = live.runtime.join(MACHINE_ID_FILE); + let machine_id = system.generate(&path).unwrap(); + assert_eq!(machine_id, MachineId::Generated(path.clone())); + let generated = fs::read(&path).unwrap(); + assert_eq!(generated.len(), IMAGE_MACHINE_ID.len()); + assert_ne!(generated, IMAGE_MACHINE_ID); + let again = system.generate(&path).unwrap_err(); + assert_eq!(again.kind(), io::ErrorKind::AlreadyExists); + let 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) + ); + + // 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. + 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); + sys::unmount(&root).unwrap(); +} + #[test] fn the_application_refuses_without_privileges_what_it_cannot_assemble() { // A template's runtime directory reaches the assembly, which refuses a relative one before it @@ -975,6 +1403,30 @@ fn application_errors_name_their_cause() { ); assert!(format!("{error:?}").starts_with("Account { error: Account(Missing)")); + let exists = io::Error::from(io::ErrorKind::AlreadyExists); + let error = ApplyError::MachineId { + error: io::ErrorKind::AlreadyExists.into(), + teardown: None, + }; + assert_eq!( + error.to_string(), + format!("the workload's machine ID cannot be generated: {exists}") + ); + assert_eq!(error.source().unwrap().to_string(), exists.to_string()); + + let error = ApplyError::View { + error: entry::Error::Hostname, + teardown: None, + }; + assert_eq!( + error.to_string(), + "the workload's hostname or machine ID cannot be set: the hostname is invalid" + ); + assert_eq!( + error.source().unwrap().to_string(), + "the hostname is invalid" + ); + // 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/nvx-rs/src/guest/mappings.rs b/nvx-rs/src/guest/mappings.rs index 2490a0e44..b94f542fb 100644 --- a/nvx-rs/src/guest/mappings.rs +++ b/nvx-rs/src/guest/mappings.rs @@ -712,6 +712,18 @@ impl TeardownError { pub fn failures(&self) -> &[UnmountFailure] { &self.failures } + + /// Returns the error of a teardown that failed to unmount the mapping at `target`, an + /// absolute path in the root, with `error`, for the tests of other modules. + #[cfg(test)] + pub(crate) fn of(target: impl Into, error: MappingError) -> Self { + Self { + failures: vec![UnmountFailure { + target: target.into(), + error, + }], + } + } } impl fmt::Display for TeardownError { diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 0d56ff6d6..7b72ce7ae 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -609,10 +609,14 @@ credit that it needs, never keeps the others' output from the host. of the refusal, or that of the preparation or the spawn. - Each program enters the layered root through `nvx::guest::entry`, as a `worker::Entrance` describes it: in new mount, PID, and UTS namespaces, with - the root moved over `/`, the entry's default hostname, `nvx-sandbox`, the - image's own machine ID, and no time status, so that the entry binds nothing - into the root, and with the publication of listeners that the root - configuration names, published unless it names them unpublished. It runs as + the root moved over `/`, the 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. 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 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 @@ -806,9 +810,13 @@ after the repair's acknowledgement of the restore: 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. It applies none of the - record's other settings: its export, its network, its hostname, and its - machine ID. + `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 @@ -820,8 +828,11 @@ after the repair's acknowledgement of the restore: 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. Without an export, the clone - refuses each part of a mapping table with `MapFailed`. It also answers the + 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 order happened after the repair's acknowledgement of the restore. @@ -872,7 +883,7 @@ ends with one of these statuses, after a diagnostic on standard error: | 52 | The `Activate`'s record does not decode, breaks a limit, or is a cold boot's | | 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 image refuses the record's workload identity: it holds no account of the identity, or not its group, or its account databases cannot be read | +| 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 | | 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 | @@ -1367,15 +1378,18 @@ library side, `clone`, holds what the command's options choose of the clone, `clone::Request`: its image, an EROFS layer or a GPT disk of the length of the template's placeholder, its upper, on an ext4 scratch image or in RAM, its memory target, the template's RAM unless given, the workload identity as which -its executions run, its overlay mode, its scratch's journal policy, and the -bound of a GPT disk's first partition. `Request::plan` plans the image over the -upper as a launch plans a layered root, and `Request::clone_request` returns -NVX's `CloneRequest` of it, with the bindings that the sandbox's store stages -for the images, as `stage` stages them, so the start hashes nothing. The -clone's activation record names the image's own machine ID, the hostname -`runtime-adapter`, and the guest's defaults of the other settings, unless the -request names them; for a GPT disk, the bound of its first partition is 64 KiB -unless the request names one, as a launch's guest takes it by default. +its executions run, its overlay mode, its scratch's journal policy, the bound +of a GPT disk's first partition, and the machine ID that its executions see. +`Request::plan` plans the image over the upper as a launch plans a layered +root, and `Request::clone_request` returns NVX's `CloneRequest` of it, with the +bindings that the sandbox's store stages for the images, as `stage` stages +them, so the start hashes nothing. The clone's activation record names the +hostname `runtime-adapter`, which every execution sees, the image's own machine +ID, unless the request asks for one that the guest generates once for the +clone, which a read-only bind holds at the root's `/etc/machine-id` for every +execution, and the guest's defaults of the other settings, unless the request +names them; for a GPT disk, the bound of its first partition is 64 KiB unless +the request names one, as a launch's guest takes it by default. The guest of a GPT clone rereads the disk's partition table before it assembles the root, since its kernel created the partitions of the template's placeholder. @@ -1570,7 +1584,7 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from its record again, and releases the lease of a clone that ended, through the template store TEMPLATES, if given, in place of the one that the clone's record names. -- `start STORE --template TEMPLATES --generation G --distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES]` +- `start STORE --template TEMPLATES --generation G --distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES] [--machine-id (image | generated)]` starts a [clone](#clones) of the generation G of the template store TEMPLATES in the sandbox store STORE, which it creates if needed, and whose `stage` staged its images, which no one else may change: the image PATH, an @@ -1582,7 +1596,10 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from every execution runs, and whose account the guest checks before the clone is ready, the overlay mode MODE and the scratch's journal policy POLICY, as a launch takes them, and for a GPT disk, the bound BYTES of its first - partition, 64 KiB by default, as a launch's guest takes it. + partition, 64 KiB by default, as a launch's guest takes it. Every execution + of the clone sees the hostname `runtime-adapter`, and the machine ID that + `--machine-id` chooses: the image's own, `image`, by default, or one that the + guest generates once for the clone, `generated`. Once the restore sequence started the clone's sandbox, it exchanges a `Ping` and the longest `Echo` with the guest agent, commits the launch, and exits, leaving the VM running, and the sandbox holding the lease. A clone that @@ -1987,9 +2004,11 @@ epoch of its broker's first reset, and the clone's activation, root applies each record as `nvx::guest::clone::apply` does, without a root: for 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, and checks the -account of the record's workload identity in the tests' directory of account -databases. The tests cover: +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: - the reset that the restore queued, of another instance, which the worker acknowledges without an attachment, and the `Activate`, whose record it @@ -2004,9 +2023,10 @@ databases. The tests cover: nothing answered or applied; - 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, and a workload identity - whose account or group the image lacks, which fail its application, each - with the status of its class, and no `Ready`; + 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`; - 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; @@ -2040,7 +2060,8 @@ databases. The tests cover: the activation with the status of the host's session; - the record's workload identity, as which every execution runs, while a start that names an identity is refused with `EINVAL`, as is a start that names - none once the record has none; + none once the record has none, and the record's hostname, which every + execution's entry takes; - 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 @@ -2052,9 +2073,9 @@ Windows, without a hypervisor: - the command lines of `start` that the host program refuses, each with status 2: missing options, a generation that is not a positive number, a scratch's journal policy for an upper in RAM, a first-partition bound for an EROFS - image, invalid values, and the options of other commands, and the options of - `start` that other commands do not take, with a clock fixture that is not a - host's time too; + image, invalid values, an option given twice, and the options of other + commands, and the options of `start` that other commands do not take, with a + clock fixture that is not a host's time too; - a start whose template store does not exist, or does not hold the generation, and whose image the plan refuses, each with status 1 before it needs NVX's artifacts or creates the sandbox's store; @@ -2419,8 +2440,8 @@ and starts its clones, each in a sandbox store of its own, over a copy of the scratch template, which no one else may change either, or in RAM. The GPT disks of a template's placeholder and of the clones, which the test writes with `tests/common/gpt.rs`, as `tests/vm_layered.rs` writes its own, hold their -first partition, then empty layers, at partitions that differ from one disk to -another: +first partition, then layers, empty but for one disk's regular file `etc`, at +partitions that differ from one disk to another: | Test | Clones | Expects | | --- | --- | --- | @@ -2429,6 +2450,8 @@ another: | `a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts` | An image 4 KiB longer, one 4 KiB shorter, and one 100 bytes longer, than the placeholder, a scratch image of twice the template's scratch, memory targets of 2 GiB and of 320 MiB, unstaged images, and a generation that the store does not hold, then the generation once its key is gone | For those that NVX's plan refuses, status 8, with NVX's reason, and the record of a failure of the request, at the plan, without a VM, by the evidence of a length that the request supplied, of the image's geometry, which OpenVMM cannot attach, or of invalid targets, which quarantines nothing; for the others, status 1, without a record; any lease released, and no VMM's log or record in the sandbox; for the generation without its key, status 8, and the record of a failure of the template, at the plan, by the evidence of a malformed generation, which quarantines the generation, so that a later start fails without a lease | | `a_clone_whose_activation_fails_ends_before_it_is_ready` | A scratch image of zeros, a journal that the record forbids, a workload identity whose account the image lacks, and, through NVX's restore sequence, a ping, a start, or the start of an execution before the `Activate` | Status 8, no `Ready`, and the record of a failure, at the activation, once the VM exited: of the request, by the evidence of an unusable root, with the guest's status 54, or of invalid settings, with 55, or, before the `Activate`, of an unknown cause, by the protocol's, with 51, which the sandbox records until the release; nothing quarantined, and the lease released, with the records | | `a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used` | Over a scratch image, in whose root the `contents` probe writes a file, then over a copy of that scratch image once the first clone stopped | The first started; then status 8, since the copy's upper is not fresh, and the record of a failure of the request, at the activation, once the VM exited, by the evidence of an unusable root, with the guest's status 54; nothing quarantined, and the lease released, with the records | +| `clones_at_once_keep_their_own_uppers_and_machine_ids` | Three at once: over a scratch image and in RAM, each with a generated machine ID, and in RAM with the image's own; two executions in each, as user ID 0, that write a marker in the root, and try to write `/etc/machine-id` | In every execution, the hostname `runtime-adapter`, and no marker of another clone; a generated machine ID that differs between the clones, the same in each execution of a clone, held by one bind that is read-only, `nosuid`, and `nodev`, and that the write cannot change; with the image's own machine ID, none, since the image has none, no bind, and in the later execution, the ID that the first wrote; then each stop releases its lease | +| `a_clone_whose_view_cannot_be_set_ends_before_it_is_ready` | Of a template of the EROFS image, a GPT disk whose layer holds a regular file `etc`, with the image's own machine ID, then with a generated one | The first started, with the disk's root; then status 8, since no bind can hold the machine ID below a file, and the record of a failure of the request, at the activation, once the VM exited, by the evidence of invalid settings, with the guest's status 55; nothing quarantined, and the lease released, with the records | | `a_clone_runs_its_executions_as_its_workload_identity_once_it_is_ready` | Over a scratch image, as nobody | An execution that runs as nobody, one that names an identity refused with `EINVAL`, and the first execution after the clone's start | | `a_generation_is_eligible_by_the_clock_of_its_capture` | At the host's time, then with clock fixtures: UTC a day behind, another boot an hour after the capture by UTC, and the restore window passed by each clock, or UTC before the capture | The host's monotonic clock on the boot of the capture, whatever UTC says, UTC after a reboot, with the fixture's downtime, and the store's refusal without a lease outside the window | | `a_clone_reads_no_more_of_its_disks_than_its_root_needs` | Over a scratch image, and in RAM | Less than an eighth of each disk read once the clone is ready, and each disk's whole length once the guest read it to its end | diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index c3383ad46..fa2ad16b1 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -97,16 +97,16 @@ //! the session's next epoch, and exchanges a `Ping`, leaving the VM running. //! - `runtime-adapter-host start STORE --template TEMPLATES --generation G --distro FORMAT:PATH //! --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity -//! UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES]` -//! starts a clone of the generation G of the template store TEMPLATES, an -//! `nvx::host::template::Store`, in the sandbox store STORE, which it creates if needed; see -//! `runtime_adapter::clone`. It reads the generation from the store's index, and, unless MIB -//! mebibytes are given, its compatibility key, whose RAM is then the clone's memory target, -//! before it needs NVX's artifacts. A key that cannot be read is left to NVX's plan, which reads -//! it again, and attributes the failure, to the generation or to the host, as it does for every -//! clone. The store then leases the generation to the sandbox, at the host's time, with -//! `Store::acquire`, which refuses a generation that a tombstone quarantines, or that is not -//! eligible then, and NVX's `ClonePlan` checks the clone against the generation, from the +//! UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES] +//! [--machine-id (image | generated)]` starts a clone of the generation G of the template store +//! TEMPLATES, an `nvx::host::template::Store`, in the sandbox store STORE, which it creates if +//! needed; see `runtime_adapter::clone`. It reads the generation from the store's index, and, +//! unless MIB mebibytes are given, its compatibility key, whose RAM is then the clone's memory +//! target, before it needs NVX's artifacts. A key that cannot be read is left to NVX's plan, +//! which reads it again, and attributes the failure, to the generation or to the host, as it +//! does for every clone. The store then leases the generation to the sandbox, at the host's +//! time, with `Store::acquire`, which refuses a generation that a tombstone quarantines, or that +//! is not eligible then, and NVX's `ClonePlan` checks the clone against the generation, from the //! metadata of its files alone: its image, the raw file PATH, an EROFS filesystem, `erofs`, or a //! GPT disk, `gpt`, of which the guest expects the identity UUID, of the length of the template's //! placeholder, whatever the placeholder's format, its upper, the ext4 scratch image PATH, of the @@ -122,7 +122,10 @@ //! record: the workload identity UID:GID, if given, as which every execution runs, whose account //! the guest checks in the clone's root before it is ready, and the overlay mode MODE and the //! scratch's journal policy POLICY, as a launch takes them, and for a GPT disk, the bound BYTES -//! of its first partition, 64 KiB unless given, as a launch's guest takes it by default. The +//! of its first partition, 64 KiB unless given, as a launch's guest takes it by default. Every +//! execution of the clone sees the hostname `runtime-adapter`, and the machine ID that +//! `--machine-id` chooses: the image's own, `image`, by default, or one that the guest generates +//! once for the clone, `generated`, which a read-only bind holds at `/etc/machine-id`. The //! guest refuses a first partition larger than BYTES, and an upper that is not fresh, whose //! `upper` or `work` directory on the scratch image exists and is not empty, as an earlier //! clone's would be, before the clone is ready. Once the clone is ready, the command starts its @@ -291,7 +294,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, Instant}; -use nvx::activation::StoreGeneration; +use nvx::activation::{MachineIdMode, StoreGeneration}; use nvx::exec::{Cause, Key, Limits, Outcome, Status, Stream, Timers, Window}; use nvx::host::artifacts::{Artifacts, absolute}; use nvx::host::instance::generate_capability; @@ -415,7 +418,7 @@ const USAGE: &str = concat!( " [--memory MIB]\n", " [--workload-identity UID:GID] [--overlay-mode MODE]", " [--scratch-journal POLICY]\n", - " [--first-partition-bound BYTES]\n", + " [--first-partition-bound BYTES] [--machine-id (image | generated)]\n", " runtime-adapter-host probe STORE PROBE... [--guest-build BUILD]\n", " runtime-adapter-host exec STORE [--guest-build BUILD] EXECUTION [';' EXECUTION]...\n", " EXECUTION: [--identity UID:GID [--groups GID,...] [--capabilities N,...]", @@ -667,6 +670,8 @@ struct Options { scratch_journal: Option, /// `--first-partition-bound`. first_partition_bound: Option, + /// `--machine-id`. + machine_id: Option, /// `--workload-identity`. workload_identity: Option<(u32, u32)>, /// `--share`, `--deny`, `--reserved`, and `--share-root`, in order. @@ -945,6 +950,7 @@ fn parse(arguments: Vec) -> Result { overlay_mode: options.overlay_mode, scratch_journal: options.scratch_journal, first_partition_bound: options.first_partition_bound, + machine_id: options.machine_id.unwrap_or(MachineIdMode::Image), }, }), time: host_time_from_env()?, @@ -1044,6 +1050,19 @@ fn parse_option( format!("--first-partition-bound takes a positive number of bytes, not {value:?}") })?); } + ("start", "--machine-id") if options.machine_id.is_none() => { + let value = text(value)?; + let mode = match value.as_str() { + "image" => MachineIdMode::Image, + "generated" => MachineIdMode::Generated, + _ => { + return Err(format!( + "--machine-id takes image or generated, not {value:?}" + )); + } + }; + options.machine_id = Some(mode); + } ("launch" | "start", "--workload-identity") if options.workload_identity.is_none() => { let value = text(value)?; let identity = ids(&value) diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index 53546e568..996c33ed4 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -4,7 +4,8 @@ //! A [`Request`] holds what the host program's options choose of a clone: its image, an EROFS //! layer or a GPT disk of the length of the template's placeholder, its upper, on an ext4 scratch //! image or in RAM, its memory target, the workload identity as which its executions run, its -//! overlay mode, its scratch's journal policy, and the bound of a GPT disk's first partition. +//! overlay mode, its scratch's journal policy, the bound of a GPT disk's first partition, and the +//! machine ID that its executions see, the image's own or one that its guest generates. //! [`Request::plan`] plans the image over the upper as a launch plans a layered root, and //! [`Request::clone_request`] returns NVX's `nvx::host::template::CloneRequest` of it, with the //! bindings that the sandbox's store stages for its images, as `staged::Staged` records them: @@ -46,8 +47,8 @@ use crate::staged::invalid; pub const CLONE_MODE: &str = "clone"; /// The hostname that a clone's activation record names. The guest leaves the hostname to the -/// activation of the workload's view, as it leaves the machine ID, which the record keeps as the -/// image's own. +/// activation of the workload's view, which sets it for every execution, as it sets the machine +/// ID that the record names. pub const CLONE_HOSTNAME: &str = "runtime-adapter"; /// The variable from which `runtime-adapter-host start` takes the host's time, for tests, in the @@ -80,6 +81,9 @@ pub struct Request { /// The most bytes that the first partition of a GPT disk may hold, or `None` for /// [`DEFAULT_FIRST_PARTITION_BOUND`], the default of a launch's guest. pub first_partition_bound: Option, + /// The machine ID that the clone's executions see: the image's own, as a launch's do, or one + /// that the clone's guest generates. + pub machine_id: MachineIdMode, } impl Request { @@ -104,7 +108,7 @@ impl Request { /// `staged::Staged::bindings` finds them, and the memory target `memory`, in bytes. /// /// The record of the clone's activation names the workload identity, if any, the - /// [`CLONE_HOSTNAME`], the image's own machine ID, the overlay's mode, and the scratch's + /// [`CLONE_HOSTNAME`], the request's machine ID, the overlay's mode, and the scratch's /// journal policy, each the guest's default unless the request names it, as a root /// configuration's are, and the bound of a GPT disk's first partition, the default of a /// launch's guest, [`DEFAULT_FIRST_PARTITION_BOUND`], unless the request names one. The record @@ -147,7 +151,7 @@ impl Request { memory, workload: WorkloadView { hostname: CLONE_HOSTNAME.as_bytes().to_vec(), - machine_id: MachineIdMode::Image, + machine_id: self.machine_id, identity: self .workload_identity .map(|(uid, gid)| WorkloadIdentity { uid, gid }), diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index 701d6e3bf..cd97f379d 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -84,8 +84,11 @@ //! 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. The template bootstrapped the guest for its root with -//! its own configuration before its capture, so the clone has its cgroups. +//! 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 @@ -96,14 +99,16 @@ //! 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, and a clone's executions run in the cgroups of the template's -//! bootstrap. 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. +//! 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 +//! `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, its network, its -//! hostname, and its machine ID. 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 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. //! //! 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 @@ -191,26 +196,29 @@ //! at stage 0, with `EAGAIN`, `ENOMEM`, the `errno` of the refusal, or that of the preparation //! or the spawn. //! - Each program enters the assembled root through `nvx::guest::entry`, as an [`Entrance`] -//! describes it: in new mount, PID, and UTS namespaces, with the root moved over `/`, the entry's -//! default hostname, `nvx-sandbox`, the image's own machine ID, and no time status, so that the -//! entry binds nothing into the root, and with the publication of listeners that the root -//! configuration names, published unless it names them unpublished, as the entry's default is. 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 its account mode. A start that names an identity in a sandbox with a -//! workload identity, or none in a sandbox without one, is refused with `EINVAL`. Before the -//! worker prepares the execution's cgroup, the entry checks the identity, and reads its account -//! in existing-account mode: it refuses with `EPERM` a capability outside its allowlist, any -//! capability of a user ID other than 0, and `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while -//! listeners are published, with `ENOENT` an identity without the user ID's passwd entry or the -//! group ID's group entry, with the error of an account database or a root that it cannot read, -//! and with `EINVAL` otherwise. The program starts in the working directory of its start, or in -//! the root's `/`, with exactly the environment of its start, and leads a session of its own, -//! without a controlling terminal, and the process group that the session creates. As PID 1 of -//! its PID namespace, its end ends every process of the namespace. The guest's network device, -//! its addresses, and the announcer of its listeners stay with NVX's `/init`, which configures -//! them before PID 1 starts. +//! describes it: in new mount, PID, and UTS namespaces, with the root moved over `/`, with the +//! 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 +//! 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 +//! its account mode. A start that names an identity in a sandbox with a workload identity, or +//! none in a sandbox without one, is refused with `EINVAL`. Before the worker prepares the +//! execution's cgroup, the entry checks the identity, and reads its account in existing-account +//! mode: it refuses with `EPERM` a capability outside its allowlist, any capability of a user ID +//! other than 0, and `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while listeners are published, +//! with `ENOENT` an identity without the user ID's passwd entry or the group ID's group entry, +//! with the error of an account database or a root that it cannot read, and with `EINVAL` +//! otherwise. The program starts in the working directory of its start, or in the root's `/`, +//! with exactly the environment of its start, and leads a session of its own, without a +//! controlling terminal, and the process group that the session creates. As PID 1 of its PID +//! namespace, its end ends every process of the namespace. The guest's network device, its +//! addresses, and the announcer of its listeners stay with NVX's `/init`, which configures them +//! before PID 1 starts. //! - Each execution runs in a cgroup of its own below the workload cgroup, `execution-N`, where N //! counts the cgroups that the worker prepared. The worker prepares it with //! `nvx::guest::cgroup::ExecutionCgroup::prepare`, and the program joins it as it spawns, @@ -356,12 +364,15 @@ 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. +/// 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. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Entrance { /// The identity: the sandbox's workload identity, or the one that the execution's start names. pub identity: Identity, - /// The hostname, the sandbox's: the entry's default. + /// The hostname, the sandbox's: the entry's default after a cold boot, and the record's in a + /// clone. pub hostname: Vec, /// Whether the sandbox's listeners are published, as the root configuration says, which /// refuses `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while they are. @@ -427,13 +438,15 @@ pub trait RootOperations: EntryOperations { /// Runs `probe` on the assembled root, and returns what it found. fn probe(&mut self, probe: &Probe) -> Result; - /// Tears the assembled root down. + /// Tears the assembled root down, once it removed the runtime's binds that a clone's + /// application, through `apply`, made on it, which keep the root's mount busy. fn teardown(&mut self) -> Result<(), String>; /// Applies `record`, the record of the Activate that a clone's activation accepted, to the /// clone, as `nvx::guest::clone::apply` does, with `template`, the inputs of the root's /// assembly that the clone's template captured, by `deadline`, the activation's, if any, until - /// which it waits for each device at the latest, and keeps the root that it assembled: in + /// 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. /// The template's VM [bootstrapped](Self::bootstrap) the guest before its capture. /// @@ -1055,7 +1068,7 @@ fn run_probe(operations: &mut dyn RootOperations, body: &[u8]) -> Message { /// /// Dropping it closes the descriptors that probes keep open and the host export's root, then /// unmounts a root that it still holds, as `nvx::guest::root::Root` does when it drops, ignoring -/// failures. +/// failures, once it removed the runtime's binds that a clone's application made on it. pub struct LibraryRoot { /// The process ID of PID 1's unrelated child, which the probes check, if PID 1 named one. sibling: Option, @@ -1069,7 +1082,49 @@ pub struct LibraryRoot { /// before its teardown. export: Option, /// The root, from its assembly until its teardown. - root: Option, + root: Option, +} + +/// 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. +enum Assembled { + /// The root of a VM that boots cold. + Cold(Root), + /// A clone's root and workload, with the runtime's binds that the application made on the + /// root, which its teardown, and its drop, remove before they unmount the root. + #[cfg(target_arch = "x86_64")] + Clone(clone::Applied), +} + +impl Assembled { + /// Returns the root. + fn root(&self) -> &Root { + match self { + Self::Cold(root) => root, + #[cfg(target_arch = "x86_64")] + Self::Clone(applied) => applied.root(), + } + } + + /// Returns the root, to mount the host export and mappings in it. + fn root_mut(&mut self) -> &mut Root { + match self { + Self::Cold(root) => root, + #[cfg(target_arch = "x86_64")] + Self::Clone(applied) => applied.root_mut(), + } + } + + /// Tears the root down: a clone's once it removed the runtime's binds, which keep the root's + /// mount busy, in the worker's mount namespace, where the application made them. Fails with + /// the problem, the binds that stay mounted and the unmounts that failed, whose mounts stay. + fn teardown(self) -> Result<(), String> { + match self { + Self::Cold(root) => root.teardown().map_err(|error| error.to_string()), + #[cfg(target_arch = "x86_64")] + Self::Clone(applied) => applied.teardown().map_err(|error| error.to_string()), + } + } } impl LibraryRoot { @@ -1166,14 +1221,14 @@ impl RootOperations for LibraryRoot { timeout: config.device_timeout, }; let root = root::assemble(&assembly).map_err(|error| error.to_string())?; - self.root = Some(root); + self.root = Some(Assembled::Cold(root)); Ok(()) } /// Runs `nvx::guest::root::Root::mount_export` with the export's tag, and the access of its /// mode, and keeps the export's root, from which the worker maps the host's mapping table. fn mount_export(&mut self, export: &HostExport) -> Result<(), String> { - let Some(root) = &mut self.root else { + let Some(root) = self.root.as_mut().map(Assembled::root_mut) else { return Err("the worker holds no root to mount the host export for".to_owned()); }; let source = root @@ -1186,7 +1241,8 @@ impl RootOperations for LibraryRoot { /// Runs `nvx::guest::root::Root::map` from the host export's root, with each mapping in turn, /// so that a failure tells how many of the mappings before it the root applied. fn map(&mut self, mappings: &[Mapping]) -> Result<(), MapError> { - let (Some(root), Some(export)) = (&mut self.root, &self.export) else { + let root = self.root.as_mut().map(Assembled::root_mut); + let (Some(root), Some(export)) = (root, &self.export) else { return Err(MapError { applied: 0, problem: "the worker holds no host export to map from".to_owned(), @@ -1212,7 +1268,8 @@ impl RootOperations for LibraryRoot { /// Runs one of the [`probes`], of the [`mapping`] probes, which inspect the root's mappings /// and map from the host export, or of the [`entries`] probes, which enter it. fn probe(&mut self, probe: &Probe) -> Result { - let (Some(bootstrap), Some(root)) = (&self.bootstrap, &mut self.root) else { + let root = self.root.as_mut().map(Assembled::root_mut); + let (Some(bootstrap), Some(root)) = (&self.bootstrap, root) else { return Err("the worker holds no root to probe".to_owned()); }; // The upper's mount point, below the runtime directory, as `nvx::guest::root` names it. @@ -1249,16 +1306,18 @@ impl RootOperations for LibraryRoot { } /// Runs `nvx::guest::root::Root::teardown`, once it closed the host export's root, which keeps - /// the export busy. A root whose teardown failed keeps its mounts. + /// the export busy, and, for a clone, `nvx::guest::clone::Applied::teardown`, which removes + /// the runtime's binds that the application made on the root first, since they keep the root's + /// mount busy too. A root whose teardown failed keeps its mounts. fn teardown(&mut self) -> Result<(), String> { self.export = None; match self.root.take() { - Some(root) => root.teardown().map_err(|error| error.to_string()), + Some(root) => root.teardown(), None => Ok(()), } } - /// Runs `nvx::guest::clone::apply`, and keeps the root that it assembled. + /// Runs `nvx::guest::clone::apply`, and keeps the root and the runtime's binds that it made. #[cfg(target_arch = "x86_64")] fn apply( &mut self, @@ -1266,8 +1325,9 @@ impl RootOperations for LibraryRoot { template: &RootTemplate, deadline: Option, ) -> Result, ApplyError> { - let (root, identity) = clone::apply(record, template, deadline)?.into_parts(); - self.root = Some(root); + let applied = clone::apply(record, template, deadline)?; + let identity = applied.identity().cloned(); + self.root = Some(Assembled::Clone(applied)); Ok(identity) } } @@ -1275,7 +1335,7 @@ impl RootOperations for LibraryRoot { impl EntryOperations for LibraryRoot { /// Runs `Entry::prepare` on the assembled root. fn prepare(&mut self, entrance: &Entrance) -> Result { - let Some(root) = &self.root else { + let Some(root) = self.root.as_ref().map(Assembled::root) else { let missing = io::Error::new(io::ErrorKind::NotFound, "the worker holds no root"); return Err(entry::Error::Root(missing)); }; diff --git a/tests/runtime-adapter/src/worker/activation.rs b/tests/runtime-adapter/src/worker/activation.rs index 13608df8e..9d9a0e87d 100644 --- a/tests/runtime-adapter/src/worker/activation.rs +++ b/tests/runtime-adapter/src/worker/activation.rs @@ -74,14 +74,21 @@ pub const ACTIVATION_PROBE: &str = "activation"; /// than the repaired clone has; /// - [`ACTIVATION_ROOT`](status::ACTIVATION_ROOT): the root's assembly failed, the scratch's upper /// is not fresh among its causes, or the kernel did not reread the partition table of the -/// record's GPT disk before it, as [`ApplyError::Assembly`] and [`ApplyError::Gpt`] tell, or the -/// application failed otherwise; +/// record's GPT disk before it, as [`ApplyError::Assembly`] and [`ApplyError::Gpt`] tell, or +/// the application failed otherwise, as this build of the adapter does not know; /// - [`ACTIVATION_WORKLOAD`](status::ACTIVATION_WORKLOAD): the image refuses the record's -/// workload identity, as [`ApplyError::Account`] tells; +/// workload identity, as [`ApplyError::Account`] tells, the machine ID that the record asks the +/// guest to generate cannot be written, as [`ApplyError::MachineId`] tells, or the record's +/// hostname or that machine ID cannot be set for every execution, as [`ApplyError::View`] +/// tells; /// - [`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. /// -/// A cause that this build of the adapter does not know ends it with `ACTIVATION_PROTOCOL`. +/// 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`. pub fn failure_status(failure: &ActivationFailure) -> u8 { match failure.cause() { ActivationCause::Expired => status::ACTIVATION_DEADLINE, @@ -92,9 +99,11 @@ pub fn failure_status(failure: &ActivationFailure) -> u8 { ActivationCause::Record(_) | ActivationCause::Cold => status::ACTIVATION_RECORD, ActivationCause::Resources { .. } => status::ACTIVATION_RESOURCES, ActivationCause::Failed(error) => match error.downcast_ref::() { - Some(ApplyError::Account { .. }) => status::ACTIVATION_WORKLOAD, - // The root's assembly, the reread of a GPT disk's partition table, and any other - // failure of the application. + Some(ApplyError::Gpt(_) | ApplyError::Assembly(_)) => status::ACTIVATION_ROOT, + Some( + ApplyError::Account { .. } | ApplyError::MachineId { .. } | ApplyError::View { .. }, + ) => status::ACTIVATION_WORKLOAD, + // A failure of the application that this build does not know. _ => status::ACTIVATION_ROOT, }, ActivationCause::Completion | ActivationCause::Start => status::ACTIVATION_ORDER, @@ -127,6 +136,8 @@ struct Applied { result: Result, ApplyError>, /// The number of mappings that the record announces. mappings: u32, + /// The hostname of every execution: the record's. + hostname: Vec, /// The number of retries of the Activate that joined the application, which its completion /// answers too. joined: usize, @@ -273,17 +284,20 @@ impl CloneState { /// Applies `record`, the record of the Activate that the activation accepted, through /// `operations`, with the template's inputs, by the activation's deadline, and keeps the - /// application's result until the worker [settles](Self::settle) the activation with it. + /// application's result, with the record's hostname and the number of its mappings, until the + /// worker [settles](Self::settle) the activation with it. fn apply(&mut self, operations: &mut dyn RootOperations, record: &ActivationRecord) { // The application's waits for the devices end by the activation's deadline, past which // the completion fails the activation, whatever the application's result. let deadline = self.activation.deadline(); let result = operations.apply(record, &self.template, deadline); + let configuration = record.configuration(); // A record announces at most `nvx::activation::MAX_MAPPINGS` mappings, which fit a `u32`. - let mappings = record.configuration().mappings as u32; + let mappings = configuration.mappings as u32; self.applied = Some(Applied { result, mappings, + hostname: configuration.workload.hostname.clone(), joined: 0, }); } @@ -293,8 +307,8 @@ impl CloneState { /// returns the worker's step: `Ready`, which reports the activation, for the `Activate` and /// for each retry that joined its application, once the clone is ready, after which /// `executions` run in the cgroups that the template's bootstrap prepared, as the record's - /// workload identity, if it has one, and the worker's order awaits the record's mappings; or - /// the end, once the activation failed. + /// workload identity, if it has one, with the record's hostname, and the worker's order awaits + /// the record's mappings; or the end, once the activation failed. pub(super) fn settle( &mut self, operations: &mut dyn RootOperations, @@ -311,7 +325,8 @@ impl CloneState { let identity = identity.map(|identity| (identity.uid(), identity.gid())); // The executions' listeners are published, the entry's default, which refuses // the most, as a root configuration that names no publication has them. - let sandbox = Sandbox::new(identity, Publication::Published); + let sandbox = + Sandbox::new(identity, Publication::Published).with_hostname(applied.hostname); executions.run_in(operations.cgroups(), sandbox); self.order = Some(Order::new(applied.mappings)); Step::Continue(vec![ready(activation); 1 + applied.joined]) @@ -415,6 +430,7 @@ fn unactivated(failure: &ActivationFailure) -> Worked { mod tests { use std::io; use std::num::NonZeroU64; + use std::path::PathBuf; use nvx::activation::{ Configuration, ExpectedResources, Identity, Image, MachineIdMode, OverlayMode, @@ -423,7 +439,7 @@ mod tests { }; use nvx::guest::clone::AdmissionFence; use nvx::guest::entry; - use nvx::guest::root::{Error as RootError, Role}; + use nvx::guest::root::{self, Assembly, Device, Error as RootError, Role}; use nvx::protocol::root::DISTRO_MMIO_BASE; use super::*; @@ -554,12 +570,39 @@ mod tests { }); let root = activating().complete(now(), Err(reread.into())); cases.push((failed(root), status::ACTIVATION_ROOT)); + // An assembly refuses a relative runtime directory before anything else. + let relative = Assembly { + runtime: PathBuf::from("run/nvx"), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: None, + }, + image: root::Image::Erofs { uuid: None }, + upper: root::Upper::Tmpfs, + overlay: root::OverlayMode::default(), + timeout: Duration::ZERO, + }; + let assembly = ApplyError::Assembly(root::assemble(&relative).unwrap_err()); + let root = activating().complete(now(), Err(assembly.into())); + cases.push((failed(root), status::ACTIVATION_ROOT)); let refused = ApplyError::Account { error: entry::Error::Root(io::Error::other("no account")), teardown: None, }; let workload = activating().complete(now(), Err(refused.into())); cases.push((failed(workload), status::ACTIVATION_WORKLOAD)); + let generated = ApplyError::MachineId { + error: io::ErrorKind::AlreadyExists.into(), + teardown: None, + }; + let workload = activating().complete(now(), Err(generated.into())); + cases.push((failed(workload), status::ACTIVATION_WORKLOAD)); + let view = ApplyError::View { + error: entry::Error::Hostname, + teardown: None, + }; + let workload = activating().complete(now(), Err(view.into())); + cases.push((failed(workload), status::ACTIVATION_WORKLOAD)); let other = activating().complete(now(), Err("another failure of the application".into())); cases.push((failed(other), status::ACTIVATION_ROOT)); // The worker's own order. diff --git a/tests/runtime-adapter/src/worker/executions.rs b/tests/runtime-adapter/src/worker/executions.rs index 8a4627263..dbe4fedb5 100644 --- a/tests/runtime-adapter/src/worker/executions.rs +++ b/tests/runtime-adapter/src/worker/executions.rs @@ -104,8 +104,8 @@ pub(super) struct Sandbox { impl Sandbox { /// Returns the sandbox whose workload identity is `identity`, if it has one, and whose /// listeners are published as `publication` says. Its executions take the entry's default - /// hostname, [`DEFAULT_HOSTNAME`](entry::DEFAULT_HOSTNAME), as nothing configures another: - /// this is the one place that names their hostname. + /// hostname, [`DEFAULT_HOSTNAME`](entry::DEFAULT_HOSTNAME), since a root configuration names + /// no other, unless `with_hostname` names theirs, as a clone's record does. pub(super) fn new(identity: Option<(u32, u32)>, publication: Publication) -> Self { Self { hostname: entry::DEFAULT_HOSTNAME.as_bytes().to_vec(), @@ -113,6 +113,13 @@ impl Sandbox { publication, } } + + /// Gives the sandbox's executions the hostname `hostname`, which a clone's record names. + #[cfg(target_arch = "x86_64")] + pub(super) fn with_hostname(mut self, hostname: Vec) -> Self { + self.hostname = hostname; + self + } } /// One of the worker's executions: the binding of its runner to its program, and either the diff --git a/tests/runtime-adapter/tests/clone_start.rs b/tests/runtime-adapter/tests/clone_start.rs index 8a7bcc2a2..94dcc6a46 100644 --- a/tests/runtime-adapter/tests/clone_start.rs +++ b/tests/runtime-adapter/tests/clone_start.rs @@ -11,7 +11,7 @@ use std::num::NonZeroU64; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; -use nvx::activation::StoreGeneration; +use nvx::activation::{MachineIdMode, StoreGeneration}; use nvx::host::artifacts::Artifacts; use nvx::host::template::{HostClock, HostTime, Store}; use nvx::protocol::root::ImageFormat; @@ -133,6 +133,21 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { with(&["--upper", "ram", "--first-partition-bound", "0"]), "--first-partition-bound takes a positive number of bytes, not \"0\"", ), + ( + with(&["--upper", "ram", "--machine-id", "random"]), + "--machine-id takes image or generated, not \"random\"", + ), + ( + with(&[ + "--upper", + "ram", + "--machine-id", + "image", + "--machine-id", + "generated", + ]), + "unexpected option \"--machine-id\" of start", + ), ( with(&["--upper", "ram", "--template", "other"]), "unexpected option \"--template\" of start", @@ -149,6 +164,10 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { vec!["probe", "store", "memory", "--template", "templates"], "unexpected option \"--template\" of probe", ), + ( + vec!["launch", "store", "--machine-id", "generated"], + "unexpected option \"--machine-id\" of launch", + ), ( vec!["create", "store", "--generation", "1"], "unexpected option \"--generation\" of create", @@ -350,6 +369,7 @@ fn a_clone_s_record_names_its_template_store_and_generation_beside_its_plan() { overlay_mode: None, scratch_journal: None, first_partition_bound: None, + machine_id: MachineIdMode::Image, }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); @@ -438,6 +458,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { overlay_mode: None, scratch_journal: None, first_partition_bound: None, + machine_id: MachineIdMode::Image, }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); @@ -455,10 +476,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { nvx::host::sandbox::distro::DISTRO_POLICY_REVISION ); assert_eq!(clone.workload.hostname, b"runtime-adapter"); - assert_eq!( - clone.workload.machine_id, - nvx::activation::MachineIdMode::Image - ); + assert_eq!(clone.workload.machine_id, MachineIdMode::Image); assert_eq!(clone.workload.identity, None); assert_eq!(clone.overlay, nvx::activation::OverlayMode::TrustedLowers); assert_eq!(clone.journal, nvx::activation::Journal::Allowed); @@ -470,6 +488,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { overlay_mode: Some(runtime_adapter::root::OverlayMode::UntrustedLowers), scratch_journal: Some(runtime_adapter::root::JournalPolicy::Forbidden), first_partition_bound: Some(4096), + machine_id: MachineIdMode::Generated, ..request.clone() }; let clone = named @@ -482,6 +501,8 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { gid: 65533 }) ); + assert_eq!(clone.workload.hostname, b"runtime-adapter"); + assert_eq!(clone.workload.machine_id, MachineIdMode::Generated); assert_eq!(clone.overlay, nvx::activation::OverlayMode::UntrustedLowers); assert_eq!(clone.journal, nvx::activation::Journal::Forbidden); assert_eq!(clone.first_partition_bound, 4096); diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index adb047074..e26d44a09 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1124,6 +1124,13 @@ enum Operation { /// A clone's reread of its GPT disk's partition table, before its assembly. #[cfg(target_arch = "x86_64")] Reread, + /// A clone's generation of the machine ID that its record asks for, once its root's account + /// was checked. + #[cfg(target_arch = "x86_64")] + MachineId, + /// 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. MountExport, /// A probe. @@ -1342,7 +1349,10 @@ impl RootOperations for FakeRoot { /// [`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. + /// [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`]. #[cfg(target_arch = "x86_64")] fn apply( &mut self, @@ -1350,6 +1360,7 @@ impl RootOperations for FakeRoot { template: &nvx::guest::clone::RootTemplate, deadline: Option, ) -> Result, nvx::guest::clone::ApplyError> { + use nvx::activation::MachineIdMode; use nvx::guest::clone::ApplyError; use nvx::guest::root::{DeviceStep, Error, Role}; @@ -1378,17 +1389,31 @@ impl RootOperations for FakeRoot { None => self.assembly, }; thread::sleep(assembly); - let Some(workload) = configuration.workload.identity else { - return Ok(None); - }; - let identity = entry::Identity::new(workload.uid, workload.gid); - match entry::Entry::new(accounts(), identity.clone()).prepare() { - Ok(_) => Ok(Some(identity)), - Err(error) => Err(ApplyError::Account { + let workload = &configuration.workload; + let identity = workload + .identity + .map(|identity| entry::Identity::new(identity.uid, identity.gid)); + if let Some(identity) = &identity + && let Err(error) = entry::Entry::new(accounts(), identity.clone()).prepare() + { + return Err(ApplyError::Account { error, teardown: None, - }), + }); } + if workload.machine_id == MachineIdMode::Generated { + self.result(Operation::MachineId) + .map_err(|problem| ApplyError::MachineId { + error: io::Error::other(problem), + teardown: None, + })?; + } + self.result(Operation::View) + .map_err(|problem| ApplyError::View { + error: entry::Error::Root(io::Error::other(problem)), + teardown: None, + })?; + Ok(identity) } } @@ -4359,7 +4384,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, before Ready. + // it lacks, a machine ID that it cannot generate, and a hostname or machine ID that it + // cannot set for every execution, before Ready. let gpt = changed(&clone, |configuration| { configuration.image = activation::Image::Gpt { binding: activation::Identity::from_bytes([0x11; 32]), @@ -4367,6 +4393,9 @@ mod clones { first_partition_bound: 1 << 20, }; }); + let generated = changed(&clone, |configuration| { + configuration.workload.machine_id = MachineIdMode::Generated; + }); let cases = [ ( gpt, @@ -4383,6 +4412,16 @@ mod clones { FakeRoot::booting(&layout()), status::ACTIVATION_WORKLOAD, ), + ( + generated.clone(), + FakeRoot::failing(Operation::MachineId, "a machine ID exists already"), + status::ACTIVATION_WORKLOAD, + ), + ( + generated, + FakeRoot::failing(Operation::View, "the image's machine ID is a directory"), + status::ACTIVATION_WORKLOAD, + ), ]; for (record, root, expected) in cases { let (mut broker, working) = start_clone(root, ACTIVATION_TIMEOUT); @@ -4692,10 +4731,20 @@ mod clones { } } + /// Returns the entrance of a clone's execution as `identity`, with the hostname `hostname`, + /// its record's, and published listeners. + fn cloned(identity: entry::Identity, hostname: &[u8]) -> Entrance { + Entrance { + hostname: hostname.to_vec(), + ..entrance(identity) + } + } + #[test] - fn a_clone_s_executions_run_as_the_workload_identity_of_its_record() { + fn a_clone_s_executions_run_as_the_workload_identity_and_with_the_hostname_of_its_record() { // The record's workload identity 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 runs as it, and a start that names an identity is refused. Every + // execution's entry takes the record's hostname. let refused = LaunchFailure::new(0, Errno::EINVAL as i32); let workload = exec::Identity::new(WORKLOAD.0, WORKLOAD.1); let mut host = Host::activated(FakeRoot::booting(&layout()), &record(1, Some(WORKLOAD))); @@ -4708,10 +4757,14 @@ mod clones { let (root, worked) = host.stop(); assert_eq!(worked.unwrap(), Worked::Stopped); let identity = entry::Identity::new(WORKLOAD.0, WORKLOAD.1); - assert_eq!(entrances(&root), [entrance(identity.clone())]); + assert_eq!(entrances(&root), [cloned(identity.clone(), b"clone")]); - // Without one, each start names its own. - let mut host = Host::activated(FakeRoot::booting(&layout()), &record(1, None)); + // Without one, each start names its own, and takes the hostname of a record that names + // another. + let renamed = changed(&record(1, None), |configuration| { + configuration.workload.hostname = b"another-clone".to_vec(); + }); + let mut host = Host::activated(FakeRoot::booting(&layout()), &renamed); host.start(start_of(1, shell("exit 0")), Vec::new()); let named = start_of(2, shell("exit 0")).with_identity(workload); host.start(named, Vec::new()); @@ -4720,7 +4773,7 @@ mod clones { check_lossless(&host, 2, Status::Exited(0)); let (root, worked) = host.stop(); assert_eq!(worked.unwrap(), Worked::Stopped); - assert_eq!(entrances(&root), [entrance(identity)]); + assert_eq!(entrances(&root), [cloned(identity, b"another-clone")]); } /// Returns the facts of the guest's `report`. diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index 9529ef106..22fa07a7e 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -35,6 +35,14 @@ //! an execution. The sandbox records the failure until the release of its lease. //! - A clone refuses a scratch image whose upper is not fresh, as an earlier clone left it, and //! ends before it is ready. +//! - Clones at once each keep a root of their own, and every execution of a clone sees its +//! hostname and its machine ID: one that its guest generated, distinct in each clone, the same +//! in each of the clone's executions, which a read-only bind holds at `/etc/machine-id`, or the +//! image's own, which the guest leaves alone, so that what an execution writes there, the +//! clone's later executions see. +//! - A clone whose generated machine ID the guest cannot bind, since its image holds a regular +//! file `etc`, ends before it is ready, with the status of the workload's view, while a clone of +//! the image that keeps the image's own machine ID, which needs no bind, starts. //! - A clone runs its executions as its record's workload identity, once it is ready: the repair //! acknowledged the restore before the clone took its reset, the host's first message came //! after that, and the clone's Ready, its start, and its first execution came in that order. @@ -85,7 +93,7 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant, SystemTime}; -use nvx::activation::{ReadyActivation, StoreGeneration}; +use nvx::activation::{MachineIdMode, ReadyActivation, StoreGeneration}; use nvx::exec::{Key, Limits, Timers, Window}; use nvx::host::artifacts::Artifacts; use nvx::host::platform::Transport; @@ -102,7 +110,7 @@ use nvx::protocol::status; use nvx::uuid::Uuid; use runtime_adapter::GUEST_BUILD; use runtime_adapter::capture::DEFAULT_SCRATCH_LENGTH; -use runtime_adapter::clone::{HOST_TIME_VARIABLE, Request, host_time_text}; +use runtime_adapter::clone::{CLONE_HOSTNAME, HOST_TIME_VARIABLE, Request, host_time_text}; use runtime_adapter::exec::{HostMessage, Start}; use runtime_adapter::frame::Message; use runtime_adapter::hook::{Action, STEP_HOOK, Step, StepHook}; @@ -142,6 +150,26 @@ const HOUR_NS: u64 = 3_600_000_000_000; /// The error number with which the guest refuses an execution that names an identity in a /// sandbox that has its own: `EINVAL`. const EINVAL: i32 = 22; +/// The machine ID that each execution of [`VIEW`] writes to a clone's `/etc/machine-id`. +const WRITTEN_MACHINE_ID: &str = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"; +/// The program of the executions that show a clone's view of its root, `/bin/sh -c VIEW sh +/// MARKER ID`, which prints, a line each: the hostname, `hostname NAME`; the machine ID at +/// `/etc/machine-id`, or `absent`, `machine-id ID`; the options of each mount there, `bind +/// OPTIONS`; whether a write of ID there succeeded, `write written`, or not, `write refused`; and +/// what the root's `/marker` holds, or `absent`, `marker TEXT`. Then it writes MARKER to `/marker`. +const VIEW: &str = r#"echo "hostname $(uname -n)" +echo "machine-id $(cat /etc/machine-id 2>/dev/null || echo absent)" +while read -r _ _ _ _ point options _; do + if [ "$point" = /etc/machine-id ]; then echo "bind $options"; fi +done < /proc/self/mountinfo +if { printf '%s\n' "$2" > /etc/machine-id; } 2>/dev/null; then + echo "write written" +else + echo "write refused" +fi +echo "marker $(cat /marker 2>/dev/null || echo absent)" +printf '%s' "$1" > /marker +"#; /// The placeholder of the template of the scenarios of GPT disks: a disk of two layers, whose /// partitions start at multiples of 1 MiB. const PLACEHOLDER: Disk = Disk { @@ -154,6 +182,7 @@ const PLACEHOLDER: Disk = Disk { alignment: 2048, first_size: 4096, first: b"\xffthe placeholder's first partition", + top: &[], }; /// A clone's GPT disk of one layer, whose partitions start at multiples of 2 MiB, with a larger /// first partition than the placeholder's: its layer's partition starts where none of the @@ -167,6 +196,7 @@ const ONE_LAYER: Disk = Disk { alignment: 4096, first_size: 8192, first: b"\xfe\0one layer\x80", + top: &[], }; /// A clone's GPT disk of three layers, whose partitions start at multiples of 640 KiB, with the /// largest first partition: its layers' partitions start where none of the placeholder's do, nor @@ -182,12 +212,26 @@ const THREE_LAYERS: Disk = Disk { alignment: 1280, first_size: 12288, first: b"\xfd\tthree layers\xc3", + top: &[], +}; +/// A clone's GPT disk of one layer whose top directory holds a regular file `etc`, below which +/// nothing can be bound at `/etc/machine-id`. +const ETC_FILE: Disk = Disk { + guid: "c3d5e7f9-1a2b-4c3d-8e4f-5a6b7c8d9e00", + partitions: &[ + "c3d5e7f9-1a2b-4c3d-8e4f-5a6b7c8d9e01", + "c3d5e7f9-1a2b-4c3d-8e4f-5a6b7c8d9e02", + ], + alignment: 2048, + first_size: 4096, + first: b"\xfcetc is a file", + top: &[("etc", b"not a directory\n")], }; /// A GPT disk of the scenarios, of the length of the platform's image, which /// [`Template::of_disk`] captures as the template's placeholder, or [`Template::disk`] writes for /// a clone. Its partitions follow each other, each at a multiple of its alignment: its first -/// partition, then a layer, an empty [`small_layer`], in each of the others. +/// partition, then a layer, a [`small_layer`], in each of the others, empty but the top one. struct Disk { /// The disk's GUID, which the guest expects of it. guid: &'static str, @@ -201,20 +245,27 @@ struct Disk { /// The bytes that its first partition holds, before NUL bytes: those that the guest returns, /// which end in neither a NUL byte nor whitespace. first: &'static [u8], + /// The regular files in the top directory of its top layer, each a name and its bytes. + top: &'static [(&'static str, &'static [u8])], } impl Disk { /// Writes the disk at `path`, of `length` bytes, a file that no one else may change. fn write(&self, path: &Path, length: u64) { - let layer = small_layer(&[], &[]); + let (layer, top) = (small_layer(&[], &[]), small_layer(self.top, &[])); let partitions = self .partitions .iter() .enumerate() .map(|(index, guid)| { - let (size, contents) = match index { - 0 => (self.first_size, self.first), - _ => (u64::try_from(layer.len()).unwrap(), layer.as_slice()), + let contents = match index { + 0 => self.first, + _ if index + 1 == self.partitions.len() => top.as_slice(), + _ => layer.as_slice(), + }; + let size = match index { + 0 => self.first_size, + _ => u64::try_from(contents.len()).unwrap(), }; Partition { guid, @@ -627,6 +678,56 @@ impl Cloned { fn scratch(&self) -> PathBuf { scratch_image(&self.sandbox) } + + /// Runs [`VIEW`] in the clone's root, as user ID 0, which writes `marker` to the root's + /// `/marker` last, and returns what it found. + fn view(&mut self, marker: &str) -> View { + let program = ["/bin/sh", "-c", VIEW, "sh", marker, WRITTEN_MACHINE_ID]; + let ran = self.exec(&[&["--identity", "0:0", "--"][..], &program].concat()); + let facts = ["launch", "status", "stderr"].map(|key| &ran[&format!("exec.1.{key}")]); + assert_eq!(facts, ["started", "exit 0", ""], "{ran:?}"); + // The host program escapes each line feed, and each backslash, which none of the lines + // holds. + let lines = ran["exec.1.stdout"] + .strip_suffix("\\n") + .filter(|lines| !lines.contains("\\\\")) + .unwrap_or_else(|| panic!("{ran:?}")) + .split("\\n") + .collect::>(); + let value = |line: &str, key: &str| { + let value = line + .strip_prefix(key) + .and_then(|rest| rest.strip_prefix(' ')); + value + .unwrap_or_else(|| panic!("{key} in {ran:?}")) + .to_owned() + }; + let [hostname, machine_id, binds @ .., write, marker] = lines.as_slice() else { + panic!("{ran:?}"); + }; + View { + hostname: value(hostname, "hostname"), + machine_id: value(machine_id, "machine-id"), + binds: binds.iter().map(|line| value(line, "bind")).collect(), + write: value(write, "write"), + marker: value(marker, "marker"), + } + } +} + +/// What an execution of [`VIEW`] found in a clone's root. +#[derive(Clone, Debug, PartialEq, Eq)] +struct View { + /// The hostname. + hostname: String, + /// The machine ID at `/etc/machine-id`, or `absent`. + machine_id: String, + /// The options of each mount at `/etc/machine-id`. + binds: Vec, + /// Whether a write of [`WRITTEN_MACHINE_ID`] at `/etc/machine-id` was `written` or `refused`. + write: String, + /// What the root's `/marker` held, or `absent`. + marker: String, } /// Returns the scratch image of a clone over one, in the clone's sandbox, `sandbox`, in a @@ -827,6 +928,23 @@ fn assert_overlay(facts: &Printed, upper: Upper<'_>, trusted: bool, identifier: ); } +/// Checks that an execution of [`VIEW`] found a machine ID that the clone's guest generated: 32 +/// lowercase hexadecimal digits, which one bind holds at `/etc/machine-id`, read-only, without +/// set-user-ID programs or devices, so that the execution's write there was refused. +fn assert_generated(view: &View) { + let id = &view.machine_id; + let digit = |byte: u8| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte); + assert!(id.len() == 32 && id.bytes().all(digit), "{view:?}"); + let [options] = view.binds.as_slice() else { + panic!("one bind holds the machine ID: {view:?}"); + }; + let options = options.split(',').collect::>(); + for flag in ["ro", "nosuid", "nodev"] { + assert!(options.contains(&flag), "{flag} in {view:?}"); + } + assert_eq!(view.write, "refused", "{view:?}"); +} + /// Checks the events of a clone's activation that its `activation` probe found: the repair /// acknowledged the restore, and the clone took its reset, the host's first message, its Ready, /// and its start, in that order, after the acknowledgement. Returns when each came, in @@ -953,6 +1071,7 @@ fn refuses_early(template: &Template, early: Message) { overlay_mode: None, scratch_journal: None, first_partition_bound: None, + machine_id: MachineIdMode::Image, }; let plan = request.plan().unwrap(); let staged = Staged::read(&sandbox.store).unwrap().unwrap(); @@ -1416,6 +1535,106 @@ fn a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used() { assert_payload(&template, false); } +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn clones_at_once_keep_their_own_uppers_and_machine_ids() { + let template = Template::create("cl-view"); + // Three clones at once: two whose guests generate their machine IDs, over a scratch image and + // in RAM, and one in RAM that keeps the image's own. + let generated = ["--machine-id", "generated"]; + let starts = [ + ("cl-s", Upper::Scratch, &generated[..]), + ("cl-r", Upper::Ram, &generated[..]), + ("cl-i", Upper::Ram, &[][..]), + ]; + let mut clones = starts.map(|(name, upper, options)| { + let start = StartOf { + upper, + options, + ..StartOf::default() + }; + let cloned = template.start(name, start, 0); + cloned.assert_started(); + (name, cloned) + }); + assert_eq!(template.leases().len(), clones.len()); + + // Each clone's first execution sees the clone's hostname, and a root of the clone's own, + // without the marker that the executions before it left in the other clones' roots. + let firsts = clones.each_mut().map(|(name, cloned)| { + let view = cloned.view(name); + let seen = [view.hostname.as_str(), view.marker.as_str()]; + assert_eq!(seen, [CLONE_HOSTNAME, "absent"], "{view:?}"); + view + }); + let [scratch, ram, image] = &firsts; + assert_generated(scratch); + assert_generated(ram); + assert_ne!(scratch.machine_id, ram.machine_id); + // The guest leaves the image's own machine ID alone: the Ubuntu image has none, and an + // execution may write one. + let image_view = ( + image.machine_id.as_str(), + image.binds.len(), + image.write.as_str(), + ); + assert_eq!(image_view, ("absent", 0, "written"), "{image:?}"); + + // A later execution of each clone sees the machine ID that its first saw, or, with the image's + // own, the one that its first wrote, and its first's marker. + for ((name, cloned), first) in clones.iter_mut().zip(&firsts) { + let machine_id = match first.binds.as_slice() { + [] => WRITTEN_MACHINE_ID, + _ => first.machine_id.as_str(), + }; + let expected = View { + machine_id: machine_id.to_owned(), + marker: (*name).to_owned(), + ..first.clone() + }; + assert_eq!(cloned.view(name), expected); + } + for (_, cloned) in &mut clones { + cloned.stop(); + } + assert!(template.leases().is_empty()); + assert_payload(&template, false); +} + +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn a_clone_whose_view_cannot_be_set_ends_before_it_is_ready() { + // The template of the EROFS image serves clones of a GPT disk of its length too. + let template = Template::create("cl-noview"); + let disk = template.disk("etc.gpt", &ETC_FILE); + let image = Some(Image::Gpt(&ETC_FILE, &disk)); + + // With the image's own machine ID, the guest binds nothing in the root, so a clone of a disk + // whose top layer holds a regular file `etc` starts. + let start = StartOf { + image, + ..StartOf::default() + }; + let mut cloned = template.start("cl-n", start, 0); + cloned.assert_started(); + let facts = cloned.probe(&["devices", "mounts", "contents", "first-partition"]); + assert_gpt_root(&facts, &ETC_FILE, Upper::Ram); + cloned.stop(); + + // A generated machine ID, which the guest binds at `/etc/machine-id`, cannot be bound below a + // file, so a clone of the disk that asks for one ends before it is ready, with the status of + // the workload's view, which attributes the failure to the request's invalid settings. + let options = ["--machine-id", "generated"]; + let start = StartOf { + image, + options: &options, + ..StartOf::default() + }; + let ended = template.start("cl-n", start, CLONE_FAILED); + ended.assert_unactivated(&template, "invalid-settings", status::ACTIVATION_WORKLOAD); + assert_payload(&template, false); +} + #[test] #[ignore = "boots VMs; see the module documentation for what they need"] fn a_clone_runs_its_executions_as_its_workload_identity_once_it_is_ready() {