diff --git a/boulder/src/build/root.rs b/boulder/src/build/root.rs index 2e02230dc..71af6b252 100644 --- a/boulder/src/build/root.rs +++ b/boulder/src/build/root.rs @@ -29,7 +29,7 @@ pub fn populate( let installation = Installation::open(&builder.env.moss_dir, None)?; let mut moss_client = moss::Client::builder("boulder", installation) .repositories(repositories) - .ephemeral(rootfs) + .ephemeral(rootfs, moss::fstree::Format::Native) .build()?; if update_repos { diff --git a/crates/erofs/src/lib.rs b/crates/erofs/src/lib.rs index cf01b70e8..9ebb2c5ac 100644 --- a/crates/erofs/src/lib.rs +++ b/crates/erofs/src/lib.rs @@ -44,7 +44,7 @@ const ST_IFLNK: u16 = 0o120_000; /// a [`vfs::Tree`] of [`StonePayloadLayoutRecord`] entries. #[derive(Debug, Clone, Copy, Default)] pub struct MetaImageWriter { - xattr_namespace: XattrNamespace, + pub xattr_namespace: XattrNamespace, } impl MetaImageWriter { diff --git a/crates/vfs/src/tree/mod.rs b/crates/vfs/src/tree/mod.rs index e996dd7d6..52d79072b 100644 --- a/crates/vfs/src/tree/mod.rs +++ b/crates/vfs/src/tree/mod.rs @@ -170,9 +170,14 @@ impl Tree { } } + /// Return structured view beginning at the provided `path` + pub fn structured_from(&self, path: &str) -> Option> { + self.resolve_node(path).map(|root| self.structured_children(root)) + } + /// Return structured view beginning at `/` pub fn structured(&self) -> Option> { - self.resolve_node("/").map(|root| self.structured_children(root)) + self.structured_from("/") } /// For the given node, recursively convert to Element::Directory of Child diff --git a/moss/src/cli/install.rs b/moss/src/cli/install.rs index 08bec59a6..319b469d0 100644 --- a/moss/src/cli/install.rs +++ b/moss/src/cli/install.rs @@ -34,6 +34,15 @@ pub struct Command { /// This operation won't be captured as a new state #[arg(value_name = "dir", long = "to")] blit_target: Option, + + /// Fstree format used when `--to` is supplied + #[arg( + value_name = "format", + long = "to-format", + default_value = "native", + requires("blit_target") + )] + blit_target_format: super::FstreeFormatArg, } /// Handle execution of `moss install` @@ -50,7 +59,7 @@ pub fn handle(args: &ArgMatches, installation: Installation) -> Result<(), Error // Make ephemeral if a blit target was provided if let Some(blit_target) = command.blit_target { - client = client.ephemeral(blit_target)?; + client = client.ephemeral(blit_target, command.blit_target_format.into())?; } client.install(&pkgs, yes, simulate)?; diff --git a/moss/src/cli/mod.rs b/moss/src/cli/mod.rs index 18ff22933..9481d33e7 100644 --- a/moss/src/cli/mod.rs +++ b/moss/src/cli/mod.rs @@ -3,14 +3,14 @@ use std::{env, io, path::Path, path::PathBuf}; -use clap::{Arg, ArgAction, Command}; +use clap::{Arg, ArgAction, Command, ValueEnum}; use clap_complete::{ generate_to, shells::{Bash, Fish, Zsh}, }; use clap_mangen::Man; use fs_err as fs; -use moss::{Installation, installation}; +use moss::{Installation, fstree, installation}; use thiserror::Error; use tracing_common::{self, logging::LogConfig, logging::init_log_with_config}; use tui::Styled; @@ -342,3 +342,18 @@ pub enum Error { #[error("I/O error")] Io(#[from] io::Error), } + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum FstreeFormatArg { + Native, + Overlayimg, +} + +impl From for fstree::Format { + fn from(value: FstreeFormatArg) -> Self { + match value { + FstreeFormatArg::Native => fstree::Format::Native, + FstreeFormatArg::Overlayimg => fstree::Format::Overlayimg, + } + } +} diff --git a/moss/src/cli/sync.rs b/moss/src/cli/sync.rs index 0a1e83b81..6194f15f8 100644 --- a/moss/src/cli/sync.rs +++ b/moss/src/cli/sync.rs @@ -30,6 +30,15 @@ pub struct Command { #[arg(value_name = "dir", long = "to")] blit_target: Option, + /// Fstree format used when `--to` is supplied + #[arg( + value_name = "format", + long = "to-format", + default_value = "native", + requires("blit_target") + )] + blit_target_format: super::FstreeFormatArg, + /// Simulate the sync (dry-run) #[arg(long)] dry_run: bool, @@ -58,7 +67,7 @@ pub fn handle(args: &ArgMatches, installation: Installation) -> Result<(), Error // Make ephemeral if a blit target was provided if let Some(blit_target) = command.blit_target { - client_builder = client_builder.ephemeral(blit_target); + client_builder = client_builder.ephemeral(blit_target, command.blit_target_format.into()); } let mut client = client_builder.build()?; diff --git a/moss/src/client/boot.rs b/moss/src/client/boot.rs index 3f28d098b..dc8927923 100644 --- a/moss/src/client/boot.rs +++ b/moss/src/client/boot.rs @@ -22,7 +22,12 @@ use itertools::Itertools; use stone::{StonePayloadLayoutFile, StonePayloadLayoutRecord}; use thiserror::{self, Error}; -use crate::{Installation, State, db, package::Id}; +use crate::{ + Installation, State, db, + fstree::{self, Fstree}, + package::Id, + state, +}; use super::Client; @@ -52,6 +57,12 @@ pub enum Error { #[error("incomplete kernel tree")] IncompleteKernel(String), + + #[error("failed to find archived fstree for state {0}")] + NoArchivedState(state::Id), + + #[error("fstree")] + Fstree(#[from] fstree::DriverError), } /// Simple mapping type for kernel discovery paths, retaining the layout reference @@ -121,7 +132,7 @@ fn layouts_for_state(client: &Client, state: &State) -> Result Result, db::Error> { +fn states_except_new<'a>(client: &'a Client, state: &State) -> Result>, Error> { let states = client .state_db .list_ids()? @@ -138,8 +149,14 @@ fn states_except_new(client: &Client, state: &State) -> Result, db::E .rev() .take(4) .rev() - .filter_map(|(id, _)| client.state_db.get(id).ok()) - .collect::>(); + .map(|(id, _)| { + let state = client.state_db.get(id)?; + let fstree = client + .open_archived_state(&state.id) + .map_err(|_| Error::NoArchivedState(id))?; + Ok(StateEntry::Archived { state, fstree }) + }) + .collect::, Error>>()?; Ok(states) } @@ -181,36 +198,43 @@ pub fn synchronize(client: &Client, state: &State) -> Result<(), Error> { let systemd = Pattern::from_str("lib*/systemd/boot/efi/*.efi")?; let booty_bits = boot_files_from_new_state(&client.installation, &head_layouts, &systemd); - let mut all_states = states_except_new(client, state)?; - // no fun times without a bootloder if booty_bits.is_empty() { return Ok(()); } + let mut all_states = states_except_new(client, state)?; + all_states.push(StateEntry::Active(state.clone())); + + // Ensure all archived fstree are brought up so we can read from them + for entry in all_states.iter_mut() { + if let StateEntry::Archived { fstree, .. } = entry { + fstree.bring_up(fstree::Mutability::ReadOnly)?; + } + } + let global_schema = os_schema_for_root(&root)?; // Grab the entries for the new state let mut all_kernels = vec![]; - all_states.push(state.clone()); for state in all_states.iter() { - let layouts = layouts_for_state(client, state)?; + let layouts = layouts_for_state(client, state.state())?; let local_kernels = kernel_files_from_state(&layouts, &kernel_pattern); let mapped = global_schema.discover_system_kernels(local_kernels.into_iter())?; - all_kernels.push((mapped, state.id)); + all_kernels.push((mapped, state)); } // pipe all of our entries into blsforme let entries = all_kernels .iter() - .flat_map(|&(ref kernels, state_id)| { + .flat_map(|&(ref kernels, state_entry)| { let rootref = &root; let configref = &config; kernels.iter().filter_map(move |k| { - let sysroot = if state.id == state_id { - rootref.clone() - } else { - client.installation.root_path(state_id.to_string()).to_owned() + let state_id = state_entry.state().id; + let sysroot = match state_entry { + StateEntry::Active(_) => rootref.clone(), + StateEntry::Archived { fstree, .. } => fstree.path.clone(), }; if !sysroot.exists() { @@ -250,6 +274,13 @@ pub fn synchronize(client: &Client, state: &State) -> Result<(), Error> { manager.sync(&global_schema)?; } + // And finally bring all archived states back down + for entry in all_states.iter_mut() { + if let StateEntry::Archived { fstree, .. } = entry { + fstree.bring_down()?; + } + } + Ok(()) } @@ -291,3 +322,17 @@ pub fn print_status(installation: &Installation) -> Result<(), Error> { Ok(()) } + +enum StateEntry<'a> { + Active(State), + Archived { state: State, fstree: Fstree<'a> }, +} + +impl StateEntry<'_> { + fn state(&self) -> &State { + match self { + StateEntry::Active(state) => state, + StateEntry::Archived { state, .. } => state, + } + } +} diff --git a/moss/src/client/mod.rs b/moss/src/client/mod.rs index 880d34993..dbd1d5718 100644 --- a/moss/src/client/mod.rs +++ b/moss/src/client/mod.rs @@ -11,18 +11,18 @@ use std::{ borrow::Borrow, io, - os::unix::fs::symlink, path::{Path, PathBuf}, time::{Duration, Instant}, }; -use fs_err as fs; +use fs_err::{self as fs, os::unix::fs::symlink}; use futures_util::{StreamExt, TryStreamExt, stream}; use itertools::Itertools; use nix::{ NixPath, errno::Errno, libc::{AT_FDCWD, RENAME_EXCHANGE, SYS_renameat2, syscall}, + mount::{MntFlags, MsFlags, mount, umount2}, }; use postblit::TriggerScope; use stone::{StoneDecodedPayload, StonePayloadLayoutRecord}; @@ -38,11 +38,14 @@ use self::verify::verify; use crate::{ Installation, Package, Provider, Registry, Signal, State, SystemModel, client::fetch::fetch, - db, environment, fstree, installation, package, + db, environment, + fstree::{self, Fstree}, + installation, package, registry::plugin::{self, Plugin}, repository, runtime, signal, state::{self, Selection}, system_model::{self, LoadedSystemModel}, + util, }; pub use self::extract::extract; @@ -69,7 +72,7 @@ pub struct ClientBuilder { installation: Installation, repositories: Option, system_model_path: Option, - blit_root: Option, + ephemeral: Option<(PathBuf, fstree::Format)>, } impl ClientBuilder { @@ -93,8 +96,8 @@ impl ClientBuilder { /// /// Returns an error on construction if `blit_root` is the same as the installation /// root, since the system client should always be stateful. - pub fn ephemeral(mut self, blit_root: impl Into) -> ClientBuilder { - self.blit_root = Some(blit_root.into()); + pub fn ephemeral(mut self, blit_root: impl Into, fstree_format: fstree::Format) -> ClientBuilder { + self.ephemeral = Some((blit_root.into(), fstree_format)); self } @@ -120,6 +123,15 @@ impl ClientBuilder { let registry = build_registry(&self.installation, &repositories, &install_db, &state_db)?; + // TODO: Add builder (cli) & system model config sources + let fstree_format = None + .or_else(environment::fstree_format) + .unwrap_or(fstree::Format::Native); + let fstree_driver = match fstree_format { + fstree::Format::Native => fstree::AnyDriver::native(), + fstree::Format::Overlayimg => fstree::AnyDriver::overlayimg(), + }; + let mut client = Client { config, installation: self.installation, @@ -128,11 +140,11 @@ impl ClientBuilder { install_db, state_db, layout_db, - scope: Scope::Stateful, + scope: Scope::Stateful { fstree_driver }, }; - if let Some(blit_root) = self.blit_root { - client = client.ephemeral(blit_root)?; + if let Some((blit_root, fstree_format)) = self.ephemeral { + client = client.ephemeral(blit_root, fstree_format)?; } Ok(client) } @@ -166,7 +178,7 @@ impl Client { installation, repositories: None, system_model_path: None, - blit_root: None, + ephemeral: None, } } @@ -208,7 +220,7 @@ impl Client { /// /// Returns an error if `blit_root` is the same as the installation root, /// since the system client should always be stateful. - pub fn ephemeral(self, blit_root: impl Into) -> Result { + pub fn ephemeral(self, blit_root: impl Into, fstree_format: fstree::Format) -> Result { let blit_root = blit_root.into(); if blit_root.exists() && blit_root.canonicalize()? == self.installation.root.canonicalize()? { @@ -216,7 +228,13 @@ impl Client { } Ok(Self { - scope: Scope::Ephemeral { blit_root }, + scope: Scope::Ephemeral { + blit_root, + fstree_driver: match fstree_format { + fstree::Format::Native => fstree::AnyDriver::native(), + fstree::Format::Overlayimg => fstree::AnyDriver::overlayimg(), + }, + }, ..self }) } @@ -355,26 +373,29 @@ impl Client { let staging_dir = self.installation.staging_dir(); // Ensure staging dir exists - if !staging_dir.exists() { - fs::create_dir(&staging_dir)?; - } + util::ensure_dir_exists(&staging_dir)?; - // Move new (archived) state to staging - fs::rename(self.installation.root_path(new.id.to_string()), &staging_dir)?; + // Identify the underlying fstree to the state we want to activate + let mut new_state_fstree = self.open_archived_state(&new.id)?; + // Move new (archived) fstree to staging for promotion logic. + new_state_fstree.move_to(&staging_dir)?; // Promote staging - self.promote_staging()?; + self.promote_staging(&new.id, &mut new_state_fstree)?; // Archive old state self.archive_state(old)?; // Build VFS from new state selections // to build triggers from - let fstree = self.vfs(new.selections.iter().map(|selection| &selection.package))?; + let vfs = self.vfs(new.selections.iter().map(|selection| &selection.package))?; if !skip_triggers { // Run system triggers - Self::apply_triggers(TriggerScope::System(&self.installation, &self.scope), &fstree)?; + Self::apply_triggers( + TriggerScope::System(&self.installation, &self.scope, &new_state_fstree), + &vfs, + )?; } if !skip_boot { @@ -422,19 +443,19 @@ impl Client { let old_state = self.installation.active_state; - let fstree = self.blit_root(selections.iter().map(|s| &s.package))?; + let mut root = self.blit_root(selections.iter().map(|s| &s.package))?; let result = match &self.scope { - Scope::Stateful => { + Scope::Stateful { .. } => { // Add to db let state = self.state_db.add(selections, Some(&summary.to_string()), None)?; - self.apply_stateful_blit(fstree, &state, old_state, system_model)?; + self.apply_stateful_blit(&mut root, &state, old_state, system_model)?; Ok(Some(state)) } - Scope::Ephemeral { blit_root } => { - self.apply_ephemeral_blit(fstree, blit_root, system_model)?; + Scope::Ephemeral { .. } => { + self.apply_ephemeral_blit(&mut root, system_model)?; Ok(None) } @@ -481,6 +502,7 @@ impl Client { ); for (i, trigger) in progress.wrap_iter(triggers.iter()).enumerate() { + progress.set_message(format!("{}", trigger.handler())); trigger.execute()?; info!( @@ -508,29 +530,37 @@ impl Client { pub fn apply_stateful_blit( &self, - fstree: vfs::Tree, + root: &mut BlittedRoot<'_>, state: &State, old_state: Option, system_model: SystemModel, ) -> Result<(), Error> { - record_state_id(&self.installation.staging_dir(), state.id)?; - record_os_release(&self.installation.staging_dir())?; - record_system_model(&self.installation.staging_dir(), system_model)?; + // Ensure fstree is brought up w/ mutability since we will be + // recording supplemental data to it. + root.fstree.bring_up(fstree::Mutability::ReadWrite)?; - create_root_links(&self.installation.isolation_dir())?; + record_state_id(&root.fstree.path, state.id)?; + record_os_release(&root.fstree.path)?; + record_system_model(&root.fstree.path, system_model)?; // The container running triggers expects /etc to exist let root_etc = self.installation.root.join("etc"); fs::create_dir_all(root_etc)?; + // Setup isolation dir + create_root_links(&self.installation.isolation_dir())?; let isolation_etc = self.installation.isolation_dir().join("etc"); fs::create_dir_all(isolation_etc)?; // Apply transaction triggers - Self::apply_triggers(TriggerScope::Transaction(&self.installation, &self.scope), &fstree)?; + Self::apply_triggers(TriggerScope::Transaction(&self.installation, &self.scope), &root.vfs)?; + + // All data is written, we can bring it "back down" in preparation + // of promotion which requires a "down" fstree + root.fstree.bring_down()?; // Staging is only used with [`Scope::Stateful`] - self.promote_staging()?; + self.promote_staging(&state.id, &mut root.fstree)?; // Now we got it staged, we need working rootfs create_root_links(&self.installation.root)?; @@ -540,33 +570,43 @@ impl Client { } // At this point we're allowed to run system triggers - Self::apply_triggers(TriggerScope::System(&self.installation, &self.scope), &fstree)?; + Self::apply_triggers( + TriggerScope::System(&self.installation, &self.scope, &root.fstree), + &root.vfs, + )?; boot::synchronize(self, state)?; Ok(()) } - pub fn apply_ephemeral_blit( - &self, - fstree: vfs::Tree, - blit_root: &Path, - system_model: SystemModel, - ) -> Result<(), Error> { - record_os_release(blit_root)?; - record_system_model(blit_root, system_model)?; + pub fn apply_ephemeral_blit(&self, root: &mut BlittedRoot<'_>, system_model: SystemModel) -> Result<(), Error> { + // Ensure fstree is brought up w/ mutability since we will be + // recording supplemental data to it. + root.fstree.bring_up(fstree::Mutability::ReadWrite)?; + + record_os_release(&root.fstree.path)?; + record_system_model(&root.fstree.path, system_model)?; - create_root_links(blit_root)?; + create_root_links(&root.fstree.path)?; create_root_links(&self.installation.isolation_dir())?; // The container running triggers expects /etc to exist - let etc = blit_root.join("etc"); + let etc = root.fstree.path.join("etc"); fs::create_dir_all(etc)?; // ephemeral tx triggers - Self::apply_triggers(TriggerScope::Transaction(&self.installation, &self.scope), &fstree)?; + Self::apply_triggers(TriggerScope::Transaction(&self.installation, &self.scope), &root.vfs)?; + + // Transition `fstree` to readonly + let applied = root.fstree.change_mutability(fstree::Mutability::ReadOnly)?; + debug_assert!(applied); + // ephemeral system triggers - Self::apply_triggers(TriggerScope::System(&self.installation, &self.scope), &fstree)?; + Self::apply_triggers( + TriggerScope::System(&self.installation, &self.scope, &root.fstree), + &root.vfs, + )?; Ok(()) } @@ -578,21 +618,72 @@ impl Client { /// This is performed using `renameat2` and results in instantly available, atomically updated /// `/usr`. In combination with the mandated "`/usr`` merge" and statelessness approach of /// our project, it provides a unique atomic upgrade strategy. - fn promote_staging(&self) -> Result<(), Error> { + fn promote_staging(&self, state: &state::Id, fstree: &mut Fstree<'_>) -> Result<(), Error> { if self.scope.is_ephemeral() { return Err(Error::EphemeralProhibitedOperation); } - let usr_target = self.installation.root.join("usr"); - let usr_source = self.installation.staging_path("usr"); + let root_usr = self.installation.root.join("usr"); + let staging_usr = self.installation.staging_path("usr"); // Create the target tree - if !usr_target.try_exists()? { - fs::create_dir_all(&usr_target)?; + if !root_usr.try_exists()? { + fs::create_dir_all(&root_usr)?; + } + + match fstree.format() { + // Overlayimg fstrees have a different promotion logic. + // We always archive the fstree up-front so mounts have a stable + // location, ensure mounts are online, and then create a staged + // dir that copies the `.stateID` to a static file (not behind a mount) + // and setup symlinks for the remaining children of `usr/` + format @ fstree::Format::Overlayimg => { + let archive_path = self.state_archive_path(format, state); + // Move fstree (staging) to archive path + fstree.move_to(&archive_path)?; + // Setup stable mounts + fstree.bring_up(fstree::Mutability::ReadOnly)?; + // Recreate staging/usr + fs::create_dir_all(&staging_usr)?; + + let archived_usr = fstree.path.join("usr"); + // Copy .stateID so it can be statically referenced w/out the mount being up + fs::copy(archived_usr.join(".stateID"), staging_usr.join(".stateID"))?; + + // Construct all binds into the mounted usr + let read_dir = fs::read_dir(&archived_usr)?; + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + if ![".stateID", ".", ".."].contains(&name.as_str()) { + let src = entry.path(); + let dest = staging_usr.join(&name); + + // Preserve if its a symlink (lib64, local) + if let Ok(original) = fs::read_link(&src) { + symlink(original, dest)?; + } + // Otherwise bind mount to the archived usr (into the overlay) + else { + fs::create_dir(&dest)?; + mount( + Some(&src.canonicalize()?), + dest.as_path(), + Option::<&str>::None, + MsFlags::MS_BIND, + Option::<&str>::None, + ) + .map_err(io::Error::other)?; + } + } + } + } + // Nothing to do here, we move the native fstree usr/ in the atomic swap + fstree::Format::Native => {} } // Now swap staging with live - atomic_swap(&usr_source, &usr_target).map_err(Error::AtomicSwap)?; + atomic_swap(&staging_usr, &root_usr).map_err(Error::AtomicSwap)?; Ok(()) } @@ -603,16 +694,45 @@ impl Client { return Err(Error::EphemeralProhibitedOperation); } - // After promotion, the old active /usr is now in staging/usr - let usr_target = self.installation.root_path(id.to_string()).join("usr"); - let usr_source = self.installation.staging_path("usr"); - if let Some(parent) = usr_target.parent() - && !parent.exists() + let staged_usr = self.installation.staging_path("usr"); + + // Check if state is already archived (overlayimg). If so, we need + // to make sure we bring down relevant mounts & staging can be safely nuked. + if let Ok(mut fstree) = self.open_archived_state(&id) + && matches!(fstree.format(), fstree::Format::Overlayimg) { - fs::create_dir_all(parent)?; + fstree.bring_down()?; + + if staged_usr.exists() { + // Bring down all binds + let read_dir = fs::read_dir(&staged_usr)?; + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + if ![".stateID", ".", ".."].contains(&name.as_str()) && fs::read_link(entry.path()).is_err() { + umount2(&entry.path(), MntFlags::MNT_DETACH).map_err(io::Error::other)?; + } + } + + // Delete staged usr/, there is nothing to archive here + fs::remove_dir_all(&staged_usr)?; + } + } + // Otherwise this is a native fstree root + // + // These use a backwards compatible activation flow that moves the entire + // usr/ instead of moving symlinks + else { + // After promotion, the old active /usr is now in staging/usr + let archived_usr = self.installation.root_path(id.to_string()).join("usr"); + if let Some(parent) = archived_usr.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + // hot swap the staging/usr into the root/$id/usr + fs::rename(staged_usr, &archived_usr)?; } - // hot swap the staging/usr into the root/$id/usr - fs::rename(usr_source, &usr_target)?; Ok(()) } @@ -813,20 +933,19 @@ impl Client { /// /// This provides a very quick means to generate a hardlinked "snapshot" on-demand, /// which can then be activated via [`Self::promote_staging`] - pub fn blit_root<'a>( - &self, - packages: impl IntoIterator, - ) -> Result, Error> { + pub fn blit_root<'a, 'b>( + &'a self, + packages: impl IntoIterator, + ) -> Result, Error> { let blit_target = match &self.scope { - Scope::Stateful => self.installation.staging_dir(), - Scope::Ephemeral { blit_root } => blit_root.to_owned(), + Scope::Stateful { .. } => self.installation.staging_dir(), + Scope::Ephemeral { blit_root, .. } => blit_root.to_owned(), }; - let fstree = self.vfs(packages)?; + let vfs = self.vfs(packages)?; + let fstree = self.scope.fstree_driver().blit(&self.installation, &vfs, blit_target)?; - fstree::native::blit_root(&self.installation, &fstree, &blit_target).map_err(Error::Blit)?; - - Ok(fstree) + Ok(BlittedRoot { vfs, fstree }) } fn load_or_create_system_model(&self, path: PathBuf, state: &State) -> Result { @@ -855,15 +974,25 @@ impl Client { let state = self.state_db.get(state)?; let is_active = self.installation.active_state == Some(state.id); - let path = if is_active { - self.installation.root.join("usr/lib/system-model.kdl") - } else { - self.installation - .root_path(state.id.to_string()) - .join("usr/lib/system-model.kdl") - }; + // State is active so file should be readily available under install root. + if is_active { + self.load_or_create_system_model(self.installation.root.join("usr/lib/system-model.kdl"), &state) + } + // State is archived, we need to ensure we bring it up to access the file + // & then bring it back down / cleanup. + else { + // Identify the fstree & bring it up + let mut fstree = self.open_archived_state(&state.id)?; + fstree.bring_up(fstree::Mutability::ReadOnly)?; + + let system_model = + self.load_or_create_system_model(fstree.path.join("usr/lib/system-model.kdl"), &state)?; + + // Cleanup + fstree.bring_down()?; - self.load_or_create_system_model(path, &state) + Ok(system_model) + } } /// Print boot status to stdout @@ -927,9 +1056,27 @@ impl Client { install_db, state_db, layout_db, - scope: Scope::Stateful, + scope: Scope::Stateful { + fstree_driver: fstree::AnyDriver::native(), + }, }) } + + /// Opens the archived state, returning it's [`Fstree`] handle. + pub fn open_archived_state<'a>(&'a self, state: &state::Id) -> Result, Error> { + fstree::Format::ALL + .iter() + .find_map(|format| Fstree::identify(&self.installation, self.state_archive_path(format, state))) + .ok_or(Error::NoArchivedState(*state)) + } + + /// Formats & returns the archive path for the provided `state`. + pub fn state_archive_path(&self, format: &fstree::Format, state: &state::Id) -> PathBuf { + match format { + fstree::Format::Native => self.installation.root_path(state.to_string()), + fstree::Format::Overlayimg => self.installation.root_path(format!("overlayimg/{state}")), + } + } } /// Add root symlinks & os-release file @@ -1066,16 +1213,29 @@ fn record_system_model(root: &Path, system_model: SystemModel) -> Result<(), Err Ok(()) } -#[derive(Clone, Debug)] enum Scope { - Stateful, - Ephemeral { blit_root: PathBuf }, + Stateful { + /// Underlying driver used to create & work with fstrees + fstree_driver: fstree::AnyDriver, + }, + Ephemeral { + blit_root: PathBuf, + /// Underlying driver used to create & work with fstrees + fstree_driver: fstree::AnyDriver, + }, } impl Scope { fn is_ephemeral(&self) -> bool { matches!(self, Self::Ephemeral { .. }) } + + fn fstree_driver(&self) -> &fstree::AnyDriver { + match self { + Scope::Stateful { fstree_driver } => fstree_driver, + Scope::Ephemeral { fstree_driver, .. } => fstree_driver, + } + } } /// Build a [`crate::registry::Registry`] during client initialisation @@ -1109,6 +1269,14 @@ fn build_registry( Ok(registry) } +/// A blitted root returned from [`Client::blit_root`]. +pub struct BlittedRoot<'a> { + /// The virtual fstree used to blit. + pub vfs: vfs::Tree, + /// The blitted fstree. + pub fstree: Fstree<'a>, +} + /// Client-relevant error mapping type #[derive(Debug, Error)] pub enum Error { @@ -1140,8 +1308,8 @@ pub enum Error { Io(#[from] io::Error), #[error("filesystem")] Filesystem(#[from] vfs::tree::Error), - #[error("blit")] - Blit(#[source] fstree::native::Error), + #[error("fstree")] + Fstree(#[from] fstree::DriverError), #[error("postblit")] PostBlit(#[from] postblit::Error), #[error("boot")] @@ -1172,4 +1340,6 @@ pub enum Error { BuildVfsTree(#[source] vfs::tree::Error), #[error("atomic swap")] AtomicSwap(#[source] Errno), + #[error("failed to find archived fstree for state {0}")] + NoArchivedState(state::Id), } diff --git a/moss/src/client/postblit.rs b/moss/src/client/postblit.rs index a2f98eed1..9adc6c12c 100644 --- a/moss/src/client/postblit.rs +++ b/moss/src/client/postblit.rs @@ -19,7 +19,10 @@ use thiserror::Error; use tracing::{error, warn}; use triggers::format::{CompiledHandler, Handler, Trigger}; -use crate::{Installation, fstree::PendingFile}; +use crate::{ + Installation, + fstree::{self, Fstree, PendingFile}, +}; /// Transaction trigger wrapper /// These are loaded from `/usr/share/moss/triggers/tx.d/*.yaml` @@ -44,13 +47,13 @@ impl config::Config for SystemTrigger { } /// The trigger scope determines the environment that the trigger runs in -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub(super) enum TriggerScope<'a> { /// A transaction trigger, isolated to `/usr` Transaction(&'a Installation, &'a super::Scope), /// A system trigger with reduced sandboxing, capable of writes outside `/usr` - System(&'a Installation, &'a super::Scope), + System(&'a Installation, &'a super::Scope, &'a Fstree<'a>), } impl TriggerScope<'_> { @@ -58,12 +61,12 @@ impl TriggerScope<'_> { fn root_dir(&self) -> PathBuf { match self { TriggerScope::Transaction(install, scope) => match scope { - super::Scope::Stateful => install.staging_dir().clone(), - super::Scope::Ephemeral { blit_root } => blit_root.clone(), + super::Scope::Stateful { .. } => install.staging_dir().clone(), + super::Scope::Ephemeral { blit_root, .. } => blit_root.clone(), }, - TriggerScope::System(install, scope) => match scope { - super::Scope::Stateful => install.root.clone(), - super::Scope::Ephemeral { blit_root } => blit_root.clone(), + TriggerScope::System(install, scope, _) => match scope { + super::Scope::Stateful { .. } => install.root.clone(), + super::Scope::Ephemeral { blit_root, .. } => blit_root.clone(), }, } } @@ -72,12 +75,12 @@ impl TriggerScope<'_> { fn host_path(&self, path: impl AsRef) -> PathBuf { match self { TriggerScope::Transaction(install, scope) => match scope { - super::Scope::Stateful => install.root.join(path), - super::Scope::Ephemeral { blit_root } => blit_root.join(path), + super::Scope::Stateful { .. } => install.root.join(path), + super::Scope::Ephemeral { blit_root, .. } => blit_root.join(path), }, - TriggerScope::System(install, scope) => match scope { - super::Scope::Stateful => install.root.join(path), - super::Scope::Ephemeral { blit_root } => blit_root.join(path), + TriggerScope::System(install, scope, _) => match scope { + super::Scope::Stateful { .. } => install.root.join(path), + super::Scope::Ephemeral { blit_root, .. } => blit_root.join(path), }, } } @@ -86,19 +89,29 @@ impl TriggerScope<'_> { fn guest_path(&self, path: impl AsRef) -> PathBuf { match self { TriggerScope::Transaction(install, scope) => match scope { - super::Scope::Stateful => install.staging_path(path), - super::Scope::Ephemeral { blit_root } => blit_root.join(path), + super::Scope::Stateful { .. } => install.staging_path(path), + super::Scope::Ephemeral { blit_root, .. } => blit_root.join(path), }, - TriggerScope::System(install, scope) => match scope { - super::Scope::Stateful => install.root.join(path), - super::Scope::Ephemeral { blit_root } => blit_root.join(path), + TriggerScope::System(install, scope, fstree) => match scope { + super::Scope::Stateful { .. } => { + // TODO: Cleanup all this resolution stuff, its way too foot-gunny + // + // If we use a container and we're bind mounting usr/ make sure its the + // actual usr/ with mount under it & not system root user which + // is symlinks that wont resolve in the container + if install.root != Path::new("/") && *fstree.format() == fstree::Format::Overlayimg { + fstree.path.join(path) + } else { + install.root.join(path) + } + } + super::Scope::Ephemeral { blit_root, .. } => blit_root.join(path), }, } } } /// Condensed type for loaded triggers with scope and executor -#[derive(Debug)] pub(super) struct TriggerRunner<'a> { scope: TriggerScope<'a>, trigger: CompiledHandler, @@ -175,13 +188,14 @@ impl TriggerRunner<'_> { Ok(isolation.run(|| execute_trigger_directly(&self.trigger))?) } - TriggerScope::System(install, _) => { + TriggerScope::System(install, _, _) => { // OK, if the root == `/` then we can run directly, otherwise we need to containerise with RW. if install.root.to_string_lossy() == "/" { Ok(execute_trigger_directly(&self.trigger)?) } else { let isolation = Container::new(install.isolation_dir()) .networking(false) + // Paths updated by system triggers .bind_rw(self.scope.host_path("etc"), "/etc") .bind_rw(self.scope.guest_path("usr"), "/usr") .work_dir("/"); diff --git a/moss/src/client/prune.rs b/moss/src/client/prune.rs index 511296e95..f7e22701b 100644 --- a/moss/src/client/prune.rs +++ b/moss/src/client/prune.rs @@ -202,7 +202,7 @@ pub(super) fn prune_states(client: &Client, strategy: Strategy<'_>, yes: bool) - let archive_paths = removals .iter() - .map(|s| installation.root_path(s.id.to_string())) + .filter_map(|s| Some(client.open_archived_state(&s.id).ok()?.path)) .collect::>(); info!( diff --git a/moss/src/client/verify.rs b/moss/src/client/verify.rs index 651ab8e9a..7c7e2d402 100644 --- a/moss/src/client/verify.rs +++ b/moss/src/client/verify.rs @@ -20,7 +20,7 @@ use vfs::tree::BlitFile; use crate::{ Client, Package, Signal, client::{self, cache}, - package, runtime, signal, state, + fstree, package, runtime, signal, state, }; #[allow(clippy::branches_sharing_code)] @@ -135,7 +135,17 @@ pub fn verify(client: &Client, yes: bool, verbose: bool) -> Result<(), client::E let base = if is_active { client.installation.root.join("usr") } else { - client.installation.root_path(state.id.to_string()).join("usr") + let fstree = client.open_archived_state(&state.id)?; + + match fstree.format() { + fstree::Format::Native => fstree.path.join("usr"), + // TODO: Do we need to verify anything? These are immutable images + // to the backing CAS which is already validated. + fstree::Format::Overlayimg => { + mpb.suspend(|| println!(" {} skipping overlayimg state #{}", "×".yellow(), state.id)); + return Ok(acc); + } + } }; let vfs = client.vfs(state.selections.iter().map(|s| &s.package))?; @@ -295,39 +305,55 @@ pub fn verify(client: &Client, yes: bool, verbose: bool) -> Result<(), client::E let is_active = client.installation.active_state == Some(state.id); - // Blits to staging dir - let fstree = client.blit_root(state.selections.iter().map(|s| &s.package))?; + // Blits to staged fstree + let mut root = client.blit_root(state.selections.iter().map(|s| &s.package))?; if is_active { let system_model = client.load_or_create_system_model(client.installation.root.join("usr/lib/system-model.kdl"), state)?; - // Override install root with the newly blitted active state - client.apply_stateful_blit(fstree, state, None, system_model)?; + // Override install root with the newly blitted active fstree + client.apply_stateful_blit(&mut root, state, None, system_model)?; // Remove corrupt (swapped) state from staging directory fs::remove_dir_all(client.installation.staging_dir())?; } else { - let system_model = client.load_or_create_system_model( - client - .installation - .root_path(state.id.to_string()) - .join("usr/lib/system-model.kdl"), - state, - )?; - + root.fstree.bring_up(fstree::Mutability::ReadWrite)?; + let system_model = + client.load_or_create_system_model(root.fstree.path.join("usr/lib/system-model.kdl"), state)?; // Use the staged blit as an ephereral target for the non-active state // then archive it to it's archive directory - client::record_state_id(&client.installation.staging_dir(), state.id)?; - client.apply_ephemeral_blit(fstree, &client.installation.staging_dir(), system_model)?; + client::record_state_id(&root.fstree.path, state.id)?; + root.fstree.bring_down()?; + + client.apply_ephemeral_blit(&mut root, system_model)?; + let archive_path = client.state_archive_path(root.fstree.format(), &state.id); // Remove the old archive state so the new blit can be archived - fs::remove_dir_all(client.installation.root_path(state.id.to_string())).or_else(|e| { + fs::remove_dir_all(&archive_path).or_else(|e| { if e.kind() == io::ErrorKind::NotFound { Ok(()) } else { Err(e) } })?; + + // TODO: This is super hacky & a code smell, we really + // need to rework the "orchestration" layer of how + // blit, apply / promote / activate work w/ the new + // fstree API + match root.fstree.format() { + // `archive_state` expects that `promote_staging` + // was called first & promote staging already + // archives this overlayimg state. Since that wasn't + // called, we need to do it manually here. + fstree::Format::Overlayimg => { + root.fstree.bring_down()?; + root.fstree.move_to(&archive_path)?; + } + fstree::Format::Native => {} + } + + // New staged state can now be "archived" client.archive_state(state.id)?; // Cleanup staging dir used as ephemeral blit target now that we've // archived out of it diff --git a/moss/src/environment.rs b/moss/src/environment.rs index 7d3c88057..b72579ffb 100644 --- a/moss/src/environment.rs +++ b/moss/src/environment.rs @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2023 AerynOS Developers // SPDX-License-Identifier: MPL-2.0 +use std::env; +use std::str::FromStr; + +use crate::fstree; + pub const NAME: &str = env!("CARGO_PKG_NAME"); /// Max concurrency for disk tasks pub const MAX_DISK_CONCURRENCY: usize = 16; @@ -10,3 +15,13 @@ pub const MAX_NETWORK_CONCURRENCY: usize = 8; pub const FILE_READ_BUFFER_SIZE: usize = 4 * 1024 * 1024; /// Threshold to begin chunking file during read, 16 KiB pub const FILE_READ_CHUNK_THRESHOLD: usize = 16 * 1024; + +/// Value of `MOSS_FSTREE_FORMAT`, if specified & valid +pub fn fstree_format() -> Option { + parse_var("MOSS_FSTREE_FORMAT") +} + +fn parse_var(name: &'static str) -> Option { + let var = env::var(name).ok()?; + var.parse().ok() +} diff --git a/moss/src/fstree.rs b/moss/src/fstree.rs index 7385acc12..a65768a54 100644 --- a/moss/src/fstree.rs +++ b/moss/src/fstree.rs @@ -4,14 +4,295 @@ //! Drivers for creating portable filesystem trees (`fstree`) from _virtual_ //! fstrees ([`vfs::Tree`]) and their backing content (`CAS` / content address store). -use std::fmt; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::{fmt, path::Path}; use astr::AStr; +use fs_err as fs; use stone::{StonePayloadLayoutFile, StonePayloadLayoutRecord}; +use thiserror::Error; -use crate::package; +use crate::{Installation, package, util}; + +pub use self::native::NativeDriver; +pub use self::overlayimg::OverlayimgDriver; pub mod native; +pub mod overlayimg; + +/// A specific `fstree` format supported by `moss` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString)] +#[strum(serialize_all = "lowercase")] +pub enum Format { + /// An `fstree` backed by the native filesystem, using + /// reflinks, hardlinks or normal copy operations to + /// populate based on the best strategy. + Native, + /// An `fstree` backed by an EROFS meta-only image and + /// overlay mount to provide deduplicated content and + /// per file metadata. + Overlayimg, +} + +impl Format { + pub const ALL: [Self; 2] = [Self::Native, Self::Overlayimg]; +} + +/// A driver capable of managing the lifecycle of an `fstree` for a specific [`Format`]. +pub trait Driver { + /// Driver specific error + type Error; + + /// Blit a new `fstree` to `target` from the supplied virtual fstree + /// and asset backing from [`Installation`]. + fn blit( + &self, + installation: &Installation, + tree: &vfs::Tree, + target: &Path, + ) -> Result<(), Self::Error>; + + /// Bring up an `fstree` at the `target` path with the requested `Mutability`. + /// + /// Some types of fstrees require mounting to be active & usable. That happens + /// at this layer, if needed. + fn bring_up( + &self, + _installation: &Installation, + _target: &Path, + _mutability: Mutability, + ) -> Result<(), Self::Error> { + Ok(()) + } + + /// Bring down an `fstree` at the `target` path. + /// + /// Some types of fstrees require unmounting to be disabled. That happens + /// at this layer, if needed. + fn bring_down(&self, _target: &Path) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// An error from a driver +#[derive(Debug, Error)] +pub enum DriverError { + #[error("native fstree driver")] + Native(#[source] native::Error), + #[error("overlayimg fstree driver")] + Overlayimg(#[source] overlayimg::Error), +} + +/// A type erased [`Driver`] +#[derive(Clone)] +pub struct AnyDriver { + inner: Arc + Send + Sync + 'static>, + /// Format of the `fstree` this driver operates on. + pub format: Format, +} + +impl AnyDriver { + fn new(inner: T, format: Format, f: fn(T::Error) -> DriverError) -> Self { + struct Adapter { + inner: T, + f: fn(T::Error) -> DriverError, + } + + impl Adapter { + fn new(inner: T, f: fn(T::Error) -> DriverError) -> Self { + Self { inner, f } + } + } + + impl Driver for Adapter { + type Error = DriverError; + + fn blit( + &self, + installation: &Installation, + tree: &vfs::Tree, + target: &Path, + ) -> Result<(), Self::Error> { + self.inner.blit(installation, tree, target).map_err(self.f) + } + + fn bring_up( + &self, + installation: &Installation, + target: &Path, + mutability: Mutability, + ) -> Result<(), Self::Error> { + self.inner.bring_up(installation, target, mutability).map_err(self.f) + } + + fn bring_down(&self, target: &Path) -> Result<(), Self::Error> { + self.inner.bring_down(target).map_err(self.f) + } + } + + Self { + inner: Arc::new(Adapter::new(inner, f)), + format, + } + } + + /// Create an erased native driver + pub fn native() -> Self { + Self::new(NativeDriver, Format::Native, DriverError::Native) + } + + /// Create an erased overlayimg driver + pub fn overlayimg() -> Self { + Self::new(OverlayimgDriver::default(), Format::Overlayimg, DriverError::Overlayimg) + } + + /// Blit a new `fstree` to `target` from the supplied virtual fstree + /// and asset backing from [`Installation`]. + pub fn blit<'a>( + &'a self, + installation: &'a Installation, + vfs: &vfs::Tree, + target: PathBuf, + ) -> Result, DriverError> { + self.inner.blit(installation, vfs, &target)?; + + Ok(Fstree { + driver: self.clone(), + installation, + path: target, + status: Status::Down, + }) + } +} + +impl Driver for AnyDriver { + type Error = DriverError; + + fn blit( + &self, + installation: &Installation, + tree: &vfs::Tree, + target: &Path, + ) -> Result<(), Self::Error> { + self.inner.blit(installation, tree, target) + } + + fn bring_up(&self, installation: &Installation, target: &Path, mutability: Mutability) -> Result<(), Self::Error> { + self.inner.bring_up(installation, target, mutability) + } + + fn bring_down(&self, target: &Path) -> Result<(), Self::Error> { + self.inner.bring_down(target) + } +} + +/// Handle to an `fstree`. +#[derive(Clone)] +pub struct Fstree<'a> { + driver: AnyDriver, + /// The installation providing the CAS backing to this fstree. + installation: &'a Installation, + /// Path to this `fstree`. + pub path: PathBuf, + /// Stateful status of this `fstree`. + pub status: Status, +} + +impl<'a> Fstree<'a> { + /// If the supplied path is an identified `fstree`, this returns the `Fstree` handle to that path. + pub fn identify(installation: &'a Installation, path: PathBuf) -> Option> { + let format = identify(&path)?; + + let driver = match format { + Format::Native => AnyDriver::native(), + Format::Overlayimg => AnyDriver::overlayimg(), + }; + + Some(Fstree { + driver, + installation, + path, + // TODO: Don't assume this down. Add per driver + // detection logic for status so we have correct state + status: Status::Down, + }) + } + + /// Format of this `fstree` + pub fn format(&self) -> &Format { + &self.driver.format + } + + /// Bring up this `fstree` with the requested [`Mutability`]. + pub fn bring_up(&mut self, mutability: Mutability) -> Result<(), DriverError> { + self.driver.bring_up(self.installation, &self.path, mutability)?; + self.status = Status::Up { mutability }; + Ok(()) + } + + /// Bring down this `fstree`. + pub fn bring_down(&mut self) -> Result<(), DriverError> { + self.driver.bring_down(&self.path)?; + self.status = Status::Down; + Ok(()) + } + + /// Change the mutability of an fstree. + /// + /// Returns `true` if the operation was applied. + /// + /// Returns `false` if the fstree was already at this mutability or + /// if the fstree is currently [`Status::Down`]. + pub fn change_mutability(&mut self, new_mutability: Mutability) -> Result { + match self.status { + Status::Up { mutability } if mutability != new_mutability => { + self.driver.bring_down(&self.path)?; + self.driver.bring_up(self.installation, &self.path, new_mutability)?; + self.status = Status::Up { + mutability: new_mutability, + }; + Ok(true) + } + _ => Ok(false), + } + } + + /// Move the `fstree` to a new path + pub fn move_to(&mut self, to: &Path) -> io::Result<()> { + // We should only be calling this when the fstree isn't brought up + debug_assert!(self.status == Status::Down); + util::ensure_dir_exists(to)?; + fs::rename(&self.path, to)?; + self.path = to.to_owned(); + Ok(()) + } +} + +/// Stateful status of an fstree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Fstree is down, if applicable. + Down, + /// Fstree is up, if applicable. + /// + /// See [`Driver::bring_up`]. + Up { + /// Mutability of the fstree + mutability: Mutability, + }, +} + +/// The requested mutability of an `fstree` +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)] +#[strum(serialize_all = "kebab-case")] +pub enum Mutability { + /// Read only + ReadOnly, + /// Read write + ReadWrite, +} /// A file pending creation to an `fstree` #[derive(Debug, Clone)] @@ -112,3 +393,14 @@ pub fn vfs(layouts: Vec<(package::Id, StonePayloadLayoutRecord)>) -> Result Option { + if overlayimg::is_valid_fstree(path) { + Some(Format::Overlayimg) + } else if path.join("usr").exists() { + Some(Format::Native) + } else { + None + } +} diff --git a/moss/src/fstree/native.rs b/moss/src/fstree/native.rs index f6e985adb..d11cf57b2 100644 --- a/moss/src/fstree/native.rs +++ b/moss/src/fstree/native.rs @@ -26,7 +26,18 @@ use vfs::tree::{BlitFile, Element}; use crate::Installation; -use super::PendingFile; +use super::{Driver, PendingFile}; + +#[derive(Debug, Clone, Copy)] +pub struct NativeDriver; + +impl Driver for NativeDriver { + type Error = Error; + + fn blit(&self, installation: &Installation, tree: &vfs::Tree, target: &Path) -> Result<(), Error> { + blit_root(installation, tree, target) + } +} struct BlitContext<'a> { is_user_root: bool, diff --git a/moss/src/fstree/overlayimg.rs b/moss/src/fstree/overlayimg.rs new file mode 100644 index 000000000..eadd56cd7 --- /dev/null +++ b/moss/src/fstree/overlayimg.rs @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: 2026 AerynOS Developers +// SPDX-License-Identifier: MPL-2.0 + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use fs_err::{self as fs, File}; +use nix::{ + mount::{MntFlags, MsFlags, mount, umount2}, + sys::stat::stat, +}; +use snafu::{ResultExt, Snafu, ensure_whatever}; + +use crate::{Installation, util}; + +use super::{Driver, Mutability, PendingFile}; + +pub use erofs::XattrNamespace; + +#[derive(Debug, Clone, Copy, Default)] +pub struct OverlayimgDriver { + erofs_image_writer: erofs::MetaImageWriter, +} + +impl OverlayimgDriver { + pub fn new() -> Self { + Self::default() + } + + pub fn with_xattr_namespace(self, xattr_namespace: XattrNamespace) -> Self { + Self { + erofs_image_writer: self.erofs_image_writer.with_xattr_namespace(xattr_namespace), + } + } +} + +impl Driver for OverlayimgDriver { + type Error = Error; + + fn blit(&self, installation: &Installation, tree: &vfs::Tree, target: &Path) -> Result<(), Error> { + self.blit(installation, tree, target) + .with_whatever_context(|_| format!("blit fstree to {}", target.display())) + } + + fn bring_up(&self, installation: &Installation, target: &Path, mutability: Mutability) -> Result<(), Error> { + bring_up(installation, target, mutability) + .with_whatever_context(|_| format!("bring up {mutability} fstree at {}", target.display())) + } + + fn bring_down(&self, target: &Path) -> Result<(), Error> { + bring_down(target).with_whatever_context(|_| format!("bring down fstree at {}", target.display())) + } +} + +impl OverlayimgDriver { + fn blit(&self, installation: &Installation, tree: &vfs::Tree, target: &Path) -> Result<(), Error> { + // Constructs all paths + let paths = Paths::new(target); + + // If this is an existing fstree that is mounted, + // we need to bring it down to blit the new image. + let _ = bring_down(target); + + // Scaffold the new fstree + self.scaffold(&paths).whatever_context("scaffold new fstree")?; + + // Write an EROFS image to the designated path + let mut erofs_image = File::create(&paths.erofs_image).whatever_context("create erofs.img file")?; + self.erofs_image_writer + .write(tree, &installation.assets_path("v2"), &mut erofs_image) + .whatever_context("write erofs.img to file")?; + + // That's everything! The real magic happens during `bring_up` + // when we mount everything. + Ok(()) + } + + /// Scaffolds a new `fstree`. + fn scaffold(&self, paths: &Paths) -> Result<(), Error> { + let scaffold_dirs = || -> io::Result<_> { + // Recreate the fstree + util::ensure_dir_exists(&paths.root)?; + fs::create_dir_all(&paths.erofs)?; + fs::create_dir_all(&paths.extra)?; + fs::create_dir_all(&paths.work)?; + fs::create_dir_all(&paths.merged)?; + Ok(()) + }; + + scaffold_dirs().whatever_context("scaffold dirs") + } +} + +/// Required paths used by an overlayimg fstree +struct Paths { + /// Root `/` of the fstree + root: PathBuf, + /// Path we will write the EROFS image to + erofs_image: PathBuf, + /// Where we mount the erofs.img + erofs: PathBuf, + /// Overlay folder used as an upper layer when + /// [`Mutability::ReadWrite`] and used as the + /// first lower layer when [`Mutability::ReadOnly`] + /// + /// This is where things like triggers & other extra + /// files will live that aren't part of the immutable + /// EROFS base image. + extra: PathBuf, + /// Overlay work dir used when [`Mutability::ReadWrite`] + work: PathBuf, + /// Overlay merged mount dir that holds the final fstree + /// and will be mounted to `usr/` + merged: PathBuf, +} + +impl Paths { + fn new(root: impl Into) -> Self { + let root = root.into(); + + // State + let var_fstree = root.join("var/lib/moss/fstree"); + let erofs_image = var_fstree.join("erofs.img"); + let extra = var_fstree.join("extra"); + + // Runtime mounts + let run_fstree = root.join("run/moss/fstree"); + let erofs = run_fstree.join("erofs"); + let work = run_fstree.join("work"); + let merged = root.join("usr"); + + Self { + root, + erofs_image, + erofs, + extra, + work, + merged, + } + } + + fn is_valid_fstree(&self) -> bool { + self.erofs_image.exists() + && self.extra.exists() + && self.erofs.exists() + && self.work.exists() + && self.merged.exists() + } +} + +pub fn is_valid_fstree(target: &Path) -> bool { + Paths::new(target).is_valid_fstree() +} + +pub fn bring_up(installation: &Installation, target: &Path, mutability: Mutability) -> Result<(), Error> { + // Constructs all paths + let paths = Paths::new(target); + + // Ensure we only try to bring up if the requested + // fstree is supported by this driver + ensure_whatever!( + paths.is_valid_fstree(), + "{} is not a valid overlayimg fstree", + target.display() + ); + + // Mount + mount_all(installation, mutability, &paths).whatever_context("mount the fstree")?; + + Ok(()) +} + +pub fn bring_down(target: &Path) -> Result<(), Error> { + // Constructs all paths + let paths = Paths::new(target); + + // Ensure we only try to bring down if the requested + // fstree is supported by this driver + ensure_whatever!( + paths.is_valid_fstree(), + "{} is not a valid overlayimg fstree", + target.display() + ); + + // Unmount + unmount_all(&paths).whatever_context("unmount the fstree")?; + + Ok(()) +} + +fn mount_all(installation: &Installation, mutability: Mutability, paths: &Paths) -> Result<(), Error> { + // Mount EROFS + mount( + Some(&paths.erofs_image), + &paths.erofs, + Some("erofs"), + MsFlags::empty(), + Some(""), + ) + .whatever_context("mount erofs.img")?; + + let overlay_options = match mutability { + Mutability::ReadOnly => format!( + "lowerdir={}:{}/usr::{}", + paths.extra.display(), + paths.erofs.display(), + installation.assets_path("v2").display(), + ), + Mutability::ReadWrite => format!( + "lowerdir={}/usr::{},upperdir={},workdir={}", + paths.erofs.display(), + installation.assets_path("v2").display(), + paths.extra.display(), + paths.work.display() + ), + }; + + // Mount overlay + mount( + Some("overlay"), + &paths.merged, + Some("overlay"), + MsFlags::empty(), + Some(overlay_options.as_str()), + ) + .whatever_context("mount overlay")?; + + Ok(()) +} + +fn unmount_all(paths: &Paths) -> Result<(), Error> { + let stat_path = |path: &Path| stat(path).with_whatever_context(|_| format!("stat {}", path.display())); + + // Stat parent vs mounts so we can compare `st_dev` + // to validate they are mounted prior to attempting + // to unmount. + let root_stat = stat_path(&paths.root)?; + let erofs_stat = stat_path(&paths.erofs)?; + let overlay_stat = stat_path(&paths.merged)?; + + if root_stat.st_dev != overlay_stat.st_dev { + // Unmount overlay + umount2(&paths.merged, MntFlags::MNT_DETACH).whatever_context("unmount overlay")?; + } + if root_stat.st_dev != erofs_stat.st_dev { + // Unmount erofs + umount2(&paths.erofs, MntFlags::MNT_DETACH).whatever_context("unmount erofs")?; + } + + Ok(()) +} + +#[derive(Debug, Snafu)] +#[snafu(whatever, display("{message}"))] +pub struct Error { + message: String, + #[snafu(source(from(Box, Some)))] + source: Option>, +}