diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 1906efa025..ab41d30454 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -82,6 +82,9 @@ pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; /// BIOS boot partition type GUID for GPT pub const BIOS_BOOT: &str = "21686148-6449-6e6f-744e-656564454649"; +/// Variable Data Partition type GUID (DPS), the same on all architectures +pub const VAR: &str = "4d21b016-b534-45c2-a9fb-5c16e091fd2d"; + #[derive(Debug, Deserialize)] struct DevicesOutput { blockdevices: Vec, @@ -230,6 +233,16 @@ impl Device { Ok((!bios_boots.is_empty()).then_some(bios_boots)) } + /// Find the first Variable Data Partition (DPS) among all root devices + /// backing this device. Returns None if there is none. + pub fn find_colocated_var(&self) -> Result> { + Ok(self + .find_all_roots()? + .iter() + .find_map(|root| root.find_partition_of_type(VAR)) + .cloned()) + } + /// Find a child partition by partition type (case-insensitive). pub fn find_partition_of_type(&self, parttype: &str) -> Option<&Device> { self.children.as_ref()?.iter().find(|child| { @@ -870,6 +883,19 @@ mod test { assert_eq!(bios.parttype.as_deref().unwrap(), BIOS_BOOT); } + /// A Variable Data Partition is found by its type GUID, whatever the case. + #[test] + fn test_find_partition_of_var() { + let fixture = include_str!("../tests/fixtures/lsblk.json"); + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + let mut dev = devs.blockdevices.into_iter().next().unwrap(); + assert!(dev.find_partition_of_type(VAR).is_none()); + let last = dev.children.as_mut().unwrap().last_mut().unwrap(); + last.parttype = Some(VAR.to_uppercase()); + let var = dev.find_partition_of_type(VAR).unwrap(); + assert_eq!(var.partn, Some(3)); + } + /// Verify that without the udev database, partition type fields are null /// and partition discovery fails. This simulates what happens when bootc /// runs inside a sandbox (like osbuild's bwrap) without /run/udev. diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da8..fa24c1fc40 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -794,20 +794,23 @@ pub(crate) fn setup_composefs_bls_boot( build_composefs_karg(id.clone(), format_version, allow_missing_fsverity); cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); - // If there's a separate /boot partition, add a systemd.mount-extra + // If there's a separate /boot or /var, add a systemd.mount-extra // karg so systemd mounts it after reboot. This avoids writing to // /etc/fstab which conflicts with transient etc (see #1388). - if let Some(boot) = root_setup.boot_mount_spec() { - if !boot.source.is_empty() { - let mount_extra = format!( - "systemd.mount-extra={}:/boot:{}:{}", - boot.source, - boot.fstype, - boot.options.as_deref().unwrap_or("defaults"), - ); - cmdline_options.extend(&Cmdline::from(mount_extra.as_str())); - tracing::debug!("Added /boot mount karg: {mount_extra}"); - } + for mount in [root_setup.boot_mount_spec(), root_setup.var_mount_spec()] + .into_iter() + .flatten() + .filter(|m| !m.source.is_empty()) + { + let mount_extra = format!( + "systemd.mount-extra={}:{}:{}:{}", + mount.source, + mount.target, + mount.fstype, + mount.options.as_deref().unwrap_or("defaults"), + ); + cmdline_options.extend(&Cmdline::from(mount_extra.as_str())); + tracing::debug!("Added {} mount karg: {mount_extra}", mount.target); } // Locate ESP partition device by walking up to the root disk(s) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 578a74adb9..2313281741 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -973,6 +973,22 @@ impl InternalsOpts { const GENERATOR_BIN: &'static str = "bootc-systemd-generator"; } +/// The text of `bootc --version`: the version, then the optional features of +/// this binary that tools driving bootc can rely on being present. +fn long_version() -> &'static str { + static LONG_VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); + LONG_VERSION + .get_or_init(|| format_long_version(clap::crate_version!(), crate::install::FEATURES)) +} + +fn format_long_version(version: &str, features: &[&str]) -> String { + let features = features + .iter() + .map(|f| format!("\n - {f}")) + .collect::(); + format!("{version}\nFeatures:{features}") +} + /// Deploy and transactionally in-place with bootable container images. /// /// The `bootc` project currently uses ostree-containers as a backend @@ -983,7 +999,7 @@ impl InternalsOpts { #[derive(Debug, Parser, PartialEq, Eq)] #[clap(name = "bootc")] #[clap(rename_all = "kebab-case")] -#[clap(version,long_version=clap::crate_version!())] +#[clap(version, long_version = long_version())] #[allow(clippy::large_enum_variant)] pub(crate) enum Opt { /// Download and queue an updated container image to apply. @@ -3161,4 +3177,17 @@ mod tests { } } } + + #[test] + fn test_long_version() { + assert_eq!( + format_long_version("1.2.3", &["a", "b"]), + "1.2.3\nFeatures:\n - a\n - b" + ); + // Every feature we have shows up in `bootc --version` + let long = Opt::command().render_long_version(); + for f in crate::install::FEATURES { + assert!(long.contains(&format!("\n - {f}\n")), "{long}"); + } + } } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index d4dc852dbf..d7ba381cfd 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -146,6 +146,7 @@ pub(crate) mod completion; pub(crate) mod config; mod osbuild; pub(crate) mod osconfig; +mod var_mounts; use std::collections::HashMap; use std::io::Write; @@ -224,6 +225,8 @@ const ALONGSIDE_ROOT_MOUNT: &str = "/target"; pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup"; /// This is an ext4 special directory we need to ignore. const LOST_AND_FOUND: &str = "lost+found"; +/// Optional behaviors of this binary, listed by `bootc --version`. +pub(crate) const FEATURES: &[&str] = &[var_mounts::FEATURE]; /// The mount path for selinux const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi @@ -487,6 +490,17 @@ pub(crate) struct InstallTargetFilesystemOpts { #[clap(long)] pub(crate) boot_mount_spec: Option, + /// Source device specification to mount at /var, such as `UUID=...`, + /// `LABEL=...` or a device path. + /// + /// bootc mounts it on the target, initializes it from the image's /var + /// and records it for mounting at boot. By default, a Variable Data + /// Partition (per the Discoverable Partitions Specification) on the disk + /// backing the root is used, if there is one. An empty value disables + /// that discovery and all /var handling. + #[clap(long, alias = "var")] + pub(crate) var_mount_spec: Option, + /// Initialize the system in-place; at the moment, only one mode for this is implemented. /// In the future, it may also be supported to set up an explicit "dual boot" system. #[clap(long)] @@ -653,6 +667,8 @@ pub(crate) struct State { pub(crate) composefs_options: InstallComposefsOpts, pub(crate) composefs_fsverity_supported: bool, pub(crate) allow_missing_verity_explicit: bool, + /// The image has a UKI as its kernel + pub(crate) is_uki: bool, } // Shared read-only global state @@ -1286,15 +1302,22 @@ async fn install_container( } } - // Write the entry for /boot to /etc/fstab. TODO: Encourage OSes to use the karg? + // Write the entries for /boot and /var to /etc/fstab. TODO: Encourage OSes to use the karg? // Or better bind this with the grub data. - // We omit it if the boot mountspec argument was empty - if let Some(boot) = root_setup.boot.as_ref() { - if !boot.source.is_empty() { - crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| { - writeln!(w, "{}", boot.to_fstab()).map_err(Into::into) - })?; - } + // We omit /boot if the boot mountspec argument was empty + let fstab = [root_setup.boot_mount_spec(), root_setup.var_mount_spec()] + .into_iter() + .flatten() + .filter(|m| !m.source.is_empty()) + .map(|m| m.to_fstab()) + .collect::>(); + if !fstab.is_empty() { + crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| { + fstab + .iter() + .try_for_each(|l| writeln!(w, "{l}")) + .map_err(Into::into) + })?; } if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { @@ -1372,6 +1395,8 @@ pub(crate) struct RootSetup { /// True if we should skip finalizing skip_finalize: bool, boot: Option, + /// A /var filesystem that bootc mounted itself for the installation. + var: Option, pub(crate) kargs: CmdlineOwned, } @@ -1392,6 +1417,11 @@ impl RootSetup { self.boot.as_ref() } + /// Get the mount spec of the /var filesystem that bootc mounted, if any. + pub(crate) fn var_mount_spec(&self) -> Option<&MountSpec> { + self.var.as_ref().map(|v| &v.spec) + } + // Drop any open file descriptors and return just the mount path and backing luks device, if any #[cfg(feature = "install-to-disk")] fn into_storage(self) -> (Utf8PathBuf, Option) { @@ -1895,6 +1925,7 @@ async fn prepare_install( .map(|fs| fs.supports_fsverity()) .unwrap_or(true), allow_missing_verity_explicit, + is_uki, }); Ok(state) @@ -2090,6 +2121,7 @@ async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, cleanup: Cleanup, + var_mounts: &[Utf8PathBuf], ) -> Result<()> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); @@ -2204,6 +2236,20 @@ async fn install_to_filesystem_impl( } } + // The deployment backend seeds its own state directory, not the caller's + // mounted /var tree. Populate fresh external filesystems while all nested + // mounts are still visible, before labeling and finalizing the installation. + let prepared_var_mounts = if !var_mounts.is_empty() { + let source = if state.composefs_options.composefs_backend { + Utf8PathBuf::from(crate::composefs_consts::SHARED_VAR_PATH) + } else { + Utf8PathBuf::from(format!("ostree/deploy/{}/var", state.stateroot())) + }; + var_mounts::populate(&rootfs.physical_root, &source, var_mounts)? + } else { + Vec::new() + }; + // As the very last step before filesystem finalization, do a full SELinux // relabel of the physical root filesystem. Any files that are already // labeled (e.g. ostree deployment contents, composefs objects) are skipped. @@ -2212,10 +2258,36 @@ async fn install_to_filesystem_impl( let mut path = Utf8PathBuf::from(""); crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None) .context("Final SELinux relabeling of physical root")?; + // The physical-root walk skips mountpoints. Label each newly + // prepared filesystem separately, using its deployed /var path. + for mount in &prepared_var_mounts { + let mut path = var_mounts::target_path(mount); + // These are fresh filesystems; label their roots unconditionally. + // Without selinuxfs (e.g. in an osbuild buildroot), an inode without + // a label still reports the kernel's unlabeled context, which the + // walk below would take as labeled. + for p in [path.clone(), path.join(LOST_AND_FOUND)] { + if let Some(meta) = rootfs.physical_root.symlink_metadata_optional(&p)? { + crate::lsm::relabel(&rootfs.physical_root, &meta, &p, None, &policy) + .with_context(|| format!("SELinux labeling of /{p}"))?; + } + } + crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None) + .with_context(|| format!("SELinux labeling of /{path}"))?; + } } else { tracing::debug!("Skipping final SELinux relabel (SELinux is disabled)"); } + // Flush the /var filesystems we wrote, surfacing any writeback errors. + // Unlike root and boot they are not remounted read-only or frozen: they + // may be bind mounts or lack freeze support, and callers may add content. + var_mounts::sync(&rootfs.physical_root, &prepared_var_mounts)?; + // A /var that we mounted is for the booted system to mount, not ours to leave behind. + if let Some(var) = &rootfs.var { + var.unmount()?; + } + // Finalize mounted filesystems if !rootfs.skip_finalize { let bootfs = rootfs.boot.as_ref().map(|_| ("boot", BOOT)); @@ -2305,7 +2377,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { (rootfs, loopback_dev) }; - install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?; + install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip, &[]).await?; // Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed. let (root_path, luksdev) = rootfs.into_storage(); @@ -2829,11 +2901,58 @@ pub(crate) async fn install_to_filesystem( target_root_path: Some(target_root_path.clone()), rootfs_uuid: inspect.uuid.clone(), boot, + var: None, kargs, skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + // Reinstalling alongside an existing OS must not touch its live state. + let var_plan = if targeting_host_root + || is_already_ostree + || matches!(fsopts.replace, Some(ReplaceMode::Alongside)) + { + anyhow::ensure!( + fsopts.var_mount_spec.as_deref().is_none_or(str::is_empty), + "--var-mount-spec is not supported when installing to the host root, over an \ + existing ostree system or with --replace=alongside" + ); + if let Some(spec) = state + .install_config + .as_ref() + .and_then(|c| c.var_mount_spec.as_deref()) + .filter(|s| !s.is_empty()) + { + tracing::warn!( + "Ignoring var-mount-spec={spec} from the install config: it is not supported \ + when installing to the host root, over an existing ostree system or with \ + --replace=alongside" + ); + } + var_mounts::Plan::Disabled + } else { + let config_var_mount_spec = state + .install_config + .as_ref() + .and_then(|c| c.var_mount_spec.as_deref()); + let spec = fsopts.var_mount_spec.as_deref().or(config_var_mount_spec); + let caller_mounts = !var_mounts::discover(&rootfs.physical_root_path)?.is_empty(); + let cannot_record = state.composefs_options.composefs_backend && state.is_uki; + var_mounts::plan(spec, caller_mounts, cannot_record, || { + rootfs.device_info.find_colocated_var() + })? + }; + if let var_mounts::Plan::Mount(source) = &var_plan { + rootfs.var = Some(var_mounts::mount( + &rootfs.physical_root_path, + &rootfs.physical_root, + source, + )?); + } + let var_mounts = match var_plan { + var_mounts::Plan::Disabled => Vec::new(), + _ => var_mounts::discover(&rootfs.physical_root_path)?, + }; + install_to_filesystem_impl(&state, &mut rootfs, cleanup, &var_mounts).await?; // Drop all data about the root except the path to ensure any file descriptors etc. are closed. drop(rootfs); @@ -2878,6 +2997,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> root_path: opts.root_path, root_mount_spec: None, boot_mount_spec: None, + var_mount_spec: None, replace: opts.replace, skip_finalize: true, acknowledge_destructive: opts.acknowledge_destructive, diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index f4d07c96ac..56fc4d77af 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -955,6 +955,7 @@ pub(crate) fn install_create_rootfs( target_root_path: None, rootfs_uuid: Some(root_uuid.to_string()), boot, + var: None, kargs, skip_finalize: false, }) diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 55fdd42f98..a9ee3f5674 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -118,6 +118,9 @@ pub(crate) struct InstallConfiguration { pub(crate) root_mount_spec: Option, /// Mount specification for the /boot filesystem. pub(crate) boot_mount_spec: Option, + /// Source device specification to mount at /var. An empty string disables + /// discovery of a Variable Data Partition and all /var handling. + pub(crate) var_mount_spec: Option, /// Bootupd configuration pub(crate) bootupd: Option, /// Bootloader to use (grub, systemd, none) @@ -214,6 +217,7 @@ impl Mergeable for InstallConfiguration { merge_basic(&mut self.stateroot, other.stateroot, env); merge_basic(&mut self.root_mount_spec, other.root_mount_spec, env); merge_basic(&mut self.boot_mount_spec, other.boot_mount_spec, env); + merge_basic(&mut self.var_mount_spec, other.var_mount_spec, env); self.bootupd.merge(other.bootupd, env); merge_basic(&mut self.bootloader, other.bootloader, env); merge_basic( @@ -775,12 +779,14 @@ stateroot = "custom" r#"[install] root-mount-spec = "LABEL=rootfs" boot-mount-spec = "UUID=abcd-1234" +var-mount-spec = "LABEL=var" "#, ) .unwrap(); let install = c.install.unwrap(); assert_eq!(install.root_mount_spec.unwrap(), "LABEL=rootfs"); assert_eq!(install.boot_mount_spec.unwrap(), "UUID=abcd-1234"); + assert_eq!(install.var_mount_spec.unwrap(), "LABEL=var"); } #[test] @@ -791,11 +797,13 @@ boot-mount-spec = "UUID=abcd-1234" let mut install: InstallConfiguration = toml::from_str( r#"root-mount-spec = "UUID=old" boot-mount-spec = "UUID=oldboot" +var-mount-spec = "UUID=oldvar" "#, ) .unwrap(); let other = InstallConfiguration { root_mount_spec: Some("LABEL=newroot".to_string()), + var_mount_spec: Some("".to_string()), ..Default::default() }; install.merge(other, &env); @@ -803,6 +811,8 @@ boot-mount-spec = "UUID=oldboot" assert_eq!(install.root_mount_spec.as_deref().unwrap(), "LABEL=newroot"); // boot_mount_spec should remain unchanged assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "UUID=oldboot"); + // An empty var_mount_spec is a value like any other + assert_eq!(install.var_mount_spec.as_deref().unwrap(), ""); } /// Empty mount specs are valid and signal to omit mount kargs entirely. diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs new file mode 100644 index 0000000000..2c1bac8d32 --- /dev/null +++ b/crates/lib/src/install/var_mounts.rs @@ -0,0 +1,714 @@ +//! Initialize caller-mounted /var filesystems from the installed deployment. + +use std::cell::Cell; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use bootc_mount::Filesystem; +use bootc_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt, dirext::CapStdExtDirExt}; +use rustix::fs::{Mode, OFlags}; + +use super::{LOST_AND_FOUND, MountSpec}; + +/// Advertised by `bootc --version`, so that tools preparing a target for +/// `install to-filesystem` know that /var filesystems are initialized. +/// Older versions leave such filesystems empty, or reject them entirely. +pub(super) const FEATURE: &str = "install-var-mount"; + +const VAR: &str = "var"; + +/// The path of a mount (relative to /var) from the installation root. +pub(super) fn target_path(mount: &Utf8Path) -> Utf8PathBuf { + if mount.as_str().is_empty() { + VAR.into() + } else { + Utf8Path::new(VAR).join(mount) + } +} + +/// What to do about /var in the target of `install to-filesystem`. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Plan { + /// Leave /var alone, as bootc did before supporting separate /var. + Disabled, + /// Initialize whatever filesystems the caller already mounted at or below /var. + CallerMounted, + /// Mount this source at /var, then initialize it. + Mount(String), +} + +/// Filesystems that we are willing to mount on /var when we discover a +/// Variable Data Partition, as opposed to e.g. LUKS, LVM or swap signatures. +const MOUNTABLE_FSTYPES: &[&str] = &["ext2", "ext3", "ext4", "xfs", "btrfs", "f2fs"]; + +/// Decide the plan from `--var-mount-spec` (or its config file equivalent). +/// +/// An empty spec disables everything, including discovery of a DPS Variable +/// Data Partition on the disk(s) backing the root, which is used when no +/// spec was given and nothing is mounted at or below /var yet. +/// +/// If `cannot_record` is set, the installed system has no way to be told to +/// mount /var (sealed UKIs have a fixed command line), so a spec is an +/// error and discovery is skipped. +pub(super) fn plan( + configured: Option<&str>, + caller_mounts: bool, + cannot_record: bool, + find_var_partition: impl FnOnce() -> Result>, +) -> Result { + match configured { + Some("") => return Ok(Plan::Disabled), + Some(spec) if cannot_record => bail!( + "Cannot mount {spec} on /var: this installation cannot record the mount \ + (composefs with a UKI has a fixed kernel command line); mount it before \ + installing and set it up in the image instead" + ), + Some(spec) => return Ok(Plan::Mount(spec.to_owned())), + None if caller_mounts => return Ok(Plan::CallerMounted), + None => {} + } + // Discovery is opportunistic: failing to probe the disks is not a reason + // to fail an install that never asked for a separate /var. + let part = match find_var_partition() { + Ok(Some(part)) => part, + Ok(None) => return Ok(Plan::CallerMounted), + Err(e) => { + tracing::warn!("Looking for a Variable Data Partition: {e:#}"); + return Ok(Plan::CallerMounted); + } + }; + if cannot_record { + tracing::warn!( + "Ignoring Variable Data Partition {}: the mount cannot be recorded in a composefs UKI", + part.path() + ); + return Ok(Plan::CallerMounted); + } + // gpt-auto-generator only mounts a /var partition whose PARTUUID matches + // the machine-id, so we always record the mount ourselves. + match (&part.partuuid, part.fstype.as_deref()) { + (Some(partuuid), Some(fstype)) if MOUNTABLE_FSTYPES.contains(&fstype) => { + println!("Using Variable Data Partition {} for /var", part.path()); + Ok(Plan::Mount(format!("PARTUUID={partuuid}"))) + } + _ => { + tracing::warn!( + "Ignoring Variable Data Partition {}: its contents ({}) are not a supported filesystem", + part.path(), + part.fstype.as_deref().unwrap_or("empty") + ); + Ok(Plan::CallerMounted) + } + } +} + +/// A /var filesystem that bootc mounted itself. Dropping it undoes the mount +/// (and the creation of the mountpoint), so an error does not leave it behind. +#[derive(Debug)] +pub(super) struct Mounted { + /// How the booted system should mount it. + pub(super) spec: MountSpec, + root_path: Utf8PathBuf, + root: Dir, + /// We mounted it (it was not already a mountpoint). + mounted: Cell, + /// We created the mountpoint directory. + created: Cell, +} + +/// Mount `source` on the target's /var, unless something is mounted there already. +pub(super) fn mount(root_path: &Utf8Path, root: &Dir, source: &str) -> Result { + let var = root_path.join(VAR); + let existing = discover(root_path)?; + let var_is_mount = existing.iter().any(|m| m.as_str().is_empty()); + // Mounting over caller mounts below /var would hide them. + if !var_is_mount && !existing.is_empty() { + bail!( + "Cannot mount {source} on {var}: {} is already mounted below it", + root_path.join(target_path(&existing[0])) + ); + } + let created = match root.symlink_metadata_optional(VAR)? { + Some(meta) if meta.is_dir() => false, + Some(_) => bail!("{var} exists but is not a directory"), + None => { + root.create_dir(VAR) + .with_context(|| format!("Creating {var}"))?; + true + } + }; + if var_is_mount { + check_mounted_matches(&var, source)?; + } + let guard = Mounted { + spec: MountSpec::new(source, "/var"), + root_path: root_path.to_owned(), + root: root.try_clone()?, + mounted: Cell::new(false), + created: Cell::new(created), + }; + if !var_is_mount { + println!("Mounting {source} on {var}"); + bootc_mount::mount(source, &var)?; + guard.mounted.set(true); + } + Ok(guard) +} + +/// Resolve a mount source (`/dev/...`, `UUID=...`, `LABEL=...`) to a canonical device path. +fn resolve_source(source: &str) -> Result { + let dev = if source.starts_with('/') { + source.to_owned() + } else { + Command::new("findfs") + .arg(source) + .run_get_string() + .with_context(|| format!("Resolving {source}"))? + .trim() + .to_owned() + }; + Utf8Path::new(&dev) + .canonicalize_utf8() + .with_context(|| format!("Resolving {dev}")) +} + +/// A caller already mounted /var: make sure it is the filesystem the +/// spec names, since we record the spec for the booted system. +fn check_mounted_matches(var: &Utf8Path, source: &str) -> Result<()> { + let mounted = bootc_mount::inspect_filesystem(var)?; + // btrfs sources look like /dev/vda3[/subvol] + let mounted_source = mounted.source.split('[').next().unwrap_or_default(); + if !mounted_source.starts_with("/dev/") { + bail!( + "/var is already mounted from {} (not a block device), which cannot match \ + --var-mount-spec {source}", + mounted.source + ); + } + if resolve_source(source)? != resolve_source(mounted_source)? { + bail!( + "/var is already mounted from {} but --var-mount-spec names {source}", + mounted.source + ); + } + Ok(()) +} + +impl Mounted { + /// Undo [`mount`], leaving the target as we found it. + pub(super) fn unmount(&self) -> Result<()> { + let path = self.root_path.join(VAR); + if self.mounted.get() { + Command::new("umount") + .arg(&path) + .run_inherited_with_cmd_context()?; + self.mounted.set(false); + } + if self.created.get() { + self.root + .remove_dir(VAR) + .with_context(|| format!("Removing {path}"))?; + self.created.set(false); + } + Ok(()) + } +} + +impl Drop for Mounted { + fn drop(&mut self) { + if let Err(e) = self.unmount() { + tracing::warn!("Cleaning up /var mount: {e:#}"); + // Do not leave a mounted /var behind on the target: detach it lazily. + if self.mounted.get() { + let path = self.root_path.join(VAR); + match Command::new("umount") + .arg("--lazy") + .arg(&path) + .run_inherited() + { + Ok(()) => { + self.mounted.set(false); + if let Err(e) = self.unmount() { + tracing::warn!("Cleaning up /var mountpoint: {e:#}"); + } + } + Err(e) => tracing::warn!("Lazily unmounting {path}: {e:#}"), + } + } + } + } +} + +/// Mount paths relative to /var, including the empty path for /var itself. +pub(super) fn discover(root: &Utf8Path) -> Result> { + let root = root + .canonicalize_utf8() + .context("Resolving installation root")?; + let mounts = + bootc_mount::run_findmnt(&["--submounts", "--mountpoint"], None, Some(root.as_str()))?; + let mut paths = Vec::new(); + collect(&root.join(VAR), &mounts.filesystems, &mut paths); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +fn collect(var: &Utf8Path, mounts: &[Filesystem], paths: &mut Vec) { + for mount in mounts { + // An error here only means the mount is outside the target's /var. + if let Ok(path) = Utf8Path::new(&mount.target).strip_prefix(var) { + paths.push(path.to_owned()); + } + if let Some(children) = &mount.children { + collect(var, children, paths); + } + } +} + +/// A fresh mount tree may contain lost+found and directories needed to reach +/// child mounts. Everything else is existing state, which we must preserve. +fn is_empty_mount_tree(dir: &Dir, path: &Utf8Path, mounts: &[Utf8PathBuf]) -> Result { + let context = || format!("Reading /{}", target_path(path)); + for entry in dir.entries().with_context(context)? { + let entry = entry.with_context(context)?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Ok(false); + }; + let child = path.join(name); + if !entry.file_type().with_context(context)?.is_dir() { + return Ok(false); + } + let child_dir = dir.open_dir(name).with_context(context)?; + if name == LOST_AND_FOUND && mounts.iter().any(|m| m == path) { + let mut entries = child_dir.entries().with_context(context)?; + if entries.next().transpose().with_context(context)?.is_some() { + return Ok(false); + } + } else if mounts.iter().any(|m| m.starts_with(&child)) { + if !is_empty_mount_tree(&child_dir, &child, mounts)? { + return Ok(false); + } + } else { + return Ok(false); + } + } + Ok(true) +} + +/// Check that a mountpoint and its ancestors are directories (not symlinks) +/// in the image's /var. Mounting on a symlinked path would not correspond to +/// the image's /var layout. Returns false if the image lacks the path. +fn check_source_path(source: &Dir, path: &Utf8Path) -> Result { + let mut dir = source.try_clone()?; + let mut walked = Utf8PathBuf::new(); + for component in path.iter() { + walked.push(component); + let Some(meta) = dir.symlink_metadata_optional(component)? else { + return Ok(false); + }; + if !meta.is_dir() { + bail!( + "Cannot initialize /{}: /{} in the image is a symlink or not a directory", + target_path(path), + target_path(&walked) + ); + } + dir = dir.open_dir(component)?; + } + Ok(true) +} + +/// Copy each top-level mount tree once, with all its nested mounts in place. +/// Existing state is never merged or replaced. A tree with existing content +/// (including content in a nested mount) is left untouched in its entirety. +/// +/// Returns the mounts that were initialized, or left empty because the image +/// has no content for them. These still need labeling and finalization. +pub(super) fn populate( + root: &Dir, + source: &Utf8Path, + mounts: &[Utf8PathBuf], +) -> Result> { + let mut prepared = Vec::new(); + if mounts.is_empty() { + return Ok(prepared); + } + let source_dir = root + .open_dir(source) + .with_context(|| format!("Opening {source}"))?; + for mount in mounts { + if mounts + .iter() + .any(|parent| parent != mount && mount.starts_with(parent)) + { + continue; + } + let tree: Vec<_> = mounts + .iter() + .filter(|m| m.starts_with(mount)) + .cloned() + .collect(); + let target = target_path(mount); + let target_dir = root + .open_dir(&target) + .with_context(|| format!("Opening /{target}"))?; + if !is_empty_mount_tree(&target_dir, mount, mounts)? { + println!("Preserving existing contents of /{target} and its child mounts"); + continue; + } + if !check_source_path(&source_dir, mount)? { + println!("No initial content for /{target} in the image"); + prepared.extend(tree); + continue; + } + // Also validate nested mounts, which cp would otherwise reach implicitly. + for nested in tree.iter().filter(|m| *m != mount) { + check_source_path(&source_dir, nested)?; + } + println!("Initializing /{target} from the image"); + // cp -a preserves ownership, modes, and xattrs (including SELinux + // labels). Hardlinks cannot span destination filesystems: materialize + // regular files independently when the destination has nested mounts. + let mut cp = Command::new("cp"); + cp.args(["--archive", "--reflink=auto"]); + if tree.len() > 1 { + cp.arg("--no-preserve=links"); + } + cp.arg("--") + .arg(source.join(mount).join(".")) + .arg(target.join(".")) + .cwd_dir(root.try_clone()?) + .run_capture_stderr() + .with_context(|| format!("Initializing /{target}"))?; + prepared.extend(tree); + } + Ok(prepared) +} + +/// Flush the filesystems of the given mounts, surfacing writeback errors. +pub(super) fn sync(root: &Dir, mounts: &[Utf8PathBuf]) -> Result<()> { + for mount in mounts { + let path = target_path(mount); + // cap-std opens directories with O_PATH, which syncfs() rejects. + let fd = rustix::fs::openat( + root, + path.as_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .with_context(|| format!("Opening /{path}"))?; + rustix::fs::syncfs(&fd).with_context(|| format!("Syncing /{path}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std::ambient_authority; + use cap_std_ext::cap_std::fs::{MetadataExt, Permissions, PermissionsExt}; + use cap_std_ext::cap_tempfile::TempDir; + + fn partition(fstype: Option<&str>) -> bootc_blockdev::Device { + serde_json::from_value(serde_json::json!({ + "name": "vda4", "path": "/dev/vda4", "size": 1, "partuuid": "abcd", + "fstype": fstype, + })) + .unwrap() + } + + #[test] + fn test_plan() { + let none = || Ok(None); + let part = |fstype| move || Ok(Some(partition(fstype))); + let mount = |s: &str| Plan::Mount(s.into()); + // An empty spec wins over discovery; an explicit one skips it. + assert_eq!( + plan(Some(""), false, false, part(Some("ext4"))).unwrap(), + Plan::Disabled + ); + assert_eq!( + plan(Some("UUID=1"), false, false, none).unwrap(), + mount("UUID=1") + ); + assert_eq!(plan(None, false, false, none).unwrap(), Plan::CallerMounted); + // Discovery needs a filesystem we can mount + assert_eq!( + plan(None, false, false, part(Some("xfs"))).unwrap(), + mount("PARTUUID=abcd") + ); + for fstype in [None, Some("crypto_LUKS"), Some("swap"), Some("LVM2_member")] { + assert_eq!( + plan(None, false, false, part(fstype)).unwrap(), + Plan::CallerMounted, + "{fstype:?}" + ); + } + // Discovery is for when the caller mounted nothing at /var + assert_eq!( + plan(None, true, false, part(Some("ext4"))).unwrap(), + Plan::CallerMounted + ); + // Without a way to record the mount, discovery is skipped and a spec is an error + assert_eq!( + plan(None, false, true, part(Some("ext4"))).unwrap(), + Plan::CallerMounted + ); + assert!(plan(Some("UUID=1"), false, true, none).is_err()); + assert_eq!(plan(Some(""), false, true, none).unwrap(), Plan::Disabled); + // Failing to look for a partition is not fatal, unless a spec was given + let fails = || anyhow::bail!("lsblk failed"); + assert_eq!( + plan(None, false, false, fails).unwrap(), + Plan::CallerMounted + ); + assert_eq!( + plan(Some("UUID=1"), false, false, fails).unwrap(), + mount("UUID=1") + ); + } + + #[test] + fn populate_var_mounts() -> Result<()> { + for (mounts, existing) in [ + (vec![""], false), + (vec!["", "opt", "opt/app"], false), + (vec!["opt", "opt/app"], false), + (vec!["opt", "opt/app"], true), + (vec!["opt/app"], false), + ] { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt/app")?; + root.write("state/var/opt/app/seed", "image seed")?; + root.set_permissions("state/var/opt/app/seed", Permissions::from_mode(0o640))?; + root.write("state/var/opt/parent", "parent seed")?; + root.symlink_contents("seed", "state/var/opt/app/link")?; + let mounts: Vec = mounts.into_iter().map(Into::into).collect(); + for mount in &mounts { + root.create_dir_all(Utf8Path::new("var").join(mount).join("lost+found"))?; + } + if existing { + root.write("var/opt/app/seed", "existing state")?; + } + let initialized = populate(&root, "state/var".into(), &mounts)?; + assert_eq!(initialized, if existing { vec![] } else { mounts.clone() }); + assert_eq!( + root.read_to_string("var/opt/app/seed")?, + if existing { + "existing state" + } else { + "image seed" + } + ); + if existing { + assert!(!root.try_exists("var/opt/parent")?); + } else { + assert_eq!(root.metadata("var/opt/app/seed")?.mode() & 0o777, 0o640); + assert_eq!( + root.read_link("var/opt/app/link")?, + std::path::Path::new("seed") + ); + } + // A later invocation must not merge a different image into state. + root.write("state/var/opt/app/new-file", "new image")?; + assert!(populate(&root, "state/var".into(), &mounts)?.is_empty()); + assert!(!root.try_exists("var/opt/app/new-file")?); + } + Ok(()) + } + + #[test] + fn prepare_unseeded_mount() -> Result<()> { + // The image has no /var/srv; the empty mount still needs labeling. + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt")?; + root.create_dir_all("var/srv/lost+found")?; + let mounts = vec![Utf8PathBuf::from("srv")]; + assert_eq!(populate(&root, "state/var".into(), &mounts)?, mounts); + Ok(()) + } + + #[test] + fn source_symlink() -> Result<()> { + // Only trees that would be initialized are validated. + for existing in [false, true] { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var")?; + root.create_dir_all("var/opt/app")?; + root.symlink_contents("/outside", "state/var/opt")?; + if existing { + root.write("var/opt/app/data", "existing state")?; + } + let result = populate(&root, "state/var".into(), &["opt/app".into()]); + if existing { + assert!(result?.is_empty()); + } else { + assert_eq!( + result.unwrap_err().to_string(), + "Cannot initialize /var/opt/app: /var/opt in the image is a symlink or not a directory" + ); + } + } + Ok(()) + } + + #[test] + fn empty_mount_tree() -> Result<()> { + // Entries below /var (a trailing slash denotes a directory), mounts + // relative to /var, and whether the /var tree counts as empty. + let cases: &[(&[&str], &[&str], bool)] = &[ + (&[], &[""], true), + (&["lost+found/"], &[""], true), + (&["lost+found/", "lost+found/x"], &[""], false), + (&["file"], &[""], false), + (&["tmp/"], &[""], false), + ( + &["lib/", "lib/app/", "lib/app/lost+found/"], + &["", "lib/app"], + true, + ), + (&["lib/", "lib/lost+found/"], &["", "lib/app"], false), + (&["lib/", "tmp/"], &["", "lib/app"], false), + (&["lib/app/", "lib/app/state"], &["", "lib/app"], false), + ]; + for (entries, mounts, expected) in cases { + let root = TempDir::new(ambient_authority())?; + for entry in *entries { + match entry.strip_suffix('/') { + Some(dir) => root.create_dir_all(dir)?, + None => root.write(entry, "data")?, + } + } + let mounts: Vec = mounts.iter().map(|m| Utf8PathBuf::from(*m)).collect(); + assert_eq!( + is_empty_mount_tree(&root, "".into(), &mounts)?, + *expected, + "{entries:?} {mounts:?}" + ); + } + Ok(()) + } + + #[test] + fn sync_mounts() -> Result<()> { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("var/lib/app")?; + sync(&root, &["".into(), "lib/app".into()])?; + let err = sync(&root, &["missing".into()]).unwrap_err(); + assert_eq!(err.to_string(), "Opening /var/missing"); + Ok(()) + } + + #[test] + fn target_paths() { + assert_eq!(target_path("".into()), "var"); + assert_eq!(target_path("lib/app".into()), "var/lib/app"); + } + + #[test] + fn preserve_non_utf8_state() -> Result<()> { + use std::os::unix::ffi::OsStrExt; + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt")?; + root.write("state/var/opt/seed", "image")?; + root.create_dir_all("var/opt")?; + let target = root.open_dir("var/opt")?; + let name = std::ffi::OsStr::from_bytes(b"existing-\xff"); + target.write(name, "existing state")?; + populate(&root, "state/var".into(), &["opt".into()])?; + assert_eq!(target.read_to_string(name)?, "existing state"); + assert!(!target.try_exists("seed")?); + Ok(()) + } + + #[test] + fn collect_only_target_var_mounts() -> Result<()> { + let mounts: bootc_mount::Findmnt = serde_json::from_value(serde_json::json!({ + "filesystems": [{ + "source": "/dev/root", "target": "/target with spaces", "maj:min": "0:1", + "fstype": "ext4", "options": "rw", "children": [ + {"source": "/dev/a", "target": "/target with spaces/var/opt", + "maj:min": "0:2", "fstype": "ext4", "options": "rw", "children": [ + {"source": "/dev/b", "target": "/target with spaces/var/opt/app", + "maj:min": "0:3", "fstype": "xfs", "options": "rw"} + ]}, + {"source": "/dev/c", "target": "/target with spaces/variable", + "maj:min": "0:4", "fstype": "ext4", "options": "rw"}, + {"source": "/dev/d", "target": "/other/var/opt", + "maj:min": "0:5", "fstype": "ext4", "options": "rw"} + ] + }] + }))?; + let mut paths = Vec::new(); + collect( + "/target with spaces/var".into(), + &mounts.filesystems, + &mut paths, + ); + assert_eq!(paths, vec![Utf8PathBuf::from("opt"), "opt/app".into()]); + Ok(()) + } + + #[test] + #[ignore = "requires a private user/mount namespace; run with unshare -Urnm"] + fn mounted_var_filesystems() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = Utf8Path::from_path(temp.path()).unwrap(); + Command::new("mount") + .args(["--bind", path.as_str(), path.as_str()]) + .run_capture_stderr()?; + let result = (|| -> Result<()> { + assert!(discover(path)?.is_empty()); + let root = Dir::open_ambient_dir(path, ambient_authority())?; + root.create_dir_all("state/var/opt/app")?; + root.write("state/var/opt/app/seed", "seed")?; + root.set_permissions("state/var/opt/app/seed", Permissions::from_mode(0o640))?; + root.hard_link("state/var/opt/app/seed", &root, "state/var/parent-link")?; + root.hard_link( + "state/var/opt/app/seed", + &root, + "state/var/opt/app/same-volume-link", + )?; + root.symlink_contents("seed", "state/var/opt/app/symlink")?; + root.create_dir("var")?; + Command::new("mount") + .args(["-t", "tmpfs", "tmpfs"]) + .arg(path.join("var")) + .run_capture_stderr()?; + root.create_dir_all("var/opt/app")?; + Command::new("mount") + .args(["-t", "tmpfs", "tmpfs"]) + .arg(path.join("var/opt/app")) + .run_capture_stderr()?; + let mounts = discover(path)?; + assert_eq!(mounts, vec![Utf8PathBuf::new(), "opt/app".into()]); + assert_eq!(populate(&root, "state/var".into(), &mounts)?, mounts); + for file in [ + "var/parent-link", + "var/opt/app/seed", + "var/opt/app/same-volume-link", + ] { + assert_eq!(root.read_to_string(file)?, "seed"); + assert_eq!(root.metadata(file)?.mode() & 0o777, 0o640); + } + assert_ne!( + root.metadata("var/parent-link")?.dev(), + root.metadata("var/opt/app/seed")?.dev() + ); + assert_eq!( + root.read_link("var/opt/app/symlink")?, + std::path::Path::new("seed") + ); + root.write("var/opt/app/seed", "existing state")?; + assert!(populate(&root, "state/var".into(), &mounts)?.is_empty()); + assert_eq!(root.read_to_string("var/opt/app/seed")?, "existing state"); + Ok(()) + })(); + let unmount = bootc_mount::unmount_recursive(path); + result?; + unmount + } +} diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index d43e00d53f..7af83babbb 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -24,6 +24,13 @@ pub(crate) fn test_bootc_status() -> Result<()> { Ok(()) } +pub(crate) fn test_bootc_version_features() -> Result<()> { + let sh = Shell::new()?; + let version = cmd!(sh, "bootc --version").read()?; + assert!(version.contains("\n - install-var-mount"), "{version}"); + Ok(()) +} + pub(crate) fn test_bootc_container_inspect() -> Result<()> { let sh = Shell::new()?; let inspect: serde_json::Value = @@ -546,6 +553,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("install config", test_bootc_install_config), new_test("printconfig --all", test_bootc_install_config_all), new_test("status", test_bootc_status), + new_test("version features", test_bootc_version_features), new_test("container inspect", test_bootc_container_inspect), new_test("system-reinstall --help", test_system_reinstall_help), new_test("container export tar", test_container_export_tar), diff --git a/docs/src/man/bootc-install-config.5.md b/docs/src/man/bootc-install-config.5.md index 7211c4aab7..8beb5d315b 100644 --- a/docs/src/man/bootc-install-config.5.md +++ b/docs/src/man/bootc-install-config.5.md @@ -40,6 +40,10 @@ The `install` section supports these subfields: - `boot-mount-spec`: A string specifying the /boot filesystem mount specification. If not provided and /boot is a separate mount, its UUID will be used. An empty string signals to omit boot mount kargs entirely. +- `var-mount-spec`: A string specifying the /var filesystem mount specification. + If not provided, a Variable Data Partition on the disk backing the root is + used, if there is one. An empty value disables that discovery and all /var handling. + Only `install to-filesystem` uses it; `install to-disk` ignores it. - `discoverable-partitions`: Boolean. When `true`, root discovery uses the Discoverable Partitions Specification via `systemd-gpt-auto-generator` and the `root=` kernel argument is omitted. This requires the bootloader to diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 2d2b8b6f62..5ed4bc9fd8 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -16,6 +16,45 @@ necessary platform partitions (such as the EFI system partition) are prepared and mounted by an external tool or script. The root filesystem is currently expected to be empty by default. +To use a separate `/var`, pass `--var-mount-spec` (for example +`UUID=...` or a device path): bootc mounts it at `/var` in the target, initializes +it as described below, and records it in `/etc/fstab` (or, with the composefs +backend, a `systemd.mount-extra` kernel argument) for the installed system. If +a Variable Data Partition (as defined by the Discoverable Partitions +Specification) exists on the disk backing the root filesystem, it is used by +default. An empty value disables that discovery and all /var handling, +keeping the image's `/var` in the root filesystem's state directory. +With the composefs backend and a UKI, the installed system's kernel command +line is fixed, so the mount cannot be recorded: an explicit spec is an error +and discovery is skipped. `--var-mount-spec` is also an error when installing +to the host root, over an existing ostree system or with `--replace=alongside`. + +Tools can tell whether a bootc binary supports this by looking for +`install-var-mount` in the `Features:` list of `bootc --version`. It covers +both /var filesystems that bootc mounts (`--var-mount-spec` or a discovered +Variable Data Partition) and ones the caller mounted; older versions leave +such filesystems empty, or reject them entirely. + +Alternatively, mount filesystems for `/var` or its subdirectories beneath *ROOT_PATH* +before invoking this command. On a fresh installation, bootc initializes +empty mounted trees from the image's initial `/var` contents, including +nested mounts. Empty `lost+found` directories and directories needed to reach +nested mountpoints do not prevent initialization. Within a tree being +initialized, mountpoints must resolve to directories in the image, not +symlinks. A mount for which the image has no content is left empty. +Filesystems that bootc initializes or leaves empty are SELinux labeled and +synced. Unlike the root filesystem, they are not remounted read-only. +When a tree includes nested filesystems, regular files are copied independently +instead of preserving hardlinks, which cannot span filesystems. Symbolic links, +ownership, permissions, and extended attributes are preserved. + +If a mounted tree or any of its nested mounts already contains data, bootc +preserves that entire tree without merging image contents into it. Alongside +installations do not initialize external `/var` filesystems. This does not +change upgrade semantics: subsequent image updates do not update `/var`. +The caller remains responsible for configuring `/etc/fstab` or mount units +so the filesystems are mounted at the same locations on subsequent boots. + # OPTIONS @@ -33,6 +72,10 @@ is currently expected to be empty by default. Mount specification for the /boot filesystem +**--var-mount-spec**=*VAR_MOUNT_SPEC* + + Source device specification to mount at /var, such as `UUID=...`, `LABEL=...` or a device path + **--replace**=*REPLACE* Initialize the system in-place; at the moment, only one mode for this is implemented. In the future, it may also be supported to set up an explicit "dual boot" system diff --git a/tmt/tests/booted/bootc_testlib.nu b/tmt/tests/booted/bootc_testlib.nu index 2560d9b13b..9eced883f8 100644 --- a/tmt/tests/booted/bootc_testlib.nu +++ b/tmt/tests/booted/bootc_testlib.nu @@ -52,7 +52,7 @@ export def maybe_upgrade [] { } # Save the pre-upgrade bootc version so post-upgrade tests # can detect known incompatibilities with older versions. - let pre_ver = (bootc --version | parse "bootc {v}" | get 0.v) + let pre_ver = (bootc --version | lines | first | parse "bootc {v}" | get 0.v) $pre_ver | save /var/bootc-pre-upgrade-version print $"Pre-upgrade bootc version: ($pre_ver)" diff --git a/tmt/tests/booted/test-49-composefs-1-16-bridge.nu b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu index 0f209313c2..f1f5c42ad6 100644 --- a/tmt/tests/booted/test-49-composefs-1-16-bridge.nu +++ b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu @@ -60,7 +60,7 @@ def required-old-bootc-sha256 [] { } def assert-old-fixture [] { - let version = (bootc --version | str trim) + let version = (bootc --version | lines | first | str trim) assert equal $version "bootc 1.16.0" let rpm_version = (rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' bootc | str trim) let binary_sha256 = (sha256sum /usr/bin/bootc | split row " " | first | str downcase) diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index c64d70c181..a32f93e3a9 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -28,6 +28,14 @@ bootc image copy-to-storage cat > /tmp/Containerfile.drop-lbis <<'EOF' FROM localhost/bootc as base RUN rm -rf /usr/lib/bootc/bound-images.d/* +RUN mkdir -p /var/lib/bootc-var-test/child && \ + printf 'parent seed\n' > /var/lib/bootc-var-test/parent && \ + printf 'child seed\n' > /var/lib/bootc-var-test/child/seed && \ + chmod 0640 /var/lib/bootc-var-test/child/seed && \ + chown 1001:1002 /var/lib/bootc-var-test/child/seed && \ + ln /var/lib/bootc-var-test/child/seed /var/lib/bootc-var-test/crosslink && \ + ln /var/lib/bootc-var-test/child/seed /var/lib/bootc-var-test/child/hardlink && \ + ln -s seed /var/lib/bootc-var-test/child/symlink EOF is_composefs=$(bootc status --json | jq '.status.booted.composefs') @@ -112,8 +120,10 @@ parted -s "$LOOP_DEV" mkpart primary fat32 2MiB 1026MiB parted -s "$LOOP_DEV" set 2 esp on # Boot partition (1 GiB) parted -s "$LOOP_DEV" mkpart primary ext4 1026MiB 2052MiB -# LVM partition (rest of disk) -parted -s "$LOOP_DEV" mkpart primary 2052MiB 100% +# LVM partition +parted -s "$LOOP_DEV" mkpart primary 2052MiB 90% +# A spare partition, made a DPS Variable Data Partition by the later scenarios +parted -s "$LOOP_DEV" mkpart primary ext4 90% 100% # Reload partition table partprobe "$LOOP_DEV" @@ -123,6 +133,9 @@ sleep 2 EFI_PART="${LOOP_DEV}p2" BOOT_PART="${LOOP_DEV}p3" LVM_PART="${LOOP_DEV}p4" +VAR_PART="${LOOP_DEV}p5" +# Partition type GUID of a DPS Variable Data Partition +DPS_VAR_TYPE=4d21b016-b534-45c2-a9fb-5c16e091fd2d # Create filesystems on boot partitions mkfs.vfat -F32 "$EFI_PART" @@ -134,32 +147,40 @@ vgcreate BL "$LVM_PART" # Create logical volumes lvcreate -L 4G -n var02 BL +lvcreate -L 256M -n child02 BL lvcreate -l 100%FREE -n root02 BL # Create filesystems on logical volumes mkfs.ext4 -F /dev/BL/var02 +mkfs.ext4 -F /dev/BL/child02 mkfs.ext4 -F /dev/BL/root02 # Get UUIDs for bootc install ROOT_UUID=$(blkid -s UUID -o value /dev/BL/root02) -BOOT_UUID=$(blkid -s UUID -o value "$EFI_PART") - -# Mount the partitions -mkdir -p /var/mnt/target -mount /dev/BL/root02 /var/mnt/target -mkdir -p /var/mnt/target/boot -mount "$BOOT_PART" /var/mnt/target/boot -mkdir -p /var/mnt/target/boot/efi -mount "$EFI_PART" /var/mnt/target/boot/efi +BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") + +# Mount root, /boot and the ESP (but no /var) the way an installer would +mount_target() { + mkdir -p /var/mnt/target + mount /dev/BL/root02 /var/mnt/target + mkdir -p /var/mnt/target/boot + mount "$BOOT_PART" /var/mnt/target/boot + mkdir -p /var/mnt/target/boot/efi + mount "$EFI_PART" /var/mnt/target/boot/efi + + # Create EFI directory structure with some files (simulating existing EFI content) + mkdir -p /var/mnt/target/boot/efi/EFI/fedora + touch /var/mnt/target/boot/efi/EFI/fedora/shimx64.efi + touch /var/mnt/target/boot/efi/EFI/fedora/grubx64.efi +} -# Create EFI directory structure with some files (simulating existing EFI content) -mkdir -p /var/mnt/target/boot/efi/EFI/fedora -touch /var/mnt/target/boot/efi/EFI/fedora/shimx64.efi -touch /var/mnt/target/boot/efi/EFI/fedora/grubx64.efi +mount_target # Critical: Mount /var as a separate partition mkdir -p /var/mnt/target/var mount /dev/BL/var02 /var/mnt/target/var +mkdir -p /var/mnt/target/var/lib/bootc-var-test/child +mount /dev/BL/child02 /var/mnt/target/var/lib/bootc-var-test/child echo "Filesystem layout:" mount | grep /var/mnt/target || true @@ -182,45 +203,160 @@ if [[ $is_composefs != "null" ]]; then fi fi -# Run bootc install to-filesystem from within the container image under test -podman run \ - --rm --privileged \ - -v /var/mnt/target:/target \ - -v /dev:/dev \ - --pid=host \ - --security-opt label=type:unconfined_t \ - "$TARGET_IMAGE" \ - bootc install to-filesystem \ - --disable-selinux \ - "${COMPOSEFS_BACKEND_PARAMS[@]}" \ - "${KARGS[@]}" \ - --root-mount-spec=UUID="$ROOT_UUID" \ - --boot-mount-spec=UUID="$BOOT_UUID" \ - /target +run_install() { + # Run bootc install to-filesystem from within the container image under test + podman run \ + --rm --privileged \ + -v /var/mnt/target:/target \ + -v /dev:/dev \ + --pid=host \ + --security-opt label=type:unconfined_t \ + "$TARGET_IMAGE" \ + bootc install to-filesystem \ + "${COMPOSEFS_BACKEND_PARAMS[@]}" \ + "${KARGS[@]}" \ + --root-mount-spec=UUID="$ROOT_UUID" \ + --boot-mount-spec=UUID="$BOOT_UUID" \ + "$@" \ + /target +} -# Verify the installation succeeded -echo "Verifying installation..." +run_install -if [[ $is_composefs == "null" ]]; then - test -d /var/mnt/target/ostree - test -d /var/mnt/target/ostree/repo +# Verify the installation succeeded +verify_install() { + echo "Verifying installation..." - # Verify bootloader was installed (grub2 or loader for different configurations) - test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader -else - test -d /var/mnt/target/composefs + if [[ $is_composefs == "null" ]]; then + test -d /var/mnt/target/ostree + test -d /var/mnt/target/ostree/repo - # TODO(Johan-Liebert1): This is getting bootloader from the VM, which is not quite correct - # It works for now as the CI runs separately for each bootloader, but we need to get the - # bootloader from the installed systemd if we wish to run the tests locally without rebuilding the images - # This probably also happens in other tests, one instance is install-outside-container - if [[ $bootloader == "grub" ]]; then + # Verify bootloader was installed (grub2 or loader for different configurations) test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader else - test -d /var/mnt/target/boot/efi/EFI - test -d /var/mnt/target/boot/efi/loader/entries + test -d /var/mnt/target/composefs + + # TODO(Johan-Liebert1): This is getting bootloader from the VM, which is not quite correct + # It works for now as the CI runs separately for each bootloader, but we need to get the + # bootloader from the installed systemd if we wish to run the tests locally without rebuilding the images + # This probably also happens in other tests, one instance is install-outside-container + if [[ $bootloader == "grub" ]]; then + test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader + else + test -d /var/mnt/target/boot/efi/EFI + test -d /var/mnt/target/boot/efi/loader/entries + fi fi +} + +verify_install + +# Check the volume contents, not just successful deployment. The nested LV +# must contain its own seed files rather than hide them on the parent LV. +test "$(cat /var/mnt/target/var/lib/bootc-var-test/parent)" = 'parent seed' +child=/var/mnt/target/var/lib/bootc-var-test/child +test "$(cat "$child/seed")" = 'child seed' +test "$(stat -c '%u:%g:%a' "$child/seed")" = '1001:1002:640' +test "$(readlink "$child/symlink")" = seed +test "$(cat "$child/hardlink")" = 'child seed' +test "$(cat /var/mnt/target/var/lib/bootc-var-test/crosslink)" = 'child seed' +if selinuxenabled; then + for path in /var /var/lib/bootc-var-test/child /var/lib/bootc-var-test/child/seed; do + test "$(stat -c '%C' "/var/mnt/target$path")" = "$(matchpathcon -n "$path")" + done fi +umount "$child" +test ! -e "$child/seed" +mount /dev/BL/child02 "$child" +test "$(cat "$child/seed")" = 'child seed' + +echo "Caller-mounted /var succeeded; now letting bootc mount /var itself" + +# Start over with fresh filesystems and no /var mounted by us +reset_target() { + umount -R /var/mnt/target + mkfs.vfat -F32 "$EFI_PART" + mkfs.ext4 -F "$BOOT_PART" + mkfs.ext4 -F /dev/BL/root02 + mkfs.ext4 -F /dev/BL/var02 + mkfs.ext4 -F "$VAR_PART" + if [[ $is_composefs != "null" ]]; then + tune2fs -O verity /dev/BL/root02 + fi + ROOT_UUID=$(blkid -s UUID -o value /dev/BL/root02) + BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") + mount_target +} +# Check that a filesystem got the image's /var content, and that the +# installed system is told to mount it at /var. +check_var_fs() { + local dev=$1 uuid_arg=$2 + mkdir -p /var/mnt/varcheck + mount "$dev" /var/mnt/varcheck + test "$(cat /var/mnt/varcheck/lib/bootc-var-test/parent)" = 'parent seed' + test "$(cat /var/mnt/varcheck/lib/bootc-var-test/child/seed)" = 'child seed' + test "$(stat -c '%u:%g:%a' /var/mnt/varcheck/lib/bootc-var-test/child/seed)" = '1001:1002:640' + umount /var/mnt/varcheck + # bootc unmounts what it mounted and removes the mountpoint it created. + # The mount happened in the container's namespace, so check that the + # directory is gone from the filesystem itself. + test ! -e /var/mnt/target/var + # The mount is recorded for the booted system + if [[ $is_composefs == "null" ]]; then + grep -rqE "^$uuid_arg /var " /var/mnt/target/ostree/deploy/default/deploy/*/etc/fstab + elif [[ $boot_type != "uki" ]]; then + grep -rq "systemd.mount-extra=$uuid_arg:/var:" /var/mnt/target/boot + fi +} + +# The installed system must not have been told to mount the spare partition +check_var_part_untouched() { + mkdir -p /var/mnt/varcheck + mount "$VAR_PART" /var/mnt/varcheck + test -z "$(ls -A /var/mnt/varcheck | grep -v '^lost+found$')" + umount /var/mnt/varcheck + if [[ $is_composefs == "null" ]]; then + if grep -qs ' /var ' /var/mnt/target/ostree/deploy/default/deploy/*/etc/fstab; then exit 1; fi + fi +} + +echo "Explicit --var-mount-spec" +reset_target +VAR_UUID=$(blkid -s UUID -o value /dev/BL/var02) +if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then + # A UKI has a fixed command line, so there is nowhere to record the mount + if run_install --var-mount-spec="UUID=$VAR_UUID"; then + echo "--var-mount-spec unexpectedly succeeded with a composefs UKI" + exit 1 + fi +else + run_install --var-mount-spec="UUID=$VAR_UUID" + verify_install + check_var_fs /dev/BL/var02 "UUID=$VAR_UUID" +fi + +echo "Discovery of a DPS Variable Data Partition" +reset_target +sfdisk --part-type "$LOOP_DEV" 5 "$DPS_VAR_TYPE" +partprobe "$LOOP_DEV" +udevadm settle +run_install +verify_install +if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then + # Discovery is skipped (with a warning) when the mount cannot be recorded + check_var_part_untouched +else + check_var_fs "$VAR_PART" "PARTUUID=$(blkid -s PARTUUID -o value "$VAR_PART")" +fi + +echo "An empty --var-mount-spec disables discovery" +reset_target +run_install --var-mount-spec= +verify_install +check_var_part_untouched +if [[ $is_composefs == "null" ]]; then + test "$(cat /var/mnt/target/ostree/deploy/default/var/lib/bootc-var-test/parent)" = 'parent seed' +fi -echo "Installation to-filesystem with separate /var mount succeeded!" +echo "Installation to-filesystem with separate /var mount and bootc-mounted /var succeeded!"