diff --git a/crates/lib/src/fixtures/repart-dry-run-v262.json b/crates/lib/src/fixtures/repart-dry-run-v262.json new file mode 100644 index 0000000000..6dcad93c2e --- /dev/null +++ b/crates/lib/src/fixtures/repart-dry-run-v262.json @@ -0,0 +1,58 @@ +[ + { + "type" : "esp", + "label" : "esp", + "uuid" : "1673706d-5a4f-4e8d-917e-39970edd4fad", + "partno" : 0, + "file" : "/usr/lib/repart.d/00-esp.conf", + "node" : "/work/fixtures/disk.img1", + "offset" : 1048576, + "old_size" : 0, + "raw_size" : 1073741824, + "old_padding" : 0, + "raw_padding" : 0, + "activity" : "create" + }, + { + "type" : "home", + "label" : "home", + "uuid" : "6fd25430-5f20-4104-bd38-e388b2b0d44e", + "partno" : 1, + "file" : "/usr/lib/repart.d/20-home.conf", + "node" : "/work/fixtures/disk.img2", + "offset" : 1074790400, + "old_size" : 0, + "raw_size" : 1073741824, + "old_padding" : 0, + "raw_padding" : 0, + "activity" : "create" + }, + { + "type" : "swap", + "label" : "swap", + "uuid" : "d7addad6-43a3-4acc-91b2-7c0c7c702ff6", + "partno" : 2, + "file" : "/usr/lib/repart.d/30-swap.conf", + "node" : "/work/fixtures/disk.img3", + "offset" : 2148532224, + "old_size" : 0, + "raw_size" : 1073741824, + "old_padding" : 0, + "raw_padding" : 0, + "activity" : "create" + }, + { + "type" : "root-x86-64", + "label" : "root-x86-64", + "uuid" : "9e3c0aa4-e4dc-4595-85ac-6a479e45bb8e", + "partno" : 3, + "file" : "/run/repart.d/50-root.conf", + "node" : "/work/fixtures/disk.img4", + "offset" : 3222274048, + "old_size" : 0, + "raw_size" : 7515123712, + "old_padding" : 0, + "raw_padding" : 0, + "activity" : "create" + } +] diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index f4d07c96ac..2b364c91aa 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -6,9 +6,12 @@ //! other more complex flows should set things up externally and use `bootc install to-filesystem`. use std::borrow::Cow; +use std::ffi::OsStr; +use std::ffi::OsString; use std::fmt::Display; use std::fmt::Write as _; use std::io::Write; +use std::num::NonZeroU64; use std::path::Path; use std::process::Command; use std::process::Stdio; @@ -115,7 +118,8 @@ pub(crate) struct InstallBlockDeviceOpts { /// Size of the root partition (default specifier: M). Allowed specifiers: M (mebibytes), G (gibibytes), T (tebibytes). /// - /// By default, all remaining space on the disk will be used. + /// By default, the root partition takes the remaining space, leaving room + /// for any other partitions the image's repart.d definitions describe. #[clap(long)] pub(crate) root_size: Option, } @@ -219,8 +223,6 @@ struct RepartPartition { partno: Option, #[serde(default)] raw_size: u64, - #[serde(default)] - raw_padding: u64, #[allow(dead_code)] fs: Option, /// The file used to generate this partition @@ -268,16 +270,31 @@ const REPART_MKFS_OPTIONS_MIN_VERSION: u32 = 254; /// [`REPART_INCLUDE_PARTITIONS_MIN_VERSION`]. const REPART_FILTERED_DEFINITIONS_DIR: &str = "/tmp/repart.d"; +/// The repart.d directory for definitions generated at runtime. bootc does +/// not write there: in `to-disk` it may be the host's. +const REPART_RUNTIME_DIR: &str = "/run/repart.d"; + /// The repart.d configuration search directories, in descending priority. A /// definition present in a higher-priority directory masks a same-named one /// below it (matching systemd's own semantics). const REPART_CONFIG_DIRS: &[&str] = &[ "/etc/repart.d", - "/run/repart.d", + REPART_RUNTIME_DIR, "/usr/local/lib/repart.d", "/usr/lib/repart.d", ]; +/// The name of the root partition definition we generate when the image's +/// repart.d definitions have none. +const REPART_GENERATED_ROOT_NAME: &str = "50-root.conf"; + +/// The name of the drop-in that pins a definition's partition to its planned +/// size. +const REPART_PLANNED_SIZE_DROPIN: &str = "99-bootc-planned-size.conf"; + +/// Bytes per MiB, the unit we pin the generated root partition's size in. +const MIB: u64 = 1024 * 1024; + /// Whether we must emulate `--include-partitions=` by pre-filtering the /// definitions ourselves fn need_filtered_definitions(generic_image: bool) -> Result { @@ -332,6 +349,97 @@ fn collect_repart_definitions(dry_partitions: &[RepartPartition]) -> Result<()> Ok(()) } +/// The size in MiB to pin the root partition we generate to, given a dry run of +/// all definitions in which it has no size limits. systemd-repart gives it the +/// space the other definitions leave, padding included, and shares that by +/// Weight= with any of them that can grow, so the partitions created only on +/// first boot still fit after it. Rounded down to whole MiB. +fn planned_root_size_mib(planned: &[RepartPartition]) -> Result { + let root = planned + .iter() + .find(|p| p.partition_type.starts_with("root-")) + .ok_or_else(|| { + anyhow::anyhow!( + "systemd-repart planned no root partition for {REPART_GENERATED_ROOT_NAME}" + ) + })?; + NonZeroU64::new(root.raw_size / MIB).ok_or_else(|| { + anyhow::anyhow!("systemd-repart planned less than 1 MiB for the root partition") + }) +} + +/// The partitions other than root that a generic image's install creates (the +/// ESP and BIOS boot), as the file name of their definition and the size in +/// bytes systemd-repart planned for them with all definitions. +/// +/// That install leaves the other partitions to first boot, so a definition +/// among these that can grow would take the space planned for them, unless it +/// is pinned to its planned size. +fn planned_boot_partition_sizes(planned: &[RepartPartition]) -> Result> { + planned + .iter() + .filter(|p| repart_partition_is_required(p) && !p.partition_type.starts_with("root")) + .map(|p| { + let name = Utf8Path::new(&p.file) + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid repart.d definition path {}", p.file))?; + Ok((name.to_owned(), p.raw_size)) + }) + .collect() +} + +/// The repart.d drop-in that pins a partition to `size` bytes. +fn planned_size_pin(size: u64) -> String { + format!("[Partition]\nSizeMinBytes={size}\nSizeMaxBytes={size}\n") +} + +/// Fail when one of the image's repart.d definitions, as listed by a dry run, +/// has the name of the root definition we generate. One of the two would mask +/// the other, so a partition would go missing from the layout. +fn ensure_generated_root_name_unused(definitions: &[RepartPartition]) -> Result<()> { + match definitions + .iter() + .find(|p| Utf8Path::new(&p.file).file_name() == Some(REPART_GENERATED_ROOT_NAME)) + { + Some(clash) => anyhow::bail!( + "repart.d definition {} has the name of the root partition definition bootc generates, rename it", + clash.file + ), + None => Ok(()), + } +} + +/// Write the repart.d definition or drop-in `name` into `dir`, creating parent +/// directories as needed. +fn write_repart_file(dir: &Path, name: &str, conf: &str) -> Result<()> { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Creating {}", parent.display()))?; + } + std::fs::write(&path, conf).with_context(|| format!("Writing {}", path.display())) +} + +/// The `--definitions=` arguments that make systemd-repart read the +/// definitions in `generated` along with the standard ones, with the priority +/// of [`REPART_RUNTIME_DIR`]. +fn repart_definitions_args(generated: &Path) -> Vec { + let mut dirs: Vec<&OsStr> = Vec::new(); + for dir in REPART_CONFIG_DIRS { + if *dir == REPART_RUNTIME_DIR { + dirs.push(generated.as_os_str()); + } + dirs.push(OsStr::new(dir)); + } + dirs.into_iter() + .map(|dir| { + let mut arg = OsString::from("--definitions="); + arg.push(dir); + arg + }) + .collect() +} + /// Create partitions using systemd-repart /// Assumes we have systemd-repart definitions #[context("Running systemd-repart")] @@ -344,7 +452,7 @@ fn systemd_repart( // Dry-run to check what partitions would be created // Send `generic_image` as false so that we can see ALL defined // partitions - let dry_partitions = systemd_repart_run(device, false, true)?; + let dry_partitions = systemd_repart_run(device, false, true, None)?; if dry_partitions.is_empty() { anyhow::bail!("systemd-repart returned empty partitions"); @@ -360,67 +468,61 @@ fn systemd_repart( collect_repart_definitions(&dry_partitions)?; } - let partitions = systemd_repart_run(device, generic_image, false)?; + let partitions = systemd_repart_run(device, generic_image, false, None)?; let layout = parse_repart_layout(&partitions)?; return Ok(layout); } // Root partition is not defined, create definition for the root part - let mut root_conf = String::from("[Partition]\nType=root\n"); + ensure_generated_root_name_unused(&dry_partitions)?; + let Some(fs) = rootfs else { + anyhow::bail!("Rootfs not specified"); + }; + let mut root_conf = format!("[Partition]\nType=root\nFormat={fs}\n"); + // Not in /run/repart.d, which may be the host's + let generated = tempfile::tempdir().context("Creating a directory for repart.d definitions")?; - match root_size { - Some(size_mib) => { - writeln!(root_conf, "SizeMinBytes={size_mib}M")?; - writeln!(root_conf, "SizeMaxBytes={size_mib}M")?; - } + let (size_mib, boot_partition_sizes) = match root_size { + Some(size_mib) => (size_mib, Vec::new()), None => { - let mb = 1024 * 1024; - // Save 64 MB as partition headroom for GPT headers - let partition_headroom = 64 * mb; - - // Installing to a disk, compute the root ptn size - // by taking all other partitions into account - // - // We're doing this to accommodate for partitions that are - // supposed to be crated on first boot, like home,var,swap etc - let space_taken = dry_partitions - .iter() - .fold(0u64, |acc, x| acc + x.raw_size + x.raw_padding); - let root_size_mib = (device.size - space_taken - partition_headroom) / mb; - + // Size root like systemd-repart would if it created all partitions + // now, and pin that: with a generic image the real run only creates + // root and the boot partitions, and an unsized root would take the + // space of the ones created on first boot. + write_repart_file(generated.path(), REPART_GENERATED_ROOT_NAME, &root_conf)?; + let planned = systemd_repart_run(device, false, true, Some(generated.path())) + .context("Planning the root partition size")?; + let size_mib = planned_root_size_mib(&planned)?.get(); tracing::debug!( - "space_taken: {} M, device.size: {} M, Root Size: {root_size_mib} M", - space_taken / mb, - device.size / mb + "Root partition size: {size_mib} MiB of {} MiB", + device.size / MIB ); - - writeln!(root_conf, "SizeMinBytes={root_size_mib}M")?; - writeln!(root_conf, "SizeMaxBytes={root_size_mib}M")?; + // For the same reason, pin the boot partitions of a generic image + // to the plan, in case one of them can grow. + let boot_partition_sizes = if generic_image { + planned_boot_partition_sizes(&planned)? + } else { + Vec::new() + }; + (size_mib, boot_partition_sizes) } }; + writeln!(root_conf, "SizeMinBytes={size_mib}M")?; + writeln!(root_conf, "SizeMaxBytes={size_mib}M")?; - match rootfs { - Some(fs) => writeln!(root_conf, "Format={fs}")?, - None => { - anyhow::bail!("Rootfs not specified") - } - } - - if need_filtered_definitions(generic_image)? { + let definitions_dir = if need_filtered_definitions(generic_image)? { collect_repart_definitions(&dry_partitions)?; - - std::fs::write( - Path::new(REPART_FILTERED_DEFINITIONS_DIR).join("50-root.conf"), - &root_conf, - ) - .context("Writing root repart config to filtered definitions")?; + Path::new(REPART_FILTERED_DEFINITIONS_DIR) } else { - std::fs::create_dir_all("/run/repart.d").context("Creating /run/repart.d")?; - std::fs::write("/run/repart.d/50-root.conf", &root_conf) - .context("Writing root repart config")?; + generated.path() + }; + write_repart_file(definitions_dir, REPART_GENERATED_ROOT_NAME, &root_conf)?; + for (name, size) in &boot_partition_sizes { + let dropin = format!("{name}.d/{REPART_PLANNED_SIZE_DROPIN}"); + write_repart_file(definitions_dir, &dropin, &planned_size_pin(*size))?; } - let partitions = systemd_repart_run(device, generic_image, false)?; + let partitions = systemd_repart_run(device, generic_image, false, Some(generated.path()))?; let layout = parse_repart_layout(&partitions)?; Ok(layout) @@ -428,12 +530,16 @@ fn systemd_repart( /// Run systemd-repart on the device and return the parsed JSON output. /// `dry_run`: if true, no changes are written to disk. +/// `generated`: a directory of definitions generated for this run, read +/// along with the image's (see [`repart_definitions_args`]). Unused with +/// filtered definitions, to which the caller adds the generated ones instead. /// `definitions`: if set, uses `--definitions=` and `--empty=allow`; /// otherwise uses the default config search paths with `--empty=force`. fn systemd_repart_run( device: &Device, generic_image: bool, dry_run: bool, + generated: Option<&Path>, ) -> Result> { let mut cmd = Command::new("systemd-repart"); @@ -461,12 +567,15 @@ fn systemd_repart_run( // don't know if the user would want to run those on this disk itself // or if this disk would be used to create an AMI/VHD and a separate disk // would be used for the other partitions - if generic_image { - if need_filtered_definitions(generic_image)? { - // Older systemd lacks --include-partitions; act only on the - // pre-filtered definitions collected by collect_repart_definitions. - cmd.arg(format!("--definitions={REPART_FILTERED_DEFINITIONS_DIR}")); - } else { + if need_filtered_definitions(generic_image)? { + // Older systemd lacks --include-partitions; act only on the + // pre-filtered definitions collected by collect_repart_definitions. + cmd.arg(format!("--definitions={REPART_FILTERED_DEFINITIONS_DIR}")); + } else { + if let Some(generated) = generated { + cmd.args(repart_definitions_args(generated)); + } + if generic_image { cmd.arg(format!("--include-partitions=root,esp,{BIOS_BOOT}")); } } @@ -959,3 +1068,121 @@ pub(crate) fn install_create_rootfs( skip_finalize: false, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_planned_root_size_mib() { + // This module imports anyhow::Ok + use std::result::Result::Ok; + let cases = [ + // A dry run on a 10 GiB disk of the definitions of + // tmt/tests/booted/test-install-repart.nu (ESP, home and swap, + // 1 GiB each) plus the generated root without size limits, which + // gets the rest minus the GPT and alignment overhead. + ( + include_str!("../fixtures/repart-dry-run-v262.json"), + Ok(7166), + ), + // A dry run without root, where systemd 262 reports the unused + // space as padding of the last partition. + ( + r#"[{"type": "swap", "file": "30-swap.conf", "raw_size": 1073741824, "raw_padding": 7515123712}]"#, + Err("planned no root partition"), + ), + ( + r#"[{"type": "root-x86-64", "file": "50-root.conf", "raw_size": 1044480}]"#, + Err("less than 1 MiB"), + ), + ( + r#"[{"type": "root-x86-64", "file": "50-root.conf"}]"#, + Err("less than 1 MiB"), + ), + ]; + for (json, expected) in cases { + let planned: Vec = serde_json::from_str(json).unwrap(); + match (planned_root_size_mib(&planned), expected) { + (Ok(size_mib), Ok(expected)) => assert_eq!(size_mib.get(), expected), + (Err(e), Err(expected)) => { + assert!(format!("{e:#}").contains(expected), "{e:#}") + } + (result, expected) => panic!("{json}: got {result:?}, expected {expected:?}"), + } + } + } + + #[test] + fn test_planned_boot_partition_sizes() { + let cases = [ + // systemd 262 names the definitions with their full path; only the + // ESP is created by a generic install. + ( + include_str!("../fixtures/repart-dry-run-v262.json"), + vec![("00-esp.conf", 1073741824)], + ), + // systemd 252 names them without a path, and BIOS boot by its GUID. + ( + r#"[ + {"type": "21686148-6449-6e6f-744e-656564454649", "file": "00-bios.conf", "raw_size": 1048576}, + {"type": "esp", "file": "00-esp.conf", "raw_size": 536870912}, + {"type": "xbootldr", "file": "10-xbootldr.conf", "raw_size": 1073741824}, + {"type": "root-x86-64", "file": "50-root.conf", "raw_size": 7516192768} + ]"#, + vec![("00-bios.conf", 1048576), ("00-esp.conf", 536870912)], + ), + (r#"[]"#, vec![]), + ]; + for (json, expected) in cases { + let planned: Vec = serde_json::from_str(json).unwrap(); + let sizes = planned_boot_partition_sizes(&planned).unwrap(); + let sizes = sizes + .iter() + .map(|(name, size)| (name.as_str(), *size)) + .collect::>(); + assert_eq!(sizes, expected, "{json}"); + } + } + + #[test] + fn test_ensure_generated_root_name_unused() { + let cases = [ + // systemd 252 names definitions without their directory, newer + // versions with it. + (r#"[{"type": "var", "file": "50-root.conf"}]"#, false), + ( + r#"[{"type": "var", "file": "/usr/lib/repart.d/50-root.conf"}]"#, + false, + ), + ( + r#"[{"type": "esp", "file": "/usr/lib/repart.d/00-esp.conf"}, + {"type": "var", "file": "/usr/lib/repart.d/60-root.conf"}]"#, + true, + ), + (r#"[]"#, true), + ]; + for (json, unused) in cases { + let definitions: Vec = serde_json::from_str(json).unwrap(); + assert_eq!( + ensure_generated_root_name_unused(&definitions).is_ok(), + unused, + "{json}" + ); + } + } + + #[test] + fn test_repart_definitions_args() { + let args = repart_definitions_args(Path::new("/tmp/generated")); + let expected = [ + "--definitions=/etc/repart.d", + "--definitions=/tmp/generated", + "--definitions=/run/repart.d", + "--definitions=/usr/local/lib/repart.d", + "--definitions=/usr/lib/repart.d", + ] + .map(OsString::from); + assert_eq!(args, expected); + } +} diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index dbd576da34..4c95690b11 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -474,21 +474,26 @@ directories, bootc falls back to its built-in sfdisk partitioning. ### Root partition handling All repart.d definitions are passed to systemd-repart together so that it can -plan a correct layout with proper space allocation across all partitions. +plan a correct layout with proper space allocation across all partitions, +except as described under Firstboot definitions below. If the image's repart.d definitions do **not** include a root partition -(`Type=root`), bootc automatically injects one into `/run/repart.d/` before -invoking systemd-repart. The generated root partition definition: +(`Type=root`), bootc generates one and passes it to systemd-repart along with +the image's definitions, without writing to `/run/repart.d/`. The generated +root partition definition: - Uses `Type=root` (architecture-specific DPS GUID is resolved by systemd-repart) - Applies `Format=` from the configured root filesystem type - Honours `--root-size` if specified (via `SizeMinBytes`/`SizeMaxBytes`) -- Otherwise takes all remaining space on the disk +- Otherwise is sized as systemd-repart plans it among all the image's + definitions, as if the image defined a root partition without a size: it + takes the space they leave, shared by `Weight=` with any of them that can + grow. Use `Weight=` in those definitions or `--root-size` to steer it. This means images can ship repart.d definitions for additional partitions (e.g. `/home`, swap, `/var`) without needing to also define root. -All definitions run at install time so that systemd-repart can plan the -layout with correct space allocation across all partitions. This is +All definitions are considered at install time so that systemd-repart can +plan the layout with correct space allocation across all partitions. This is important because the root filesystem is mounted read-only on bootc systems (`/sysroot` is ro), so systemd-repart cannot resize it after installation. @@ -504,10 +509,15 @@ If none of these provide a filesystem type, the installation will fail. ### Firstboot definitions -Images may include repart.d definitions for partitions beyond root, ESP, -and xbootldr, for example `/home`, swap, or `/var`. Because all definitions -run at install time, systemd-repart allocates space for all of them during -installation. +Images may include repart.d definitions for partitions beyond root and the +ESP, for example `/home`, swap, or `/var`. Without `--generic-image`, +systemd-repart creates all of them at install time. With `--generic-image` +(implied by `--via-loopback`) only root, the ESP and the BIOS boot partition +are created at install time, and the others on first boot, in the space left +for them. When bootc generates root and no `--root-size` is given, it plans +the layout with all definitions and pins these three partitions to their +planned sizes, which leaves the space planned for the others. Otherwise the +image's definitions must keep them from growing into that space. ### DPS auto-mount and symlinked directories diff --git a/tmt/tests/booted/test-install-repart.nu b/tmt/tests/booted/test-install-repart.nu index 0d806d8f5c..f16319faf7 100644 --- a/tmt/tests/booted/test-install-repart.nu +++ b/tmt/tests/booted/test-install-repart.nu @@ -195,13 +195,20 @@ EOF # Verify partition layout (again) verify_part_layout_second_boot - # Now run systemd-repart on the disk again to simulate what would happen on first boot + verify_first_boot localhost/bootc-repart-noroot + rm -rvf /var/disk.img +} + +# Run systemd-repart on the disk again to simulate what would happen on first +# boot, and check that it creates the home and swap partitions the install +# left to it +def verify_first_boot [image: string] { print "Running systemd-repart to simulate first boot" ( podman run --privileged --rm -v /dev:/dev -v /var/disk.img:/output/disk.img - localhost/bootc-repart-noroot + $image systemd-repart --dry-run=no --json=pretty --no-pager /output/disk.img ) @@ -230,11 +237,57 @@ EOF } losetup -d $loop +} + +def test_repart_no_root_growable_esp [] { + tap begin "install with systemd-repart (growable ESP, root generated by bootc)" + + # The install creates only root and the ESP (and BIOS boot); an ESP that + # can grow must leave the space planned for home and swap to first boot. + # Its low weight keeps most of the disk for root. + let dockerfile = $"FROM localhost/bootc as base +RUN rm -rf /etc/repart.d /usr/lib/repart.d /usr/local/lib/repart.d /run/repart.d +RUN rm -rf /usr/lib/bootc/bound-images.d/* + +RUN mkdir -p /usr/lib/repart.d +($bios) +RUN <<'EOF' cat > /usr/lib/repart.d/00-esp.conf +[Partition] +Type=esp +Format=vfat +SizeMinBytes=512M +Weight=100 +EOF + +RUN <<'EOF' cat > /usr/lib/repart.d/20-home.conf +[Partition] +Type=home +Format=ext4 +SizeMinBytes=1024M +SizeMaxBytes=1024M +EOF + +RUN <<'EOF' cat > /usr/lib/repart.d/30-swap.conf +[Partition] +Type=swap +Format=swap +SizeMinBytes=1024M +SizeMaxBytes=1024M +EOF +" + (tap make_uki_containerfile $dockerfile) | podman build -t localhost/bootc-repart-growable-esp . -f - + + truncate -s 10G /var/disk.img + setenforce 0 + + run_install_to_disk localhost/bootc-repart-growable-esp ["--filesystem" "ext4"] + verify_first_boot localhost/bootc-repart-growable-esp rm -rvf /var/disk.img } def main [] { test_repart_full test_repart_no_root + test_repart_no_root_growable_esp tap ok }