Skip to main content

max / alloy

Mount the boot partition before asking ostree where the deployment is Every encrypted install failed at `ostree admin --print-current-dir` with "Unable to find a deployment in sysroot", and the recovery then wiped a disk that had deployed perfectly. It failed this way on fw12 and in qemu; an unencrypted install from the same medium was fine. ostree does not answer that question by looking in `ostree/deploy`. It builds its deployment list from the bootloader entries under `<sysroot>/boot/loader`, and an encrypted install puts /boot on its own partition, because the bootloader has to read the kernel before anything can be unlocked. Mounting only the root filesystem left ostree with nothing to find. Measured on a target held back from the recovery by running bootc by hand from the medium's debug shell, which is the first time one of these disks has been read before something erased it: the deployment and its .origin were both present, `<mount>/boot` was empty, and mounting the boot partition there and changing nothing else made both `--print-current-dir` and `admin status` answer. Read-only, which is all ostree needs and what the deployment's own fstab mounts it as; `bootc install finalize` exits 0 against it. Found by label, because the partition carries the generic Linux filesystem GUID and cannot be identified the way the root one is. The target is then two nested mounts, so `unmount` takes -R: a plain umount of the mountpoint above /boot exits 32, which would strand the filesystem mounted across the reboot the user is about to perform. Verified end to end in qemu with a software TPM, and on fw12: the install completes, and the machine boots, unlocks and logs in.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 01:50 UTC
Signed with PGP, not checked
Commit: 8bc957b5c6bcc1e09644ea1b3426924f494c1af8
Parent: a9783ab
1 file changed, +197 insertions, -17 deletions
@@ -557,6 +557,24 @@
557 557 /// deployment directory." That makes the configuration target a value
558 558 /// discovered at install time rather than a constant, which is the structural
559 559 /// change this function represents.
560 + ///
561 + /// # `<mount>/boot` has to be mounted first
562 + ///
563 + /// ostree does not answer this by looking in `ostree/deploy`. It builds the
564 + /// deployment list from the bootloader entries under `<sysroot>/boot/loader`,
565 + /// so a sysroot whose `/boot` is a separate unmounted partition has no
566 + /// deployments as far as it is concerned, however complete the tree under
567 + /// `ostree/deploy` is. That is every encrypted install: bootc puts `/boot`
568 + /// outside the LUKS container, and mounting only the root filesystem left this
569 + /// command reporting "Unable to find a deployment in sysroot" about a disk that
570 + /// had deployed perfectly. The install then wiped it.
571 + ///
572 + /// Measured in a VM 2026-08-09, on a target held back from the recovery that
573 + /// had erased every previous one: the deployment directory and its `.origin`
574 + /// were both present on the root filesystem, `<mount>/boot` was empty, this
575 + /// command exited 1 and `ostree admin status` said "No deployments"; mounting
576 + /// the boot partition at `<mount>/boot` and changing nothing else made both
577 + /// answer. See [`boot_partition`] for how it is found.
560 578 fn deployment_dir(mount: &str) -> Invocation {
561 579 Invocation::new("ostree")
562 580 .arg("admin")
@@ -608,6 +626,64 @@
608 626 .arg(partition)
609 627 }
610 628
629 + /// The filesystem label bootc gives the separate `/boot` partition.
630 + ///
631 + /// bootc creates that partition only when the root is encrypted: the bootloader
632 + /// has to read the kernel before anything can be unlocked, so `/boot` cannot
633 + /// live inside the LUKS container the way it does on a plain install. It writes
634 + /// the label itself, with `mkfs.xfs -L boot`.
635 + ///
636 + /// A label rather than a partition type GUID, which is what [`root_partition`]
637 + /// matches on and would be the obvious symmetry. It cannot be used here: the
638 + /// boot partition carries the generic `0fc63daf-…` Linux filesystem GUID,
639 + /// indistinguishable from any other data partition, so matching by type would
640 + /// mean identifying it by elimination and knowing every partition bootc might
641 + /// ever add. The label is the one thing bootc sets about this filesystem on
642 + /// purpose, and it is what the deployment's own fstab resolves to.
643 + const BOOT_LABEL: &str = "boot";
644 +
645 + /// Ask lsblk for the partitions of `disk` and their filesystem labels.
646 + fn partition_labels(disk: &str) -> Invocation {
647 + Invocation::new("lsblk")
648 + .args(["-J", "-o", "NAME,PATH,LABEL"])
649 + .arg(disk)
650 + }
651 +
652 + /// The partition holding `/boot`, when it is not on the root filesystem.
653 + ///
654 + /// `None` is the ordinary answer on an unencrypted install, where there is no
655 + /// such partition because `/boot` is a directory in the root filesystem. It is
656 + /// not an error: it says the sysroot is already complete once the root is
657 + /// mounted.
658 + ///
659 + /// Read off the disk rather than from `choices.encryption`, for the reason
660 + /// [`filesystem_device`] gives: what bootc built is a fact the disk holds, and
661 + /// reading it is what stays right if bootc changes the layout.
662 + fn boot_partition(listing: &str) -> Result<Option<String>, String> {
663 + #[derive(Deserialize)]
664 + struct Listing {
665 + blockdevices: Vec<Node>,
666 + }
667 + #[derive(Deserialize)]
668 + struct Node {
669 + path: String,
670 + #[serde(default)]
671 + label: Option<String>,
672 + #[serde(default)]
673 + children: Vec<Node>,
674 + }
675 +
676 + let parsed: Listing = serde_json::from_str(listing)
677 + .map_err(|err| format!("lsblk emitted invalid JSON: {err}"))?;
678 +
679 + Ok(parsed
680 + .blockdevices
681 + .iter()
682 + .flat_map(|disk| disk.children.iter())
683 + .find(|node| node.label.as_deref() == Some(BOOT_LABEL))
684 + .map(|node| node.path.clone()))
685 + }
686 +
611 687 /// The device actually holding the root filesystem.
612 688 ///
613 689 /// Without encryption that is the root partition itself. With it, the partition
@@ -699,8 +775,18 @@
699 775 }
700 776
701 777 /// Unmount one leftover so the target's superblock is released.
778 + ///
779 + /// `-R` because the target is not always a single mount. An encrypted install
780 + /// has a separate `/boot` partition mounted inside it (see [`deployment_dir`]),
781 + /// and a plain `umount` of the mountpoint above it exits 32, "target is busy" —
782 + /// which would leave the filesystem mounted across the reboot the user is about
783 + /// to perform, and leave the recovery unable to remount it. Measured in a VM
784 + /// 2026-08-09: 32 for the plain form against that target, 0 for this one.
785 + ///
786 + /// It changes nothing where there is nothing nested, which is every other
787 + /// caller.
702 788 fn unmount(target: &str) -> Invocation {
703 - Invocation::new("umount").arg(target)
789 + Invocation::new("umount").arg("-R").arg(target)
704 790 }
705 791
706 792 /// Run one of the target's own programs inside it, rather than pointing the
@@ -2288,6 +2374,8 @@
2288 2374 let contents_end = dead_end.clone();
2289 2375 let mount_end = dead_end.clone();
2290 2376 let deployment_end = dead_end;
2377 + // The disk again, for the boot-partition discovery three closures down.
2378 + let boot_disk = disk.to_string();
2291 2379
2292 2380 vec![
2293 2381 // bootc returns when the install is done, not when the kernel and
@@ -2357,24 +2445,68 @@
2357 2445 if let Err(message) = writable_mount(options) {
2358 2446 return or_dead_end(Err(message), mount_end.as_ref());
2359 2447 }
2360 - // Third discovery, only possible once
2361 - // mounted: where the deployment is inside
2362 - // the sysroot.
2448 + // Third discovery: whether this sysroot's
2449 + // /boot is a partition of its own, which is
2450 + // what an encrypted install builds. Until
2451 + // it is mounted the sysroot has no
2452 + // bootloader entries, and the deployment
2453 + // below is invisible to ostree however
2454 + // complete it is. See [`deployment_dir`].
2363 2455 Ok(vec![Stage::Resolve {
2364 - invocation: deployment_dir(TARGET_MOUNT),
2365 - then: Box::new(move |printed| {
2366 - let deployment = printed.trim();
2367 - if deployment.is_empty() {
2368 - return or_dead_end(
2369 - Err("ostree reported no current deployment"
2370 - .into()),
2371 - deployment_end.as_ref(),
2372 - );
2456 + invocation: partition_labels(&boot_disk),
2457 + then: Box::new(move |listing| {
2458 + let boot_end = deployment_end.clone();
2459 + let boot = match boot_partition(listing) {
2460 + Ok(boot) => boot,
2461 + Err(message) => {
2462 + return or_dead_end(
2463 + Err(message),
2464 + boot_end.as_ref(),
2465 + );
2466 + }
2467 + };
2468 +
2469 + let mut stages = Vec::new();
2470 + if let Some(boot) = boot {
2471 + // Read-only, which is all ostree
2472 + // needs to read the entries and
2473 + // what the deployment's own
2474 + // fstab mounts it as. Nothing
2475 + // the installer does after this
2476 + // writes to /boot: measured, on
2477 + // the same VM target, that
2478 + // `bootc install finalize`
2479 + // exits 0 against it.
2480 + stages.push(Stage::Run(
2481 + Invocation::new("mount")
2482 + .args(["-o", "ro"])
2483 + .arg(&boot)
2484 + .arg(format!("{TARGET_MOUNT}/boot")),
2485 + ));
2373 2486 }
2374 - or_dead_end(
2375 - then(&partition, &device, deployment),
2376 - deployment_end.as_ref(),
2377 - )
2487 +
2488 + // Fourth discovery, only possible
2489 + // once both are mounted: where the
2490 + // deployment is inside the sysroot.
2491 + stages.push(Stage::Resolve {
2492 + invocation: deployment_dir(TARGET_MOUNT),
2493 + then: Box::new(move |printed| {
2494 + let deployment = printed.trim();
2495 + if deployment.is_empty() {
2496 + return or_dead_end(
2497 + Err("ostree reported no current \
2498 + deployment"
2499 + .into()),
2500 + deployment_end.as_ref(),
2501 + );
2502 + }
2503 + or_dead_end(
2504 + then(&partition, &device, deployment),
2505 + deployment_end.as_ref(),
2506 + )
2507 + }),
2508 + });
2509 + Ok(stages)
2378 2510 }),
2379 2511 }])
2380 2512 }),
@@ -5760,6 +5892,54 @@
5760 5892 assert!(err.contains("unopened LUKS"), "{err}");
5761 5893 }
5762 5894
5895 + // The layout an encrypted install leaves, verbatim from lsblk in a VM: a
5896 + // labelled boot partition alongside the LUKS container. Without it mounted
5897 + // ostree reports no deployment and the install wipes a disk that deployed.
5898 + #[test]
5899 + fn an_encrypted_layout_names_the_partition_holding_boot() {
5900 + let listing = r#"{"blockdevices":[{"name":"vda","path":"/dev/vda","label":null,
5901 + "children":[
5902 + {"name":"vda1","path":"/dev/vda1","label":null},
5903 + {"name":"vda2","path":"/dev/vda2","label":"EFI-SYSTEM"},
5904 + {"name":"vda3","path":"/dev/vda3","label":"boot"},
5905 + {"name":"vda4","path":"/dev/vda4","label":null,
5906 + "children":[{"name":"root","path":"/dev/mapper/root","label":"root"}]}]}]}"#;
5907 + assert_eq!(
5908 + boot_partition(listing).unwrap(),
5909 + Some("/dev/vda3".to_string())
5910 + );
5911 + }
5912 +
5913 + // An unencrypted one keeps /boot in the root filesystem, so there is
5914 + // nothing to mount. `None` is that answer and not a failure.
5915 + #[test]
5916 + fn a_plain_layout_has_no_boot_partition_to_mount() {
5917 + let listing = r#"{"blockdevices":[{"name":"vda","path":"/dev/vda","label":null,
5918 + "children":[
5919 + {"name":"vda1","path":"/dev/vda1","label":null},
5920 + {"name":"vda2","path":"/dev/vda2","label":"EFI-SYSTEM"},
5921 + {"name":"vda3","path":"/dev/vda3","label":"root"}]}]}"#;
5922 + assert_eq!(boot_partition(listing).unwrap(), None);
5923 + }
5924 +
5925 + // The listing has to nest, for the reason the partition listing does: the
5926 + // partitions are children of the disk, and lsblk builds that tree around
5927 + // NAME. Asking for PATH,LABEL alone would return the disk and nothing else.
5928 + #[test]
5929 + fn the_label_listing_asks_for_the_column_lsblk_nests_around() {
5930 + let shown = Stage::Run(partition_labels("/dev/sda")).display();
5931 + assert!(shown.contains("NAME"), "{shown}");
5932 + assert!(shown.contains("LABEL"), "{shown}");
5933 + }
5934 +
5935 + // An encrypted target is two mounts, the inner one inside the outer. A
5936 + // plain umount of the outer exits 32 and leaves the filesystem mounted
5937 + // across the reboot.
5938 + #[test]
5939 + fn the_target_comes_off_recursively() {
5940 + assert!(Stage::Run(unmount(TARGET_MOUNT)).display().contains("-R"));
5941 + }
5942 +
5763 5943 // Both slots bootc wipes, enrolled against the container rather than the
5764 5944 // mapper device: the header is on the partition.
5765 5945 #[test]