Skip to main content

max / alloy

install: wait for udev before reading the partition table bootc returns when the install is done, not when udev has caught up with the table it just wrote. In that window lsblk reports parttype null for every partition, so the discovery finds no DPS root GUID and fails with "disk not deployed" -- about a disk that deployed perfectly, at the one moment the user can least afford a wrong answer. Seen against a real bootc 1.16.3 install to a scratch device: null for all three partitions immediately after it returned, the correct GUIDs a moment later. partprobe beforehand did not close the window. udevadm settle waits for the specific event rather than retrying, and the real listing from that install is pinned as a fixture so the GUID the code matches on stays the one bootc actually sets.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 17:39 UTC
Signed with PGP, not checked
Commit: f03ae007c3c9227568eaa5c4b78a45dc04ca8fa4
Parent: 11045f1
1 file changed, +66 insertions, -5 deletions
@@ -592,6 +592,18 @@
592 592 .args(["install", "to-disk", "--wipe"])
593 593 .arg(disk),
594 594 ),
595 + // bootc returns when the install is done, not when the kernel and
596 + // udev have caught up with the partition table it wrote. In that
597 + // window `lsblk` answers with a `parttype` of null for every
598 + // partition, so the discovery below finds no root and fails with
599 + // "disk not deployed" about a disk that deployed perfectly. Observed
600 + // against a real bootc 1.16.3 install: null for all three partitions
601 + // immediately after, the correct GUIDs a moment later.
602 + //
603 + // Waiting for the queue to drain is the fix rather than a retry loop:
604 + // there is a specific event to wait for, and a retry would just be
605 + // this wait spelled less precisely.
606 + Stage::Run(Invocation::new("udevadm").arg("settle")),
595 607 Stage::Run(Invocation::new("mkdir").args(["-p", TARGET_MOUNT])),
596 608 // First discovery: which partition bootc made the root.
597 609 Stage::Resolve {
@@ -2081,10 +2093,11 @@
2081 2093 let (view, _log) = at_summary();
2082 2094 let shown: Vec<String> = view.plan().iter().map(Stage::display).collect();
2083 2095
2084 - assert_eq!(shown.len(), 3, "{shown:#?}");
2096 + assert_eq!(shown.len(), 4, "{shown:#?}");
2085 2097 assert_eq!(shown[0], "bootc install to-disk --wipe /dev/sda");
2086 - assert_eq!(shown[1], "mkdir -p /mnt/alloy-target");
2087 - assert!(shown[2].starts_with("lsblk"), "{}", shown[2]);
2098 + assert_eq!(shown[1], "udevadm settle");
2099 + assert_eq!(shown[2], "mkdir -p /mnt/alloy-target");
2100 + assert!(shown[3].starts_with("lsblk"), "{}", shown[3]);
2088 2101 }
2089 2102
2090 2103 // The summary can only show what is known before anything runs. Everything
@@ -2402,6 +2415,54 @@
2402 2415 assert!(root_partition("not json").unwrap_err().contains("JSON"));
2403 2416 }
2404 2417
2418 + // Verbatim from `lsblk -J -o PATH,PARTTYPE` against a disk bootc 1.16.3
2419 + // had just installed to, once udev had settled. A hand-written fixture
2420 + // cannot show that the GUID the code matches on is the one bootc sets;
2421 + // this can, and it is the whole reason the discovery exists.
2422 + #[test]
2423 + fn a_real_bootc_layout_yields_its_root_partition() {
2424 + let listing = r#"{"blockdevices":[
2425 + {"path": "/dev/nbd0", "parttype": null},
2426 + {"path": "/dev/nbd0p1", "parttype": "21686148-6449-6e6f-744e-656564454649"},
2427 + {"path": "/dev/nbd0p2", "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"},
2428 + {"path": "/dev/nbd0p3", "parttype": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"}
2429 + ]}"#;
2430 + assert_eq!(root_partition(listing).unwrap(), "/dev/nbd0p3");
2431 + }
2432 +
2433 + // The same disk a moment earlier, before udev caught up with the table
2434 + // bootc had written. Every parttype reads null, so the discovery reports
2435 + // a disk that was in fact deployed as undeployed. install_plan waits on
2436 + // `udevadm settle` to keep this listing from ever reaching the parser;
2437 + // pinned here so the reason that stage exists survives it.
2438 + #[test]
2439 + fn an_unsettled_listing_is_why_the_plan_waits_for_udev() {
2440 + let listing = r#"{"blockdevices":[
2441 + {"path": "/dev/nbd0", "parttype": null},
2442 + {"path": "/dev/nbd0p1", "parttype": null},
2443 + {"path": "/dev/nbd0p2", "parttype": null},
2444 + {"path": "/dev/nbd0p3", "parttype": null}
2445 + ]}"#;
2446 + assert!(root_partition(listing).is_err());
2447 + }
2448 +
2449 + // The stage ordering the race turns on: nothing may look at the
2450 + // partition table between bootc writing it and udev settling.
2451 + #[test]
2452 + fn the_plan_settles_udev_before_it_reads_the_partition_table() {
2453 + let plan = install_plan("/dev/sda", "host", "user", "pw");
2454 + let lines: Vec<String> = plan.iter().map(Stage::display).collect();
2455 + let settle = lines
2456 + .iter()
2457 + .position(|line| line.contains("udevadm"))
2458 + .expect("the plan waits for udev");
2459 + let install = lines
2460 + .iter()
2461 + .position(|line| line.contains("install to-disk"))
2462 + .expect("the plan installs");
2463 + assert!(install < settle, "settle must follow the install: {lines:?}");
2464 + }
2465 +
2405 2466 // Missing answers cannot happen from the summary — every step gates on its
2406 2467 // own validation — but a partial plan would render a command with a hole in
2407 2468 // it, so it returns nothing instead.
@@ -2424,8 +2485,8 @@
2424 2485
2425 2486 let sequence = view.running.as_ref().expect("nothing was queued");
2426 2487 assert!(!sequence.is_done());
2427 - // Three up front; the rest appear as the discoveries resolve.
2428 - assert_eq!(sequence.progress(), (0, 3));
2488 + // Four up front; the rest appear as the discoveries resolve.
2489 + assert_eq!(sequence.progress(), (0, 4));
2429 2490 assert!(sequence.output().is_empty(), "a command ran during confirm");
2430 2491 }
2431 2492