Skip to content

Commit 93dbff9

Browse files
committed
bootloader: Chroot into composefs image root for bootupd installs
Closes: #2270 Like the ostree backend, run bootupctl inside a chroot of the target system rather than the host, so we use the bootupd binaries and plugins shipped in the deployed container image. This is needed for use cases like Anaconda. This turned out to be fairly complicated though in terms of how we set up the bind mounts; see the code for details. Generated-by: https://github.com/cgwalters/#llms Signed-off-by: Colin Walters <walters@verbum.org>
1 parent a3fccae commit 93dbff9

4 files changed

Lines changed: 164 additions & 63 deletions

File tree

crates/lib/src/bootc_composefs/boot.rs

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,11 +1196,21 @@ pub(crate) fn setup_composefs_uki_boot(
11961196
/// `<tmpdir>/tmp` to provide a writable scratch area for tools invoked with
11971197
/// `--root`.
11981198
///
1199-
/// Drop order matters: the ESP and tmpfs guards are declared before `composefs`
1200-
/// so they are unmounted (and flushed) before the composefs root is detached.
1199+
/// Drop order matters: the tmpfs guard is declared before `composefs` so it
1200+
/// is unmounted (and flushed) before the composefs root is detached.
12011201
pub(crate) struct MountedImageRoot {
1202-
// Unmounted before `composefs` on drop; ESP before tmp (inner before outer).
1203-
_esp: bootc_mount::tempmount::MountGuard,
1202+
// The ESP's device path. The ESP is intentionally *not* mounted here for
1203+
// our whole lifetime; it's mounted on demand via `with_esp()` instead.
1204+
// That's because `install_via_bootupd` runs bootupd's install tooling
1205+
// inside a `ChrootCmd`, which unshares the mount namespace and
1206+
// recursively self-binds the chroot directory (our `root_path()`) onto
1207+
// itself. If the ESP were already mounted at `<root_path>/boot` when
1208+
// that happens, the self-bind would duplicate it into an
1209+
// orphaned/shadowed mountinfo entry that's still visible to naive
1210+
// `findmnt`-based scans (like bootupd's), causing it to pick the wrong
1211+
// filesystem UUID. See the commit introducing this comment for details.
1212+
esp_device: Utf8PathBuf,
1213+
// Unmounted before `composefs` on drop.
12041214
_tmp: bootc_mount::tempmount::MountGuard,
12051215
composefs: TempMount,
12061216
pub(crate) esp_subdir: &'static str,
@@ -1236,10 +1246,6 @@ impl MountedImageRoot {
12361246
// unconditionally mount the ESP at /boot for now.
12371247
let esp_subdir = "boot";
12381248

1239-
let esp_path = composefs.dir.path().join(esp_subdir);
1240-
let esp =
1241-
mount_esp_at(&esp_part.path(), esp_path).context("Mounting ESP into composefs root")?;
1242-
12431249
// Mount a tmpfs over /tmp so that tools invoked with --root have a
12441250
// writable scratch area without touching the read-only EROFS root.
12451251
let tmp_path = composefs.dir.path().join("tmp");
@@ -1253,7 +1259,7 @@ impl MountedImageRoot {
12531259
.context("Mounting tmpfs into composefs root")?;
12541260

12551261
Ok(Self {
1256-
_esp: esp,
1262+
esp_device: esp_part.path().into(),
12571263
_tmp: tmp,
12581264
composefs,
12591265
esp_subdir,
@@ -1271,12 +1277,29 @@ impl MountedImageRoot {
12711277
}
12721278

12731279
/// Open the mounted ESP as a capability-safe directory.
1280+
///
1281+
/// This only succeeds while the ESP is actually mounted, i.e. from
1282+
/// within (or after) a call to [`Self::with_esp`].
12741283
pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
12751284
self.composefs
12761285
.fd
12771286
.open_dir(self.esp_subdir)
12781287
.with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
12791288
}
1289+
1290+
/// Mount the ESP at `<tmpdir>/<esp_subdir>` for the duration of `f`, then
1291+
/// unmount it again. Nothing is mounted there outside of calls to this
1292+
/// method, so callers that need to invoke bootloader-install tooling
1293+
/// that itself creates a private mount namespace (e.g. via `ChrootCmd`)
1294+
/// can safely do so without risking this mount being shadowed/duplicated
1295+
/// by that tooling's own mount-namespace setup.
1296+
pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
1297+
let esp_path = self.root_path().join(self.esp_subdir);
1298+
let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
1299+
.context("Mounting ESP into composefs root")?;
1300+
let dir = self.open_esp_dir()?;
1301+
f(&dir)
1302+
}
12801303
}
12811304

12821305
pub struct SecurebootKeys {
@@ -1381,56 +1404,75 @@ pub(crate) async fn setup_composefs_boot(
13811404
postfetch.detected_bootloader,
13821405
Bootloader::Grub | Bootloader::GrubCC
13831406
) {
1407+
let chroot_target = Utf8Path::from_path(mounted_root.root_path())
1408+
.ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
1409+
// Like the ostree backend, bind the physical root's real /boot (an
1410+
// ordinary ext4/xfs/... directory, not yet populated with kernels at
1411+
// this point) into the chroot. This gives bootupd both a correct
1412+
// filesystem to inspect for `--write-uuid` (rather than the ESP,
1413+
// which is otherwise mounted at the composefs root's own /boot) and
1414+
// an empty `boot/efi` directory for its EFI component to discover
1415+
// and mount the real ESP into, exactly as it would on ostree.
1416+
let bind_boot_path = root_setup.physical_root_path.join("boot");
13841417
crate::bootloader::install_via_bootupd(
13851418
&root_setup.device_info,
13861419
&root_setup.physical_root_path,
13871420
&state.config_opts,
1388-
None,
1421+
Some(chroot_target),
1422+
Some(bind_boot_path.as_path()),
13891423
)?;
13901424

13911425
// FIXME: Remove this hack once we have support in bootupd
13921426
if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) {
1427+
// bootupctl wrote this under the physical root's real /boot (via
1428+
// the bind mount above), not under the composefs root.
13931429
root_setup
13941430
.physical_root
1395-
.remove_dir_all("boot/grub2")
1431+
.remove_all_optional("boot/grub2")
13961432
.context("removing grub2")?;
13971433

1398-
let (os_id, ..) = parse_os_release(mounted_root.dir())?
1399-
.ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1400-
1401-
let dir = format!("EFI/{os_id}");
1402-
1403-
// Files are in EFI/<os-name>/
1404-
let efis_dir = mounted_root
1405-
.open_esp_dir()
1406-
.context("opening esp")?
1407-
.open_dir(&dir)
1408-
.with_context(|| format!("Opening {dir}"))?;
1409-
1410-
efis_dir
1411-
.remove_file_optional("bootuuid.cfg")
1412-
.context("Removing bootuuid.cfg")?;
1413-
efis_dir
1414-
.remove_file_optional("grub.cfg")
1415-
.context("Removing grub.cfg")?;
1416-
1417-
let final_name = match std::env::consts::ARCH {
1418-
"x86_64" => "grubx64.efi",
1419-
"aarch64" => "grubaa64-cc.efi",
1420-
arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1421-
};
1434+
// install_via_bootupd above has already returned, so it's safe
1435+
// to mount the ESP here for the duration of this cleanup.
1436+
mounted_root.with_esp(|esp_dir| {
1437+
let (os_id, ..) = parse_os_release(mounted_root.dir())?
1438+
.ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1439+
1440+
let dir = format!("EFI/{os_id}");
1441+
1442+
// Files are in EFI/<os-name>/
1443+
let efis_dir = esp_dir
1444+
.open_dir(&dir)
1445+
.with_context(|| format!("Opening {dir}"))?;
1446+
1447+
efis_dir
1448+
.remove_file_optional("bootuuid.cfg")
1449+
.context("Removing bootuuid.cfg")?;
1450+
efis_dir
1451+
.remove_file_optional("grub.cfg")
1452+
.context("Removing grub.cfg")?;
1453+
1454+
let final_name = match std::env::consts::ARCH {
1455+
"x86_64" => "grubx64.efi",
1456+
"aarch64" => "grubaa64-cc.efi",
1457+
arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1458+
};
1459+
1460+
mounted_root
1461+
.dir()
1462+
.copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1463+
.context("Copying grub-cc binary")?;
14221464

1423-
mounted_root
1424-
.dir()
1425-
.copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1426-
.context("Copying grub-cc binary")?;
1465+
Ok(())
1466+
})?;
14271467
}
14281468
} else {
1429-
crate::bootloader::install_systemd_boot(
1430-
&mounted_root,
1431-
&state.config_opts,
1432-
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1433-
)?;
1469+
mounted_root.with_esp(|_esp_dir| {
1470+
crate::bootloader::install_systemd_boot(
1471+
&mounted_root,
1472+
&state.config_opts,
1473+
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1474+
)
1475+
})?;
14341476
}
14351477

14361478
let Some(entry) = entries.iter().next() else {

crates/lib/src/bootloader.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,23 @@ fn bootupd_supports_filesystem(chroot_target: Option<&Utf8Path>) -> Result<bool>
134134
///
135135
/// For older bootupd versions that lack `--filesystem` we fall back to the
136136
/// legacy `--device <device_path> <rootfs>` invocation.
137+
///
138+
/// If `bind_boot_path` is set, the given host path is bind-mounted onto
139+
/// `/boot` inside the chroot. Both the ostree and composefs backends use
140+
/// this to expose the physical root's real `/boot` inside their respective
141+
/// chroots, since neither chroot target (an ostree deployment, or a mounted
142+
/// composefs image) has a `/boot` backed by the real root filesystem on its
143+
/// own. This matters because bootupd derives the UUID it writes for
144+
/// `--write-uuid` from whatever filesystem is mounted at `<chroot>/boot`,
145+
/// and looks for an empty `boot/efi` directory there to discover and mount
146+
/// the real ESP into.
137147
#[context("Installing bootloader")]
138148
pub(crate) fn install_via_bootupd(
139149
device: &bootc_blockdev::Device,
140150
rootfs: &Utf8Path,
141151
configopts: &crate::install::InstallConfigOpts,
142152
chroot_target: Option<&Utf8Path>,
153+
bind_boot_path: Option<&Utf8Path>,
143154
) -> Result<()> {
144155
let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv");
145156
// bootc defaults to only targeting the platform boot method.
@@ -192,7 +203,16 @@ pub(crate) fn install_via_bootupd(
192203
bootupd_args.push(rootfs_mount);
193204
} else {
194205
tracing::debug!("bootupd supports --filesystem");
195-
bootupd_args.extend(["--filesystem", rootfs_mount]);
206+
// Inside a chroot the physical root is bind-mounted at /sysroot (see
207+
// below) so bootupd's own device resolution (via lsblk) sees a real
208+
// block-backed path. This matters for composefs, where the chroot's
209+
// own "/" is a virtual composefs mount with no backing block device.
210+
let filesystem_path = if chroot_target.is_some() {
211+
"/sysroot"
212+
} else {
213+
rootfs_mount
214+
};
215+
bootupd_args.extend(["--filesystem", filesystem_path]);
196216
bootupd_args.push(rootfs_mount);
197217
}
198218

@@ -201,7 +221,6 @@ pub(crate) fn install_via_bootupd(
201221
// deployment, without requiring a user namespace (which fails under
202222
// qemu-user — see <https://github.com/bootc-dev/bootc/issues/2111>).
203223
if let Some(target_root) = chroot_target {
204-
let boot_path = rootfs.join("boot");
205224
let rootfs_path = rootfs.to_path_buf();
206225

207226
tracing::debug!("Running bootupctl via chroot in {}", target_root);
@@ -211,10 +230,19 @@ pub(crate) fn install_via_bootupd(
211230
let mut chroot_args = vec!["bootupctl"];
212231
chroot_args.extend(bootupd_args);
213232

214-
let mut cmd = ChrootCmd::new(target_root)
215-
// Bind mount /boot from the physical target root so bootupctl can find
216-
// the boot partition and install the bootloader there
217-
.bind(&boot_path, &"/boot");
233+
let mut cmd = ChrootCmd::new(target_root);
234+
// Bind mount /boot from the physical target root so bootupctl can find
235+
// the boot partition and install the bootloader there. This is a
236+
// non-recursive bind: the physical root's /boot may itself have the
237+
// ESP mounted at boot/efi (see clean_boot_directories()), and we
238+
// don't want that mount to be dragged along, since bootupd's own EFI
239+
// component expects to find an empty boot/efi directory to mount the
240+
// real ESP onto itself (see MountedImageRoot::with_esp()). A stray
241+
// nested ESP mount there has also been observed to confuse grub-probe
242+
// into embedding the wrong root device in the BIOS boot prefix.
243+
if let Some(boot_path) = &bind_boot_path {
244+
cmd = cmd.bind_flat(boot_path, &"/boot");
245+
}
218246

219247
// Only bind mount the physical root at /sysroot when using --filesystem;
220248
// bootupd needs it to resolve backing block devices via lsblk.

crates/lib/src/install.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,11 +1873,13 @@ async fn install_with_sysroot(
18731873
.clone()
18741874
.unwrap_or(rootfs.physical_root_path.clone());
18751875
let chroot_target = root_path.join(deployment_path.as_str());
1876+
let bind_boot_path = root_path.join("boot");
18761877
crate::bootloader::install_via_bootupd(
18771878
&rootfs.device_info,
18781879
&root_path,
18791880
&state.config_opts,
18801881
Some(chroot_target.as_path()),
1882+
Some(bind_boot_path.as_path()),
18811883
)?;
18821884
}
18831885
Bootloader::Systemd | Bootloader::GrubCC => {

crates/utils/src/chroot.rs

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use std::process::Command;
99

1010
use anyhow::{Context, Result};
1111
use cap_std_ext::camino::Utf8Path;
12-
use rustix::mount::{MountFlags, MountPropagationFlags, mount, mount_bind_recursive, mount_change};
12+
use rustix::mount::{
13+
MountFlags, MountPropagationFlags, mount, mount_bind, mount_bind_recursive, mount_change,
14+
};
1315
use rustix::process::{chdir, chroot};
1416
use rustix::thread::{UnshareFlags, unshare_unsafe};
1517

@@ -21,8 +23,8 @@ use crate::CommandRunExt;
2123
pub struct ChrootCmd<'a> {
2224
/// The target directory to use as root for the chroot.
2325
chroot_path: Cow<'a, Utf8Path>,
24-
/// Bind mounts in format (host source, chroot-relative target).
25-
bind_mounts: Vec<(&'a str, &'a str)>,
26+
/// Bind mounts in format (host source, chroot-relative target, recursive).
27+
bind_mounts: Vec<(&'a str, &'a str, bool)>,
2628
/// Environment variables to set on the spawned command.
2729
env_vars: Vec<(&'a str, &'a str)>,
2830
}
@@ -37,15 +39,31 @@ impl<'a> ChrootCmd<'a> {
3739
}
3840
}
3941

40-
/// Add a bind mount from `source` (on the host) to `target` (a path
41-
/// inside the chroot, e.g. `/boot`).
42+
/// Add a recursive bind mount from `source` (on the host) to `target` (a
43+
/// path inside the chroot, e.g. `/sysroot`). Any mounts nested under
44+
/// `source` on the host (e.g. an ESP mounted under a `/boot` source) are
45+
/// also visible under `target` inside the chroot.
4246
pub fn bind(
4347
mut self,
4448
source: &'a impl AsRef<Utf8Path>,
4549
target: &'a impl AsRef<Utf8Path>,
4650
) -> Self {
4751
self.bind_mounts
48-
.push((source.as_ref().as_str(), target.as_ref().as_str()));
52+
.push((source.as_ref().as_str(), target.as_ref().as_str(), true));
53+
self
54+
}
55+
56+
/// Add a non-recursive bind mount from `source` (on the host) to
57+
/// `target` (a path inside the chroot). Unlike [`Self::bind`], mounts
58+
/// nested under `source` on the host are *not* carried over into the
59+
/// chroot, leaving any mountpoint directories beneath `target` empty.
60+
pub fn bind_flat(
61+
mut self,
62+
source: &'a impl AsRef<Utf8Path>,
63+
target: &'a impl AsRef<Utf8Path>,
64+
) -> Self {
65+
self.bind_mounts
66+
.push((source.as_ref().as_str(), target.as_ref().as_str(), false));
4967
self
5068
}
5169

@@ -91,14 +109,18 @@ impl<'a> ChrootCmd<'a> {
91109
let sys_target = CString::new(sys_target.as_str())?;
92110
let run_target = CString::new(run_target.as_str())?;
93111

94-
let user_binds: Vec<(CString, CString)> = self
112+
let user_binds: Vec<(CString, CString, bool)> = self
95113
.bind_mounts
96114
.iter()
97-
.map(|(src, tgt)| -> Result<_> {
115+
.map(|(src, tgt, recursive)| -> Result<_> {
98116
let tgt_in_chroot = self.chroot_path.join(tgt.trim_start_matches('/'));
99117
create_dir_all(&tgt_in_chroot)
100118
.with_context(|| format!("Creating bind target {tgt_in_chroot}"))?;
101-
Ok((CString::new(*src)?, CString::new(tgt_in_chroot.as_str())?))
119+
Ok((
120+
CString::new(*src)?,
121+
CString::new(tgt_in_chroot.as_str())?,
122+
*recursive,
123+
))
102124
})
103125
.collect::<Result<_>>()?;
104126

@@ -150,8 +172,12 @@ impl<'a> ChrootCmd<'a> {
150172
// properties.
151173
mount_bind_recursive(c"/run", run_target.as_c_str())?;
152174

153-
for (src, tgt) in &user_binds {
154-
mount_bind_recursive(src.as_c_str(), tgt.as_c_str())?;
175+
for (src, tgt, recursive) in &user_binds {
176+
if *recursive {
177+
mount_bind_recursive(src.as_c_str(), tgt.as_c_str())?;
178+
} else {
179+
mount_bind(src.as_c_str(), tgt.as_c_str())?;
180+
}
155181
}
156182

157183
chroot(chroot_cstr.as_c_str())?;
@@ -198,12 +224,15 @@ mod tests {
198224
fn builder_accumulates_binds_and_env() {
199225
let (_keep, root) = tmp_root();
200226
let src = root.join("src");
227+
let flat_src = root.join("flat-src");
201228
let cmd = ChrootCmd::new(&root)
202229
.bind(&src, &"/boot")
230+
.bind_flat(&flat_src, &"/sysroot")
203231
.setenv("FOO", "bar")
204232
.set_default_path();
205-
assert_eq!(cmd.bind_mounts.len(), 1);
206-
assert_eq!(cmd.bind_mounts[0].1, "/boot");
233+
assert_eq!(cmd.bind_mounts.len(), 2);
234+
assert_eq!(cmd.bind_mounts[0], (src.as_str(), "/boot", true));
235+
assert_eq!(cmd.bind_mounts[1], (flat_src.as_str(), "/sysroot", false));
207236
// setenv + set_default_path
208237
assert_eq!(cmd.env_vars.len(), 2);
209238
assert!(cmd.env_vars.iter().any(|(k, _)| *k == "PATH"));

0 commit comments

Comments
 (0)