max / alloy
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] |