From dffe45f94d2d55926fe2ecd64a633417fc0f7835 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Tue, 27 Oct 2026 01:19:52 +0100 Subject: [PATCH 1/4] docs: Say that to-disk uses systemd-repart only with --run-repart `bootc install to-disk` only partitions with systemd-repart when given --run-repart, but bootc-installation(7) still describes that as automatic whenever the image has repart.d definitions. Generated-by: AI Signed-off-by: Christian Glombek --- docs/src/bootc-installation.7.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 22f086adda..0ff4ee3013 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -470,9 +470,9 @@ If you're building tooling that uses `bootc install to-filesystem`, you should: ## systemd-repart -When `systemd-repart` is available and repart.d definitions are present in the -container image, `bootc install to-disk` uses systemd-repart instead of sfdisk -to partition the target disk. +With `--run-repart`, when `systemd-repart` is available and repart.d +definitions are present in the container image, `bootc install to-disk` uses +systemd-repart instead of sfdisk to partition the target disk. ### How definitions are discovered From 71cc1f392b8c33a6843b76dfff33e8c95eb661b8 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 3 Oct 2026 00:33:15 +0200 Subject: [PATCH 2/4] repart: Keep generated definitions out of /run/repart.d When the image's repart.d definitions have no root partition, bootc wrote one to /run/repart.d/50-root.conf for systemd-repart. In `bootc install to-disk`, /run may be the host's, where bootc must not change any state. The file was also left behind, so a later install from the same environment took it for one of the image's definitions and reused the previous root size. Write it to a temporary directory instead, which systemd-repart reads through --definitions= along with the standard directories, with the priority of /run/repart.d. On systemd < 253, which lacks --include-partitions=, a generic image's install reads only copies of the definitions it needs; put those in such a directory too, instead of the fixed /tmp/repart.d. With every caller passing a fresh directory, collect_repart_definitions no longer clears its target. Prep for the next commits, which generate more definitions. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/install/baseline.rs | 203 ++++++++++++++++++++--------- docs/src/bootc-installation.7.md | 5 +- 2 files changed, 144 insertions(+), 64 deletions(-) diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 28e89a9728..1e6a32d623 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -6,6 +6,8 @@ //! 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; @@ -259,14 +261,7 @@ fn can_use_systemd_repart() -> bool { return false; } - let repart_config_dirs = [ - Path::new("/etc/repart.d"), - Path::new("/run/repart.d"), - Path::new("/usr/local/lib/repart.d"), - Path::new("/usr/lib/repart.d"), - ]; - - let has_config = repart_config_dirs.iter().any(|d| { + let has_config = REPART_CONFIG_DIRS.iter().map(Path::new).any(|d| { d.is_dir() && d.read_dir() .ok() @@ -284,21 +279,24 @@ const REPART_INCLUDE_PARTITIONS_MIN_VERSION: u32 = 253; /// ext4 fs-verity). Older versions silently ignore it. const REPART_MKFS_OPTIONS_MIN_VERSION: u32 = 254; -/// Directory we assemble the filtered set of repart.d definitions into when -/// emulating `--include-partitions=` on systemd older than -/// [`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"; + /// Whether we must emulate `--include-partitions=` by pre-filtering the /// definitions ourselves fn need_filtered_definitions(generic_image: bool) -> Result { @@ -315,7 +313,7 @@ fn repart_partition_is_required(part: &RepartPartition) -> bool { || ptype.eq_ignore_ascii_case(BIOS_BOOT) } -/// Collect the selected repart.d definitions into dest_dir. +/// Collect the selected repart.d definitions into dest_dir, a fresh directory. /// /// systemd < 253 does not support the `--include-partitions=` option, so instead /// we point systemd-repart at a directory containing only the definitions we want @@ -325,13 +323,6 @@ fn collect_repart_definitions( dest_dir: &Path, filter: impl Fn(&RepartPartition) -> bool, ) -> Result<()> { - // Start from a clean directory so stale definitions from a previous run - // don't leak in. - if dest_dir.exists() { - std::fs::remove_dir_all(dest_dir) - .with_context(|| format!("Removing {}", dest_dir.display()))?; - } - std::fs::create_dir_all(dest_dir) .with_context(|| format!("Creating {}", dest_dir.display()))?; @@ -358,6 +349,17 @@ fn collect_repart_definitions( 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())) +} + /// Create partitions using systemd-repart /// Assumes we have systemd-repart definitions #[context("Running systemd-repart")] @@ -370,7 +372,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, None)?; + let dry_partitions = systemd_repart_run(device, false, true, RepartDefinitions::Image)?; if dry_partitions.is_empty() { anyhow::bail!("systemd-repart returned empty partitions"); @@ -382,15 +384,20 @@ fn systemd_repart( if has_root { // Root partition is defined in repart.d config, run for real - if need_filtered_definitions(generic_image)? { + let filtered; + let definitions = if need_filtered_definitions(generic_image)? { + filtered = + tempfile::tempdir().context("Creating a directory for repart.d definitions")?; collect_repart_definitions( &dry_partitions, - Path::new(REPART_FILTERED_DEFINITIONS_DIR), + filtered.path(), repart_partition_is_required, )?; - } - - let partitions = systemd_repart_run(device, generic_image, false, None)?; + RepartDefinitions::Only(filtered.path()) + } else { + RepartDefinitions::Image + }; + let partitions = systemd_repart_run(device, generic_image, false, definitions)?; let layout = parse_repart_layout(&partitions)?; return Ok(layout); } @@ -436,38 +443,85 @@ fn systemd_repart( } } - if need_filtered_definitions(generic_image)? { + // Not in /run/repart.d, which may be the host's + let generated = tempfile::tempdir().context("Creating a directory for repart.d definitions")?; + let definitions = if need_filtered_definitions(generic_image)? { + // The run then reads only this directory, so it needs the copies too collect_repart_definitions( &dry_partitions, - Path::new(REPART_FILTERED_DEFINITIONS_DIR), + generated.path(), repart_partition_is_required, )?; - - std::fs::write( - Path::new(REPART_FILTERED_DEFINITIONS_DIR).join("50-root.conf"), - &root_conf, - ) - .context("Writing root repart config to filtered definitions")?; + RepartDefinitions::Only(generated.path()) } 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")?; - } + RepartDefinitions::WithGenerated(generated.path()) + }; + write_repart_file(generated.path(), REPART_GENERATED_ROOT_NAME, &root_conf)?; - let partitions = systemd_repart_run(device, generic_image, false, None)?; + let partitions = systemd_repart_run(device, generic_image, false, definitions)?; let layout = parse_repart_layout(&partitions)?; Ok(layout) } +/// Which repart.d definitions a systemd-repart run reads. +#[derive(Debug, Clone, Copy)] +enum RepartDefinitions<'a> { + /// The image's, from the standard search paths. + Image, + /// The image's plus those generated in this directory, which take the + /// priority of [`REPART_RUNTIME_DIR`]. + WithGenerated(&'a Path), + /// Only those in this directory, such as the copies a generic image needs + /// on systemd < 253 (see [`need_filtered_definitions`]). + Only(&'a Path), +} + +impl RepartDefinitions<'_> { + /// The systemd-repart arguments that select these definitions, and for a + /// generic image the partitions to create. + fn args(self, generic_image: bool) -> Vec { + let definitions = |dir: &OsStr| { + let mut arg = OsString::from("--definitions="); + arg.push(dir); + arg + }; + let mut args = Vec::new(); + match self { + // With a generic image, callers pass copies of just the definitions + // to act on this way where systemd lacks --include-partitions=. + Self::Only(dir) => args.push(definitions(dir.as_os_str())), + Self::Image | Self::WithGenerated(_) => { + if let Self::WithGenerated(generated) = self { + for dir in REPART_CONFIG_DIRS { + if *dir == REPART_RUNTIME_DIR { + args.push(definitions(generated.as_os_str())); + } + args.push(definitions(OsStr::new(dir))); + } + } + // If generic image, only run repart definitions that we absolutely + // require, (BIOS/ESP and root). Others can run at first boot since we + // 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 { + args.push(format!("--include-partitions=root,esp,{BIOS_BOOT}").into()); + } + } + } + args + } +} + /// Run systemd-repart on the device and return the parsed JSON output. /// `dry_run`: if true, no changes are written to disk. -/// `definitions`: if set, uses that directory instead of the default search paths. +/// `definitions`: which repart.d definitions to read. fn systemd_repart_run( device: &Device, generic_image: bool, dry_run: bool, - definitions: Option<&Path>, + definitions: RepartDefinitions, ) -> Result> { let mut cmd = Command::new("systemd-repart"); @@ -490,23 +544,7 @@ fn systemd_repart_run( "--root=/", ]); - // If generic image, only run repart definitions that we absolutely - // require, (BIOS/ESP and root). Others can run at first boot since we - // 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 let Some(definitions) = definitions { - cmd.arg(format!("--definitions={}", definitions.display())); - } else 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 { - cmd.arg(format!("--include-partitions=root,esp,{BIOS_BOOT}")); - } - } - + cmd.args(definitions.args(generic_image)); cmd.arg(device.path()); let output = cmd.output().context("Failed to run systemd-repart")?; @@ -646,7 +684,7 @@ fn image_repart_partitions( if !can_use_systemd_repart() { return Ok(Vec::new()); } - let partitions = systemd_repart_run(device, false, true, None)?; + let partitions = systemd_repart_run(device, false, true, RepartDefinitions::Image)?; validate_repart_boot_layout(&partitions, layout)?; Ok(partitions) } @@ -760,7 +798,7 @@ fn aboot_repart_definitions( if let Some(size) = root_size { root.push_str(&format!("SizeMinBytes={size}M\nSizeMaxBytes={size}M\n")); } - definitions.push(("50-root.conf".into(), root)); + definitions.push((REPART_GENERATED_ROOT_NAME.into(), root)); Ok(definitions) } @@ -786,7 +824,12 @@ fn systemd_repart_aboot( .with_context(|| format!("Creating generated repart definition {name}"))? .write_all(config.as_bytes())?; } - let partitions = systemd_repart_run(device, false, false, Some(definitions.path()))?; + let partitions = systemd_repart_run( + device, + false, + false, + RepartDefinitions::Only(definitions.path()), + )?; parse_repart_layout(&partitions) } @@ -1403,4 +1446,40 @@ mod tests { } Ok(()) } + + #[test] + fn test_repart_definitions_args() { + use RepartDefinitions::{Image, Only, WithGenerated}; + let include = format!("--include-partitions=root,esp,{BIOS_BOOT}"); + let with_generated = [ + "--definitions=/etc/repart.d", + "--definitions=/tmp/generated", + "--definitions=/run/repart.d", + "--definitions=/usr/local/lib/repart.d", + "--definitions=/usr/lib/repart.d", + ]; + let generated = WithGenerated(Path::new("/tmp/generated")); + let copies = Only(Path::new("/tmp/copies")); + let cases = [ + (Image, false, vec![]), + (Image, true, vec![include.as_str()]), + (generated, false, with_generated.to_vec()), + ( + generated, + true, + [&with_generated[..], &[include.as_str()]].concat(), + ), + // Already just the definitions to act on + (copies, false, vec!["--definitions=/tmp/copies"]), + (copies, true, vec!["--definitions=/tmp/copies"]), + ]; + for (definitions, generic_image, expected) in cases { + let expected = expected.into_iter().map(OsString::from).collect::>(); + assert_eq!( + definitions.args(generic_image), + expected, + "{definitions:?}, generic image: {generic_image}" + ); + } + } } diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 0ff4ee3013..f874cef82c 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -493,8 +493,9 @@ 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. 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 From 9a847cf6a42095cccb46b5ddfb9d4407e075b1f4 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 3 Oct 2026 00:34:11 +0200 Subject: [PATCH 3/4] repart: Refuse a definition named like the generated root bootc only generates its root definition, 50-root.conf, when the image's repart.d definitions have no root partition, so a file of the image with that name is a definition of some other partition or a mask. systemd-repart reads only one of two same-named definitions: the generated one, read with the priority of /run/repart.d, masks one in /run/repart.d, /usr/local/lib/repart.d or /usr/lib/repart.d, whose partition then silently goes missing from the layout, and one in /etc/repart.d masks it. A mask there, for example of a base image's root definition, hides the generated root too, and systemd-repart then writes a partition table without root before bootc notices. Fail with an error that names the file instead. Look in the directories rather than at a dry run, which does not list masks. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/install/baseline.rs | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 1e6a32d623..2c2233b759 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -349,6 +349,27 @@ fn collect_repart_definitions( Ok(()) } +/// Fail when one of `config_dirs` has a file with the name of the root +/// definition we generate: a definition of the image, which one of the two +/// would mask, or a mask, which hides the generated one too. Masks never show +/// up in a dry run, so look at the directories themselves. +fn ensure_generated_root_name_unused(config_dirs: &[impl AsRef]) -> Result<()> { + for dir in config_dirs { + let path = dir.as_ref().join(REPART_GENERATED_ROOT_NAME); + if let Err(e) = path.symlink_metadata() { + if e.kind() == std::io::ErrorKind::NotFound { + continue; + } + return Err(e).with_context(|| format!("Checking {}", path.display())); + } + anyhow::bail!( + "{} has the name of the root partition definition bootc generates, rename or remove it", + path.display() + ); + } + 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<()> { @@ -403,6 +424,7 @@ fn systemd_repart( } // Root partition is not defined, create definition for the root part + ensure_generated_root_name_unused(REPART_CONFIG_DIRS)?; let mut root_conf = String::from("[Partition]\nType=root\n"); match root_size { @@ -1447,6 +1469,35 @@ mod tests { Ok(()) } + #[test] + fn test_ensure_generated_root_name_unused() -> Result<()> { + let td = tempfile::tempdir()?; + let etc = td.path().join("etc"); + let usr = td.path().join("usr"); + std::fs::create_dir(&etc)?; + std::fs::create_dir(&usr)?; + let dirs = [&etc, &usr, &td.path().join("missing")]; + std::fs::write(usr.join("00-esp.conf"), "[Partition]\nType=esp\n")?; + ensure_generated_root_name_unused(&dirs)?; + + // An image definition for another partition with that name + let clash = usr.join(REPART_GENERATED_ROOT_NAME); + std::fs::write(&clash, "[Partition]\nType=var\n")?; + assert!(ensure_generated_root_name_unused(&dirs).is_err()); + std::fs::remove_file(&clash)?; + + // A mask, as of a root definition of a base image, which would hide + // the generated one as well + let mask = etc.join(REPART_GENERATED_ROOT_NAME); + std::os::unix::fs::symlink("/dev/null", &mask)?; + let e = ensure_generated_root_name_unused(&dirs).unwrap_err(); + assert!( + format!("{e:#}").contains(&mask.display().to_string()), + "{e:#}" + ); + Ok(()) + } + #[test] fn test_repart_definitions_args() { use RepartDefinitions::{Image, Only, WithGenerated}; From 7927c6f2062a36603ed2e2ea87831a481ab4c280 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Tue, 27 Oct 2026 00:39:12 +0100 Subject: [PATCH 4/4] repart: Let systemd-repart size the generated root partition With systemd 262, `bootc install to-disk` fails for images whose repart.d definitions have no root partition, unless --root-size is given: systemd-repart now reports the free space as padding of the last partition (systemd 959c3286d00f), and the root size bootc derives from its dry run underflows. Unless --root-size is given, leave the generated root without size limits, so that systemd-repart sizes it like an unsized root of the image, sharing free space by Weight=. A generic image's install creates only root, the ESP and BIOS boot, and leaves the other partitions to first boot, so in that case plan the layout with all definitions in a second dry run and pin those three to their planned sizes, root through its definition and the others with drop-ins, keeping the space planned for the rest. With --root-size, nothing is planned or pinned: the plan would need root and all other partitions to fit the install disk, which breaks generic images meant to be grown when deployed. Generated-by: AI Signed-off-by: Christian Glombek --- .../lib/src/fixtures/repart-dry-run-v262.json | 58 +++++ crates/lib/src/install/baseline.rs | 207 ++++++++++++++---- docs/src/bootc-installation.7.md | 25 ++- tmt/tests/booted/test-install-repart.nu | 63 +++++- 4 files changed, 302 insertions(+), 51 deletions(-) create mode 100644 crates/lib/src/fixtures/repart-dry-run-v262.json 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..7d8a6946f1 --- /dev/null +++ b/crates/lib/src/fixtures/repart-dry-run-v262.json @@ -0,0 +1,58 @@ +[ + { + "type" : "esp", + "label" : "esp", + "uuid" : "4450fa5c-8a37-462f-a99f-9c0ce5ef4d45", + "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" : "ee91f8c0-f889-41c5-8a5a-882f39dd1540", + "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" : "4d65aeda-a93d-42b7-8950-80cf7648c5cb", + "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" : "b109a783-eb87-46c5-9e97-e1e65114ca63", + "partno" : 3, + "file" : "/tmp/tmp.BUTWwTH38q/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 2c2233b759..002959752d 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -11,6 +11,7 @@ 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; @@ -128,7 +129,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, } @@ -234,8 +236,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 @@ -297,6 +297,14 @@ const REPART_CONFIG_DIRS: &[&str] = &[ /// 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. Drop-ins apply in name order, so the name sorts after those images +/// usually ship, such as `override.conf` or `10-size.conf`. +const REPART_PLANNED_SIZE_DROPIN: &str = "zz-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 { @@ -349,6 +357,50 @@ fn collect_repart_definitions( 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 `config_dirs` has a file with the name of the root /// definition we generate: a definition of the image, which one of the two /// would mask, or a mask, which hides the generated one too. Masks never show @@ -425,48 +477,45 @@ fn systemd_repart( // Root partition is not defined, create definition for the root part ensure_generated_root_name_unused(REPART_CONFIG_DIRS)?; - let mut root_conf = String::from("[Partition]\nType=root\n"); - - match root_size { - Some(size_mib) => { - writeln!(root_conf, "SizeMinBytes={size_mib}M")?; - writeln!(root_conf, "SizeMaxBytes={size_mib}M")?; - } - 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; - - tracing::debug!( - "space_taken: {} M, device.size: {} M, Root Size: {root_size_mib} M", - space_taken / mb, - device.size / mb - ); - - writeln!(root_conf, "SizeMinBytes={root_size_mib}M")?; - writeln!(root_conf, "SizeMaxBytes={root_size_mib}M")?; - } + let Some(fs) = rootfs else { + anyhow::bail!("Rootfs not specified"); }; - - match rootfs { - Some(fs) => writeln!(root_conf, "Format={fs}")?, - None => { - anyhow::bail!("Rootfs not specified") - } + let mut root_conf = format!("[Partition]\nType=root\nFormat={fs}\n"); + if let Some(size_mib) = root_size { + writeln!(root_conf, "SizeMinBytes={size_mib}M")?; + writeln!(root_conf, "SizeMaxBytes={size_mib}M")?; } - // Not in /run/repart.d, which may be the host's let generated = tempfile::tempdir().context("Creating a directory for repart.d definitions")?; + + // A generic image's install creates only root, the ESP and BIOS boot, and + // leaves the other partitions to first boot. Plan the layout with all + // definitions and pin those three to it, so that none of them takes the + // space planned for the others. With --root-size there is no such plan: + // the other partitions may only fit once the disk has been grown. Without + // a generic image, the real run creates all the partitions, and sizes an + // unsized root by itself. + let boot_partition_sizes = if generic_image && root_size.is_none() { + write_repart_file(generated.path(), REPART_GENERATED_ROOT_NAME, &root_conf)?; + let planned = systemd_repart_run( + device, + false, + true, + RepartDefinitions::WithGenerated(generated.path()), + ) + .context("Planning the root partition size")?; + let size_mib = planned_root_size_mib(&planned)?.get(); + tracing::debug!( + "Root partition size: {size_mib} MiB of {} MiB", + device.size / MIB + ); + writeln!(root_conf, "SizeMinBytes={size_mib}M")?; + writeln!(root_conf, "SizeMaxBytes={size_mib}M")?; + planned_boot_partition_sizes(&planned)? + } else { + Vec::new() + }; + let definitions = if need_filtered_definitions(generic_image)? { // The run then reads only this directory, so it needs the copies too collect_repart_definitions( @@ -479,6 +528,10 @@ fn systemd_repart( RepartDefinitions::WithGenerated(generated.path()) }; write_repart_file(generated.path(), 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(generated.path(), &dropin, &planned_size_pin(*size))?; + } let partitions = systemd_repart_run(device, generic_image, false, definitions)?; let layout = parse_repart_layout(&partitions)?; @@ -1469,6 +1522,78 @@ mod tests { Ok(()) } + #[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() -> Result<()> { let td = tempfile::tempdir()?; diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index f874cef82c..4fe0a659a5 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -490,7 +490,8 @@ 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 generates one and passes it to systemd-repart along with @@ -500,12 +501,15 @@ 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. @@ -521,10 +525,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 their +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 8d1c7f673d..06aff74b8b 100644 --- a/tmt/tests/booted/test-install-repart.nu +++ b/tmt/tests/booted/test-install-repart.nu @@ -229,13 +229,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 ) @@ -264,11 +271,63 @@ 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 sizes come from a drop-in of the image, which must not override the + # size bootc pins. 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/00-esp.conf.d +($bios) +RUN <<'EOF' cat > /usr/lib/repart.d/00-esp.conf +[Partition] +Type=esp +Format=vfat +EOF + +RUN <<'EOF' cat > /usr/lib/repart.d/00-esp.conf.d/size.conf +[Partition] +SizeMinBytes=512M +SizeMaxBytes=100G +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 ["--run-repart" "--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 }