Skip to content

Commit c565461

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 6f87a68 commit c565461

4 files changed

Lines changed: 201 additions & 66 deletions

File tree

crates/lib/src/bootc_composefs/boot.rs

Lines changed: 113 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
//! 1. **Primary**: New/upgraded deployment (default boot target)
6262
//! 2. **Secondary**: Currently booted deployment (rollback option)
6363
64+
use std::cell::Cell;
6465
use std::fs::create_dir_all;
6566
use std::io::{Read, Seek, SeekFrom, Write};
6667
use std::path::Path;
@@ -1207,11 +1208,28 @@ pub(crate) fn setup_composefs_uki_boot(
12071208
/// `<tmpdir>/tmp` to provide a writable scratch area for tools invoked with
12081209
/// `--root`.
12091210
///
1210-
/// Drop order matters: the ESP and tmpfs guards are declared before `composefs`
1211-
/// so they are unmounted (and flushed) before the composefs root is detached.
1211+
/// Drop order matters: the tmpfs guard is declared before `composefs` so it
1212+
/// is unmounted (and flushed) before the composefs root is detached.
12121213
pub(crate) struct MountedImageRoot {
1213-
// Unmounted before `composefs` on drop; ESP before tmp (inner before outer).
1214-
_esp: bootc_mount::tempmount::MountGuard,
1214+
// The ESP's device path. The ESP is intentionally *not* mounted here for
1215+
// our whole lifetime; it's mounted on demand via `with_esp()` instead.
1216+
// That's because `install_via_bootupd` runs bootupd's install tooling
1217+
// inside a `ChrootCmd`, which unshares the mount namespace and
1218+
// recursively self-binds the chroot directory (our `root_path()`) onto
1219+
// itself. If the ESP were already mounted at `<root_path>/boot` when
1220+
// that happens, the self-bind would duplicate it into an
1221+
// orphaned/shadowed mountinfo entry that's still visible to naive
1222+
// `findmnt`-based scans (like bootupd's), causing it to pick the wrong
1223+
// filesystem UUID.
1224+
esp_device: Utf8PathBuf,
1225+
// Tracks whether the ESP is currently mounted, i.e. whether we're
1226+
// inside a call to `with_esp()`. Guards `open_esp_dir()` against
1227+
// returning the wrong thing: without this, calling it while the ESP
1228+
// isn't mounted would silently open the empty `esp_subdir` directory
1229+
// that already exists directly in the composefs image, rather than
1230+
// failing.
1231+
esp_mounted: Cell<bool>,
1232+
// Unmounted before `composefs` on drop.
12151233
_tmp: bootc_mount::tempmount::MountGuard,
12161234
composefs: TempMount,
12171235
pub(crate) esp_subdir: &'static str,
@@ -1247,10 +1265,6 @@ impl MountedImageRoot {
12471265
// unconditionally mount the ESP at /boot for now.
12481266
let esp_subdir = "boot";
12491267

1250-
let esp_path = composefs.dir.path().join(esp_subdir);
1251-
let esp =
1252-
mount_esp_at(&esp_part.path(), esp_path).context("Mounting ESP into composefs root")?;
1253-
12541268
// Mount a tmpfs over /tmp so that tools invoked with --root have a
12551269
// writable scratch area without touching the read-only EROFS root.
12561270
let tmp_path = composefs.dir.path().join("tmp");
@@ -1264,7 +1278,8 @@ impl MountedImageRoot {
12641278
.context("Mounting tmpfs into composefs root")?;
12651279

12661280
Ok(Self {
1267-
_esp: esp,
1281+
esp_device: esp_part.path().into(),
1282+
esp_mounted: Cell::new(false),
12681283
_tmp: tmp,
12691284
composefs,
12701285
esp_subdir,
@@ -1282,12 +1297,47 @@ impl MountedImageRoot {
12821297
}
12831298

12841299
/// Open the mounted ESP as a capability-safe directory.
1300+
///
1301+
/// Fails unless called from within a call to [`Self::with_esp`]: the ESP
1302+
/// is only actually mounted for the duration of that call, and
1303+
/// `esp_subdir` otherwise refers to an ordinary (empty) directory that
1304+
/// already exists directly in the composefs image.
12851305
pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
1306+
if !self.esp_mounted.get() {
1307+
bail!("BUG: attempted to open the ESP directory while it is not mounted");
1308+
}
12861309
self.composefs
12871310
.fd
12881311
.open_dir(self.esp_subdir)
12891312
.with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
12901313
}
1314+
1315+
/// Mount the ESP at `<tmpdir>/<esp_subdir>` for the duration of `f`, then
1316+
/// unmount it again. Nothing is mounted there outside of calls to this
1317+
/// method, so callers that need to invoke bootloader-install tooling
1318+
/// that itself creates a private mount namespace (e.g. via `ChrootCmd`)
1319+
/// can safely do so without risking this mount being shadowed/duplicated
1320+
/// by that tooling's own mount-namespace setup.
1321+
pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
1322+
let esp_path = self.root_path().join(self.esp_subdir);
1323+
let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
1324+
.context("Mounting ESP into composefs root")?;
1325+
1326+
self.esp_mounted.set(true);
1327+
// Reset `esp_mounted` when we return, including via `?` or panic,
1328+
// so a later `with_esp()` call (or a stray `open_esp_dir()` call
1329+
// after this one returns) doesn't see a stale "mounted" state.
1330+
struct ResetOnDrop<'a>(&'a Cell<bool>);
1331+
impl Drop for ResetOnDrop<'_> {
1332+
fn drop(&mut self) {
1333+
self.0.set(false);
1334+
}
1335+
}
1336+
let _reset = ResetOnDrop(&self.esp_mounted);
1337+
1338+
let dir = self.open_esp_dir()?;
1339+
f(&dir)
1340+
}
12911341
}
12921342

12931343
pub struct SecurebootKeys {
@@ -1392,56 +1442,75 @@ pub(crate) async fn setup_composefs_boot(
13921442
postfetch.detected_bootloader,
13931443
Bootloader::Grub | Bootloader::GrubCC
13941444
) {
1445+
let chroot_target = Utf8Path::from_path(mounted_root.root_path())
1446+
.ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
1447+
// Like the ostree backend, bind the physical root's real /boot (an
1448+
// ordinary ext4/xfs/... directory, not yet populated with kernels at
1449+
// this point) into the chroot. This gives bootupd both a correct
1450+
// filesystem to inspect for `--write-uuid` (rather than the ESP,
1451+
// which is otherwise mounted at the composefs root's own /boot) and
1452+
// an empty `boot/efi` directory for its EFI component to discover
1453+
// and mount the real ESP into, exactly as it would on ostree.
1454+
let bind_boot_path = root_setup.physical_root_path.join("boot");
13951455
crate::bootloader::install_via_bootupd(
13961456
&root_setup.device_info,
13971457
&root_setup.physical_root_path,
13981458
&state.config_opts,
1399-
None,
1459+
Some(chroot_target),
1460+
Some(bind_boot_path.as_path()),
14001461
)?;
14011462

14021463
// FIXME: Remove this hack once we have support in bootupd
14031464
if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) {
1465+
// bootupctl wrote this under the physical root's real /boot (via
1466+
// the bind mount above), not under the composefs root.
14041467
root_setup
14051468
.physical_root
1406-
.remove_dir_all("boot/grub2")
1469+
.remove_all_optional("boot/grub2")
14071470
.context("removing grub2")?;
14081471

1409-
let (os_id, ..) = parse_os_release(mounted_root.dir())?
1410-
.ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1411-
1412-
let dir = format!("EFI/{os_id}");
1413-
1414-
// Files are in EFI/<os-name>/
1415-
let efis_dir = mounted_root
1416-
.open_esp_dir()
1417-
.context("opening esp")?
1418-
.open_dir(&dir)
1419-
.with_context(|| format!("Opening {dir}"))?;
1420-
1421-
efis_dir
1422-
.remove_file_optional("bootuuid.cfg")
1423-
.context("Removing bootuuid.cfg")?;
1424-
efis_dir
1425-
.remove_file_optional("grub.cfg")
1426-
.context("Removing grub.cfg")?;
1427-
1428-
let final_name = match std::env::consts::ARCH {
1429-
"x86_64" => "grubx64.efi",
1430-
"aarch64" => "grubaa64-cc.efi",
1431-
arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1432-
};
1472+
// install_via_bootupd above has already returned, so it's safe
1473+
// to mount the ESP here for the duration of this cleanup.
1474+
mounted_root.with_esp(|esp_dir| {
1475+
let (os_id, ..) = parse_os_release(mounted_root.dir())?
1476+
.ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1477+
1478+
let dir = format!("EFI/{os_id}");
1479+
1480+
// Files are in EFI/<os-name>/
1481+
let efis_dir = esp_dir
1482+
.open_dir(&dir)
1483+
.with_context(|| format!("Opening {dir}"))?;
1484+
1485+
efis_dir
1486+
.remove_file_optional("bootuuid.cfg")
1487+
.context("Removing bootuuid.cfg")?;
1488+
efis_dir
1489+
.remove_file_optional("grub.cfg")
1490+
.context("Removing grub.cfg")?;
1491+
1492+
let final_name = match std::env::consts::ARCH {
1493+
"x86_64" => "grubx64.efi",
1494+
"aarch64" => "grubaa64-cc.efi",
1495+
arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1496+
};
1497+
1498+
mounted_root
1499+
.dir()
1500+
.copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1501+
.context("Copying grub-cc binary")?;
14331502

1434-
mounted_root
1435-
.dir()
1436-
.copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1437-
.context("Copying grub-cc binary")?;
1503+
Ok(())
1504+
})?;
14381505
}
14391506
} else {
1440-
crate::bootloader::install_systemd_boot(
1441-
&mounted_root,
1442-
&state.config_opts,
1443-
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1444-
)?;
1507+
mounted_root.with_esp(|_esp_dir| {
1508+
crate::bootloader::install_systemd_boot(
1509+
&mounted_root,
1510+
&state.config_opts,
1511+
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1512+
)
1513+
})?;
14451514
}
14461515

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

crates/lib/src/bootloader.rs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::process::Command;
33
use std::sync::OnceLock;
44

55
use anyhow::{Context, Result, anyhow, bail};
6-
use bootc_utils::{ChrootCmd, CommandRunExt};
6+
use bootc_utils::{BindMode, ChrootCmd, CommandRunExt};
77
use camino::Utf8Path;
88
use cap_std_ext::cap_std::fs::Dir;
99
use cap_std_ext::dirext::CapStdExtDirExt;
@@ -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,15 +230,24 @@ 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(boot_path, &"/boot", BindMode::Default);
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.
221249
if root_device_path.is_none() {
222-
cmd = cmd.bind(&rootfs_path, &"/sysroot");
250+
cmd = cmd.bind(&rootfs_path, &"/sysroot", BindMode::Recursive);
223251
}
224252

225253
// ChrootCmd starts the child with a cleared environment, so we

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 => {

0 commit comments

Comments
 (0)