From 7857febfc00b0d016ceda3834664d882eda9a371 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 7 Dec 2023 17:34:13 -0500 Subject: [PATCH] composepost: Support rootfs.transient=yes This pairs with https://github.com/ostreedev/ostree/pull/3114 Basically we want to detect the case where the OS has opted-in to this new mode and *not* symlink things. I originally thought we could implement this by just moving all the toplevel directories, but then I hit on the fact that because the `filesystem` package is creating all the toplevel directories in lua script which we ignore...that doesn't work. So we need to keep making them by hand. --- rust/src/composepost.rs | 199 ++++++++++++++++++++----- rust/src/lib.rs | 1 + rust/src/ostree_prepareroot.rs | 50 +++++++ tests/compose/test-rootfs-transient.sh | 25 ++++ 4 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 rust/src/ostree_prepareroot.rs create mode 100755 tests/compose/test-rootfs-transient.sh diff --git a/rust/src/composepost.rs b/rust/src/composepost.rs index cd8e6dab01..e3377d33b5 100644 --- a/rust/src/composepost.rs +++ b/rust/src/composepost.rs @@ -43,6 +43,11 @@ use std::process::Stdio; /// location to `/usr/lib/`. pub(crate) static COMPAT_VARLIB_SYMLINKS: &[&str] = &["alternatives", "vagrant"]; +const DEFAULT_DIRMODE: u32 = 0o755; + +/// Symlinks to ensure home directories persist by default. +const OSTREE_HOME_SYMLINKS: &[(&str, &str)] = &[("var/roothome", "root"), ("var/home", "home")]; + /* See rpmostree-core.h */ const RPMOSTREE_BASE_RPMDB: &str = "usr/lib/sysimage/rpm-ostree-base-db"; pub(crate) const RPMOSTREE_RPMDB_LOCATION: &str = "usr/share/rpm"; @@ -59,26 +64,15 @@ fn dir_move_if_exists(src: &cap_std::fs::Dir, dest: &cap_std::fs::Dir, name: &st /// Initialize an ostree-oriented root filesystem. /// -/// This is hardcoded; in the future we may make more things configurable, -/// but the goal is for all state to be in `/etc` and `/var`. -#[context("Initializing rootfs")] -fn compose_init_rootfs(rootfs_dfd: &cap_std::fs::Dir, tmp_is_dir: bool) -> Result<()> { - println!("Initializing rootfs"); - - let default_dirmode: u32 = 0o755; - let default_dirbuilder = &dirbuilder_from_mode(default_dirmode); - let default_dirmode = cap_std::fs::Permissions::from_mode(default_dirmode); - +/// Now unfortunately today, we're not generating toplevel filesystem entries +/// because the `filesystem` package does it from Lua code, which we don't run. +/// (See rpmostree-core.cxx) +#[context("Initializing rootfs (base)")] +fn compose_init_rootfs_base(rootfs_dfd: &cap_std::fs::Dir, tmp_is_dir: bool) -> Result<()> { const TOPLEVEL_DIRS: &[&str] = &["dev", "proc", "run", "sys", "var", "sysroot"]; - const TOPLEVEL_SYMLINKS: &[(&str, &str)] = &[ - ("var/opt", "opt"), - ("var/srv", "srv"), - ("var/mnt", "mnt"), - ("var/roothome", "root"), - ("var/home", "home"), - ("run/media", "media"), - ("sysroot/ostree", "ostree"), - ]; + + let default_dirbuilder = &dirbuilder_from_mode(DEFAULT_DIRMODE); + let default_dirmode = cap_std::fs::Permissions::from_mode(DEFAULT_DIRMODE); rootfs_dfd .set_permissions(".", default_dirmode) @@ -90,11 +84,6 @@ fn compose_init_rootfs(rootfs_dfd: &cap_std::fs::Dir, tmp_is_dir: bool) -> Resul .with_context(|| format!("Creating {d}")) .map(|_: bool| ()) })?; - TOPLEVEL_SYMLINKS.par_iter().try_for_each(|&(dest, src)| { - rootfs_dfd - .symlink(dest, src) - .with_context(|| format!("Creating {src}")) - })?; if tmp_is_dir { let tmp_mode = 0o1777; @@ -108,15 +97,86 @@ fn compose_init_rootfs(rootfs_dfd: &cap_std::fs::Dir, tmp_is_dir: bool) -> Resul rootfs_dfd.symlink("sysroot/tmp", "tmp")?; } + OSTREE_HOME_SYMLINKS + .par_iter() + .try_for_each(|&(dest, src)| { + rootfs_dfd + .symlink(dest, src) + .with_context(|| format!("Creating {src}")) + })?; + + rootfs_dfd + .symlink("sysroot/ostree", "ostree") + .context("Symlinking ostree -> sysroot/ostree")?; + + Ok(()) +} + +/// Initialize a root filesystem set up for use with ostree's `root.transient` mode. +#[context("Initializing rootfs (base)")] +fn compose_init_rootfs_transient(rootfs_dfd: &cap_std::fs::Dir) -> Result<()> { + // Enforce tmp-is-dir in this, because there's really no reason not to. + compose_init_rootfs_base(rootfs_dfd, true)?; + // Again we need to make these directories here because we don't run + // the `filesystem` package's lua script. + const EXTRA_TOPLEVEL_DIRS: &[&str] = &["opt", "media", "mnt", "usr/local"]; + + let mut db = dirbuilder_from_mode(DEFAULT_DIRMODE); + db.recursive(true); + EXTRA_TOPLEVEL_DIRS.par_iter().try_for_each(|&d| { + // We need to handle the case where these may have been created as a symlink + // by tmpfiles.d snippets for example. + if let Some(meta) = rootfs_dfd.symlink_metadata_optional(d)? { + if !meta.is_dir() { + rootfs_dfd.remove_file(d)?; + } + } + rootfs_dfd + .ensure_dir_with(d, &db) + .with_context(|| format!("Creating {d}")) + .map(|_: bool| ()) + })?; + + Ok(()) +} + +/// Initialize an ostree-oriented root filesystem. +/// +/// This is hardcoded; in the future we may make more things configurable, +/// but the goal is for all state to be in `/etc` and `/var`. +#[context("Initializing rootfs")] +fn compose_init_rootfs_strict(rootfs_dfd: &cap_std::fs::Dir, tmp_is_dir: bool) -> Result<()> { + println!("Initializing rootfs"); + + compose_init_rootfs_base(rootfs_dfd, tmp_is_dir)?; + + // This is used in the case where we don't have a transient rootfs; redirect + // these toplevel directories underneath /var. + const OSTREE_STRICT_MODE_SYMLINKS: &[(&str, &str)] = &[ + ("var/opt", "opt"), + ("var/srv", "srv"), + ("var/mnt", "mnt"), + ("run/media", "media"), + ]; + OSTREE_STRICT_MODE_SYMLINKS + .par_iter() + .try_for_each(|&(dest, src)| { + rootfs_dfd + .symlink(dest, src) + .with_context(|| format!("Creating {src}")) + })?; + Ok(()) } /// Prepare rootfs for commit. /// -/// Initialize a basic root filesystem in @target_root_dfd, then walk over the +/// In the default mode, we initialize a basic root filesystem in @target_root_dfd, then walk over the /// root filesystem in @src_rootfs_fd and take the basic content (/usr, /boot, /var) /// and cherry pick only specific bits of the rest of the toplevel like compatibility /// symlinks (e.g. /lib64 -> /usr/lib64) if they exist. +/// +/// However, if the rootfs is setup as transient, then we just copy everything. #[context("Preparing rootfs for commit")] pub fn compose_prepare_rootfs( src_rootfs_dfd: i32, @@ -127,7 +187,32 @@ pub fn compose_prepare_rootfs( let target_rootfs_dfd = unsafe { &ffi_dirfd(target_rootfs_dfd)? }; let tmp_is_dir = treefile.parsed.base.tmp_is_dir.unwrap_or_default(); - compose_init_rootfs(target_rootfs_dfd, tmp_is_dir)?; + + if crate::ostree_prepareroot::transient_root_enabled(src_rootfs_dfd)? { + println!("Target has transient root enabled"); + // While sadly tmp-is-dir: false by default, we want to encourage + // people to switch, so just error out if they're somehow configured + // things for the newer transient root model but forgot to set `tmp-is-dir`. + if !tmp_is_dir { + return Err("Transient root conflicts with tmp-is-dir: false" + .to_string() + .into()); + } + // We grab all the content from the source root by default on general principle, + // but note this won't be very much right now because + // we're not executing the `filesystem` package's lua script. + for entry in src_rootfs_dfd.entries()? { + let entry = entry?; + let name = entry.file_name(); + src_rootfs_dfd + .rename(&name, target_rootfs_dfd, &name) + .with_context(|| format!("Moving {name:?}"))?; + } + compose_init_rootfs_transient(target_rootfs_dfd)?; + return Ok(()); + } + + compose_init_rootfs_strict(target_rootfs_dfd, tmp_is_dir)?; println!("Moving /usr to target"); src_rootfs_dfd.rename("usr", target_rootfs_dfd, "usr")?; @@ -883,12 +968,14 @@ pub fn rootfs_prepare_links(rootfs_dfd: i32) -> CxxResult<()> { let mut db = dirbuilder_from_mode(0o755); db.recursive(true); - // Unconditionally drop /usr/local and replace it with a symlink. - rootfs - .remove_all_optional("usr/local") - .context("Removing /usr/local")?; - ensure_symlink(rootfs, "../var/usrlocal", "usr/local") - .context("Creating /usr/local symlink")?; + if !crate::ostree_prepareroot::transient_root_enabled(rootfs)? { + // Unconditionally drop /usr/local and replace it with a symlink. + rootfs + .remove_all_optional("usr/local") + .context("Removing /usr/local")?; + ensure_symlink(rootfs, "../var/usrlocal", "usr/local") + .context("Creating /usr/local symlink")?; + } // Move existing content to /usr/lib, then put a symlink in its // place under /var/lib. @@ -1308,20 +1395,52 @@ OSTREE_VERSION='33.4' assert_eq!(replaced.as_str(), expected); } + fn verify_base(rootfs: &Dir) -> Result<()> { + // Not exhaustive, just a sanity check + for d in ["proc", "sys"] { + assert!(rootfs.symlink_metadata(d)?.is_dir()); + } + let homelink = rootfs.read_link("home")?; + assert_eq!(homelink.to_str().unwrap(), "var/home"); + Ok(()) + } + #[test] - fn test_init_rootfs() -> Result<()> { + fn test_init_rootfs_strict() -> Result<()> { + // Test the legacy tmp_is_dir path { let rootfs = cap_tempfile::tempdir(cap_tempfile::ambient_authority())?; - compose_init_rootfs(&rootfs, false)?; + compose_init_rootfs_base(&rootfs, false)?; let target = rootfs.read_link("tmp").unwrap(); assert_eq!(target, Path::new("sysroot/tmp")); + verify_base(&rootfs)?; } - { - let rootfs = cap_tempfile::tempdir(cap_tempfile::ambient_authority())?; - compose_init_rootfs(&rootfs, true)?; - let tmpdir_meta = rootfs.metadata("tmp").unwrap(); - assert!(tmpdir_meta.is_dir()); - assert_eq!(tmpdir_meta.permissions().mode() & 0o7777, 0o1777); + // Default expected strict mode + let rootfs = cap_tempfile::tempdir(cap_tempfile::ambient_authority())?; + compose_init_rootfs_base(&rootfs, true)?; + let tmpdir_meta = rootfs.metadata("tmp").unwrap(); + assert!(tmpdir_meta.is_dir()); + assert_eq!(tmpdir_meta.permissions().mode() & 0o7777, 0o1777); + verify_base(&rootfs)?; + Ok(()) + } + + #[test] + fn test_init_rootfs_transient() -> Result<()> { + let rootfs = cap_tempfile::tempdir(cap_tempfile::ambient_authority())?; + compose_init_rootfs_transient(&rootfs)?; + let tmpdir_meta = rootfs.metadata("tmp").unwrap(); + assert!(tmpdir_meta.is_dir()); + assert_eq!(tmpdir_meta.permissions().mode() & 0o7777, 0o1777); + verify_base(&rootfs)?; + for d in ["opt", "usr/local"] { + assert!( + rootfs + .symlink_metadata(d) + .with_context(|| format!("Verifying {d} is dir"))? + .is_dir(), + "Verifying {d} is dir" + ); } Ok(()) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b18cd89122..c11ed0831c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -970,6 +970,7 @@ pub(crate) use self::modularity::*; mod nameservice; mod normalization; mod origin; +mod ostree_prepareroot; pub(crate) use self::origin::*; mod passwd; use passwd::*; diff --git a/rust/src/ostree_prepareroot.rs b/rust/src/ostree_prepareroot.rs new file mode 100644 index 0000000000..a6ecef18cd --- /dev/null +++ b/rust/src/ostree_prepareroot.rs @@ -0,0 +1,50 @@ +//! Logic related to parsing ostree-prepare-root.conf. +//! + +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::io::BufReader; +use std::io::Read; + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use ostree_ext::glib; +use ostree_ext::keyfileext::KeyFileExt; + +pub(crate) const CONF_PATH: &str = "ostree/prepare-root.conf"; + +pub(crate) fn load_config(rootfs: &Dir) -> Result> { + let kf = glib::KeyFile::new(); + for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) { + let path = &path.join(CONF_PATH); + if let Some(fd) = rootfs + .open_optional(path) + .with_context(|| format!("Opening {path}"))? + { + let mut fd = BufReader::new(fd); + let mut buf = String::new(); + fd.read_to_string(&mut buf) + .with_context(|| format!("Reading {path}"))?; + kf.load_from_data(&buf, glib::KeyFileFlags::NONE) + .with_context(|| format!("Parsing {path}"))?; + tracing::debug!("Loaded {path}"); + return Ok(Some(kf)); + } + } + tracing::debug!("No {CONF_PATH} found"); + Ok(None) +} + +/// Query whether the target root has the `root.transient` key +/// which sets up a transient overlayfs. +pub(crate) fn transient_root_enabled(rootfs: &Dir) -> Result { + if let Some(config) = load_config(rootfs)? { + Ok(config + .optional_bool("root", "transient")? + .unwrap_or_default()) + } else { + Ok(false) + } +} diff --git a/tests/compose/test-rootfs-transient.sh b/tests/compose/test-rootfs-transient.sh new file mode 100755 index 0000000000..4268c426ac --- /dev/null +++ b/tests/compose/test-rootfs-transient.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -xeuo pipefail + +dn=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=libcomposetest.sh +. "${dn}/libcomposetest.sh" + +# Add a local rpm-md repo so we can mutate local test packages +treefile_append "repos" '["test-repo"]' +build_rpm prepare-root-config \ + files "/usr/lib/ostree/prepare-root.conf" \ + install "mkdir -p %{buildroot}/usr/lib/ostree && echo -e '[root]\ntransient=true' > %{buildroot}/usr/lib/ostree/prepare-root.conf" + +echo gpgcheck=0 >> yumrepo.repo +ln "$PWD/yumrepo.repo" config/yumrepo.repo +# the top-level manifest doesn't have any packages, so just set it +treefile_append "packages" '["prepare-root-config"]' + +# Do the compose +runcompose +echo "ok compose" + +ostree --repo=${repo} ls ${treeref} /opt > ls.txt +assert_file_has_content ls.txt 'd00755 *0 *0 *0 */opt' +echo "ok opt is directory with transient rootfs"