Skip to content

Commit 383aafc

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 ce63f69 commit 383aafc

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;
@@ -1201,11 +1202,28 @@ pub(crate) fn setup_composefs_uki_boot(
12011202
/// `<tmpdir>/tmp` to provide a writable scratch area for tools invoked with
12021203
/// `--root`.
12031204
///
1204-
/// Drop order matters: the ESP and tmpfs guards are declared before `composefs`
1205-
/// so they are unmounted (and flushed) before the composefs root is detached.
1205+
/// Drop order matters: the tmpfs guard is declared before `composefs` so it
1206+
/// is unmounted (and flushed) before the composefs root is detached.
12061207
pub(crate) struct MountedImageRoot {
1207-
// Unmounted before `composefs` on drop; ESP before tmp (inner before outer).
1208-
_esp: bootc_mount::tempmount::MountGuard,
1208+
// The ESP's device path. The ESP is intentionally *not* mounted here for
1209+
// our whole lifetime; it's mounted on demand via `with_esp()` instead.
1210+
// That's because `install_via_bootupd` runs bootupd's install tooling
1211+
// inside a `ChrootCmd`, which unshares the mount namespace and
1212+
// recursively self-binds the chroot directory (our `root_path()`) onto
1213+
// itself. If the ESP were already mounted at `<root_path>/boot` when
1214+
// that happens, the self-bind would duplicate it into an
1215+
// orphaned/shadowed mountinfo entry that's still visible to naive
1216+
// `findmnt`-based scans (like bootupd's), causing it to pick the wrong
1217+
// filesystem UUID.
1218+
esp_device: Utf8PathBuf,
1219+
// Tracks whether the ESP is currently mounted, i.e. whether we're
1220+
// inside a call to `with_esp()`. Guards `open_esp_dir()` against
1221+
// returning the wrong thing: without this, calling it while the ESP
1222+
// isn't mounted would silently open the empty `esp_subdir` directory
1223+
// that already exists directly in the composefs image, rather than
1224+
// failing.
1225+
esp_mounted: Cell<bool>,
1226+
// Unmounted before `composefs` on drop.
12091227
_tmp: bootc_mount::tempmount::MountGuard,
12101228
composefs: TempMount,
12111229
pub(crate) esp_subdir: &'static str,
@@ -1241,10 +1259,6 @@ impl MountedImageRoot {
12411259
// unconditionally mount the ESP at /boot for now.
12421260
let esp_subdir = "boot";
12431261

1244-
let esp_path = composefs.dir.path().join(esp_subdir);
1245-
let esp =
1246-
mount_esp_at(&esp_part.path(), esp_path).context("Mounting ESP into composefs root")?;
1247-
12481262
// Mount a tmpfs over /tmp so that tools invoked with --root have a
12491263
// writable scratch area without touching the read-only EROFS root.
12501264
let tmp_path = composefs.dir.path().join("tmp");
@@ -1258,7 +1272,8 @@ impl MountedImageRoot {
12581272
.context("Mounting tmpfs into composefs root")?;
12591273

12601274
Ok(Self {
1261-
_esp: esp,
1275+
esp_device: esp_part.path().into(),
1276+
esp_mounted: Cell::new(false),
12621277
_tmp: tmp,
12631278
composefs,
12641279
esp_subdir,
@@ -1276,12 +1291,47 @@ impl MountedImageRoot {
12761291
}
12771292

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

12871337
pub struct SecurebootKeys {
@@ -1386,56 +1436,75 @@ pub(crate) async fn setup_composefs_boot(
13861436
postfetch.detected_bootloader,
13871437
Bootloader::Grub | Bootloader::GrubCC
13881438
) {
1439+
let chroot_target = Utf8Path::from_path(mounted_root.root_path())
1440+
.ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
1441+
// Like the ostree backend, bind the physical root's real /boot (an
1442+
// ordinary ext4/xfs/... directory, not yet populated with kernels at
1443+
// this point) into the chroot. This gives bootupd both a correct
1444+
// filesystem to inspect for `--write-uuid` (rather than the ESP,
1445+
// which is otherwise mounted at the composefs root's own /boot) and
1446+
// an empty `boot/efi` directory for its EFI component to discover
1447+
// and mount the real ESP into, exactly as it would on ostree.
1448+
let bind_boot_path = root_setup.physical_root_path.join("boot");
13891449
crate::bootloader::install_via_bootupd(
13901450
&root_setup.device_info,
13911451
&root_setup.physical_root_path,
13921452
&state.config_opts,
1393-
None,
1453+
Some(chroot_target),
1454+
Some(bind_boot_path.as_path()),
13941455
)?;
13951456

13961457
// FIXME: Remove this hack once we have support in bootupd
13971458
if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) {
1459+
// bootupctl wrote this under the physical root's real /boot (via
1460+
// the bind mount above), not under the composefs root.
13981461
root_setup
13991462
.physical_root
1400-
.remove_dir_all("boot/grub2")
1463+
.remove_all_optional("boot/grub2")
14011464
.context("removing grub2")?;
14021465

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

1428-
mounted_root
1429-
.dir()
1430-
.copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1431-
.context("Copying grub-cc binary")?;
1497+
Ok(())
1498+
})?;
14321499
}
14331500
} else {
1434-
crate::bootloader::install_systemd_boot(
1435-
&mounted_root,
1436-
&state.config_opts,
1437-
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1438-
)?;
1501+
mounted_root.with_esp(|_esp_dir| {
1502+
crate::bootloader::install_systemd_boot(
1503+
&mounted_root,
1504+
&state.config_opts,
1505+
get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1506+
)
1507+
})?;
14391508
}
14401509

14411510
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)