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