From efd3f2d6de93c1a3bb1292858f38d2b6d241e180 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 24 Apr 2026 22:45:17 +0000 Subject: [PATCH] blockdev: Fall back to blkid -p when udev is unavailable See https://github.com/osbuild/osbuild/pull/2428 When running inside a container or sandbox without /run/udev bind-mounted (e.g. osbuild's bwrap), lsblk returns null for partition metadata fields like parttype (partition type GUID) and pttype (partition table type), because these come from the udev database. This causes partition discovery to fail -- most visibly on ppc64le where the PReP partition can't be found by GUID, but also affecting ESP discovery on all architectures. On x86_64 UEFI, bootupd silently falls back to mounted-ESP detection, masking the problem; on ppc64le there is no fallback and the install fails hard. Add a blkid -p fallback in backfill_missing() that probes partition metadata directly from the disk when udev is absent. The udev check tests for /run/udev/data (the actual database directory) rather than /run/udev, because bootc's ensure_mirrored_host_mount() creates an empty /run/udev directory on the bwrap tmpfs that is_same_as_host() then considers 'already mounted'. Tested end-to-end via the BIB tmt test (plan-33-bib-build) with a patched bootupd that removes the mounted-ESP fallback entirely. This follows the same approach coreos-installer uses (PRs #1511, sfdisk-based workaround originally added in PR #688. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/blockdev/src/blockdev.rs | 102 +++++- .../tests/fixtures/lsblk-no-udev.json | 312 ++++++++++++++++++ crates/lib/src/cli.rs | 37 +++ .../readonly/016-test-blockdev-no-udev.nu | 17 + 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 crates/blockdev/tests/fixtures/lsblk-no-udev.json create mode 100644 tmt/tests/booted/readonly/016-test-blockdev-no-udev.nu diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 44c23d34e..ea0d0f656 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -1,7 +1,8 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::env; use std::path::Path; use std::process::{Command, Stdio}; +use std::sync::OnceLock; use anyhow::{Context, Result, anyhow}; use camino::{Utf8Path, Utf8PathBuf}; @@ -11,6 +12,64 @@ use serde::Deserialize; use bootc_utils::CommandRunExt; +/// Check whether the udev database is accessible (cached for the process lifetime). +/// +/// When running inside a container or sandbox without `/run/udev` +/// bind-mounted, tools like `lsblk` that depend on the udev database +/// will return null for fields like `parttype` and `fstype`. +/// +/// We check for `/run/udev/data` (the actual database directory) rather +/// than just `/run/udev` because the parent directory can exist as an +/// empty mount point without the database being populated. +fn have_udev() -> bool { + static HAVE_UDEV: OnceLock = OnceLock::new(); + *HAVE_UDEV.get_or_init(|| { + let r = Path::new("/run/udev/data").exists(); + if !r { + tracing::debug!( + "udev database not available, will use blkid -p for partition metadata" + ); + } + r + }) +} + +/// Probe a device with `blkid -p` and return all discovered properties +/// as key-value pairs. +/// +/// This uses the `export` output format (`KEY=value`, one per line) to +/// retrieve all tags in a single invocation, rather than spawning blkid +/// once per property. +/// +/// Returns `Ok(empty map)` if blkid exits with code 2 (no tags found, +/// e.g. the device is a whole disk). Other non-zero exits are propagated +/// as errors. +fn blkid_probe(dev: &str) -> Result> { + let mut cmd = Command::new("blkid"); + cmd.args(["-p", "-o", "export"]).arg(dev); + cmd.log_debug(); + let output = cmd.output().context("Failed to run blkid")?; + if !output.status.success() { + // blkid exits with 2 when no tags are found (e.g. whole disk) + if output.status.code() == Some(2) { + return Ok(HashMap::new()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "blkid -p failed on {dev} (exit status {}): {stderr}", + output.status + ); + } + let text = String::from_utf8(output.stdout).context("blkid output is not UTF-8")?; + let mut props = HashMap::new(); + for line in text.lines() { + if let Some((key, value)) = line.split_once('=') { + props.insert(key.to_string(), value.to_string()); + } + } + Ok(props) +} + /// MBR partition type IDs that indicate an EFI System Partition. /// 0x06 is FAT16 (used as ESP on some MBR systems), 0xEF is the /// explicit EFI System Partition type. @@ -29,7 +88,7 @@ struct DevicesOutput { } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, serde::Serialize, Deserialize)] pub struct Device { pub name: String, pub serial: Option, @@ -265,7 +324,12 @@ impl Device { Ok(Some(parsed)) } - /// Older versions of util-linux may be missing some properties. Backfill them if they're missing. + /// Backfill properties that may be missing from lsblk output. + /// + /// Older versions of util-linux may lack `start` and `partn`; these are + /// backfilled from sysfs. When the udev database is unavailable (e.g. + /// inside a container sandbox), `parttype` and `pttype` are backfilled + /// via `blkid -p` which reads directly from the disk. pub fn backfill_missing(&mut self) -> Result<()> { // The "start" parameter was only added in a version of util-linux that's only // in Fedora 40 as of this writing. @@ -277,6 +341,18 @@ impl Device { if self.partn.is_none() { self.partn = self.read_sysfs_property("partition")?; } + // When udev is unavailable, lsblk can't populate parttype/pttype from + // the udev database. Fall back to blkid -p which probes the disk + // directly. See https://github.com/osbuild/osbuild/pull/2428 + if !have_udev() && (self.parttype.is_none() || self.pttype.is_none()) { + let props = blkid_probe(&self.path())?; + if self.parttype.is_none() { + self.parttype = props.get("PART_ENTRY_TYPE").cloned(); + } + if self.pttype.is_none() { + self.pttype = props.get("PTTYPE").cloned(); + } + } // Recurse to child devices for child in self.children.iter_mut().flatten() { child.backfill_missing()?; @@ -673,6 +749,26 @@ mod test { assert_eq!(bios.parttype.as_deref().unwrap(), BIOS_BOOT); } + /// Verify that without the udev database, partition type fields are null + /// and partition discovery fails. This simulates what happens when bootc + /// runs inside a sandbox (like osbuild's bwrap) without /run/udev. + #[test] + fn test_parse_lsblk_no_udev() { + let fixture = include_str!("../tests/fixtures/lsblk-no-udev.json"); + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + let dev = devs.blockdevices.into_iter().next().unwrap(); + // Without udev, parttype and pttype are null + assert!(dev.pttype.is_none()); + let children = dev.children.as_deref().unwrap(); + assert_eq!(children.len(), 3); + assert!(children[0].parttype.is_none()); + assert!(children[1].parttype.is_none()); + assert!(children[2].parttype.is_none()); + // ESP and BIOS boot discovery should fail (no parttype to match) + assert!(dev.find_partition_of_esp_optional().unwrap().is_none()); + assert!(dev.find_partition_of_bios_boot().is_none()); + } + #[test] fn test_parse_lsblk_mbr() { let fixture = include_str!("../tests/fixtures/lsblk-mbr.json"); diff --git a/crates/blockdev/tests/fixtures/lsblk-no-udev.json b/crates/blockdev/tests/fixtures/lsblk-no-udev.json new file mode 100644 index 000000000..10c9e0498 --- /dev/null +++ b/crates/blockdev/tests/fixtures/lsblk-no-udev.json @@ -0,0 +1,312 @@ +{ + "blockdevices": [ + { + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda", + "label": null, + "log-sec": 512, + "maj:min": "252:0", + "maj": "252", + "min": "0", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/vda", + "phy-sec": 512, + "pkname": null, + "pttype": null, + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 10737418240, + "start": null, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "disk", + "uuid": null, + "vendor": "0x1af4", + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0, + "children": [ + { + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda1", + "label": null, + "log-sec": 512, + "maj:min": "252:1", + "maj": "252", + "min": "1", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda1", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": 1, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/vda1", + "phy-sec": 512, + "pkname": "vda", + "pttype": null, + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 1048576, + "start": 2048, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "part", + "uuid": null, + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + },{ + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda2", + "label": null, + "log-sec": 512, + "maj:min": "252:2", + "maj": "252", + "min": "2", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda2", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": 2, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/vda2", + "phy-sec": 512, + "pkname": "vda", + "pttype": null, + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 536870912, + "start": 4096, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": "virtio", + "type": "part", + "uuid": null, + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + },{ + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 512, + "disk-seq": 1, + "disc-max": 2147483136, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + "/ostree/deploy/default/var", "/ostree/deploy/default/var", "/ostree/deploy/default/deploy/41b7689b3d723570fcea1942007139dbbd7a4dfa7225b1747b47ddb67b37955a.0/etc", "/boot", "/" + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "disk", + "hctl": null, + "hotplug": false, + "kname": "vda3", + "label": null, + "log-sec": 512, + "maj:min": "252:3", + "maj": "252", + "min": "3", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 2", + "name": "vda3", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": 3, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/vda3", + "phy-sec": 512, + "pkname": "vda", + "pttype": null, + "ptuuid": "6596b2ac-09cd-41a0-8229-74bc5157d879", + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": true, + "rq-size": 256, + "sched": "none", + "serial": null, + "size": 10197401600, + "start": 1052672, + "state": null, + "subsystems": "block:virtio:pci", + "mountpoint": "/sysroot", + "mountpoints": [ + "/var", "/sysroot/ostree/deploy/default/var", "/etc", "/boot", "/sysroot" + ], + "tran": "virtio", + "type": "part", + "uuid": null, + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0 + } + ] + } + ] +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index e4fa2a70c..f8280f811 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -714,6 +714,31 @@ pub(crate) enum InternalsOpts { #[clap(long)] dry_run: bool, }, + /// Block device inspection tools. + #[clap(subcommand)] + Blockdev(BlockdevOpts), +} + +/// Subcommands for `bootc internals blockdev`. +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum BlockdevOpts { + /// List block device information (as JSON) for a given device path. + /// + /// This runs lsblk and backfills any missing partition metadata, + /// including falling back to `blkid -p` when the udev database + /// is unavailable. + Ls { + /// Block device path (e.g. /dev/vda) + device: Utf8PathBuf, + }, + /// List block device information (as JSON) for the device backing a filesystem. + /// + /// Takes a directory path, finds the underlying block device, and + /// outputs its full device tree with backfilled metadata. + LsFilesystem { + /// Filesystem path (e.g. /sysroot) + path: Utf8PathBuf, + }, } /// Options for the `set-options-for-source` subcommand. @@ -2175,6 +2200,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } } + InternalsOpts::Blockdev(opts) => { + let dev = match opts { + BlockdevOpts::Ls { device } => crate::blockdev::list_dev(&device)?, + BlockdevOpts::LsFilesystem { path } => { + let dir = Dir::open_ambient_dir(&path, cap_std::ambient_authority())?; + crate::blockdev::list_dev_by_dir(&dir)? + } + }; + serde_json::to_writer_pretty(std::io::stdout().lock(), &dev)?; + println!(); + Ok(()) + } }, Opt::State(opts) => match opts { StateOpts::WipeOstree => { diff --git a/tmt/tests/booted/readonly/016-test-blockdev-no-udev.nu b/tmt/tests/booted/readonly/016-test-blockdev-no-udev.nu new file mode 100644 index 000000000..e6876f288 --- /dev/null +++ b/tmt/tests/booted/readonly/016-test-blockdev-no-udev.nu @@ -0,0 +1,17 @@ +use std assert +use tap.nu + +tap begin "blockdev ls-filesystem works without udev" + +# Normal invocation should populate parttype via udev +let normal = (bootc internals blockdev ls-filesystem /sysroot | from json) +assert ($normal.parttype != null) "expected parttype set (with udev)" + +# Run with udev hidden via InaccessiblePaths to force the blkid -p fallback +let no_udev = (systemd-run -qPG -p InaccessiblePaths=/run/udev -- bootc internals blockdev ls-filesystem /sysroot | from json) +assert ($no_udev.parttype != null) "expected parttype set (without udev, via blkid fallback)" + +# The values should match +assert equal $no_udev.parttype $normal.parttype "parttype mismatch between udev and blkid fallback" + +tap ok