From 98aba06ca5fa71d7cc019806c97b224e7c3ae2dc Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Mon, 28 Feb 2022 13:12:21 +0100 Subject: [PATCH] tools/image-info: support inspecting LVM2 layouts When encountering an LVM2 layout, activate all its logical volumes so that they can be mounted. NB: we need to pass "norecovery" to the mount options because LVM does not setup the device mapper tables read-only even though the underlying loopback device is and then xfs will try to write to its journal and the kernel will panic. Attempts to reload the DM tables as readonly didn't work. NB: this will not work if we are trying to inspect an image that has a volume group name that is also present on the host. We could open the image file read-write and modify its vg name, but that would mean modifying the image file and thus we would need to copy it first. Pass `-c /dev/null` to `blkid` to force it not to use its cache. --- tools/image-info | 151 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 138 insertions(+), 13 deletions(-) diff --git a/tools/image-info b/tools/image-info index f106ceced..acc47ad3c 100755 --- a/tools/image-info +++ b/tools/image-info @@ -12,6 +12,7 @@ import json import os import platform import re +import stat import subprocess import sys import time @@ -19,6 +20,7 @@ import tempfile import xml.etree.ElementTree import yaml +from collections import OrderedDict from typing import Dict from osbuild import loop @@ -108,9 +110,11 @@ def mount_at(device, mountpoint, options=[], extra=[]): @contextlib.contextmanager -def mount(device): +def mount(device, options=None): + options = options or [] + opts = ",".join(["ro"] + options) with tempfile.TemporaryDirectory() as mountpoint: - subprocess.run(["mount", "-o", "ro", device, mountpoint], check=True) + subprocess.run(["mount", "-o", opts, device, mountpoint], check=True) try: yield mountpoint finally: @@ -188,7 +192,8 @@ def read_partition(device, partition): Returns: the 'partition' dictionary provided as an argument, extended with 'label', 'uuid' and 'fstype' keys and their values. """ - res = subprocess.run(["blkid", "--output", "export", device], + res = subprocess.run(["blkid", "-c", "/dev/null", "--output", "export", + device], check=False, encoding="utf-8", stdout=subprocess.PIPE) if res.returncode == 0: @@ -2377,6 +2382,111 @@ def append_filesystem(report, tree, *, is_ostree=False): print("EFI partition", file=sys.stderr) +def volume_group_for_device(device: str) -> str: + # Find the volume group that belongs to the device specified via `parent` + vg_name = None + count = 0 + + cmd = [ + "pvdisplay", "-C", "--noheadings", "-o", "vg_name", device + ] + + while True: + res = subprocess.run(cmd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="UTF-8") + + if res.returncode == 5: + if count == 10: + raise RuntimeError("Could not find parent device") + time.sleep(1*count) + count += 1 + continue + + if res.returncode != 0: + raise RuntimeError(res.stderr.strip()) + + vg_name = res.stdout.strip() + if vg_name: + break + + return vg_name + + +def ensure_device_file(path: str, major: int, minor: int): + """Ensure the device file with the given major, minor exists""" + os.makedirs(os.path.dirname(path), exist_ok=True) + if not os.path.exists(path): + os.mknod(path, 0o600 | stat.S_IFBLK, os.makedev(major, minor)) + + +@contextlib.contextmanager +def discover_lvm(dev) -> Dict: + # find the volume group name for the device file + vg_name = volume_group_for_device(dev) + + # activate it + r = subprocess.run(["vgchange", "-ay", vg_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False) + if r.returncode != 0: + raise RuntimeError(r.stderr.strip()) + + try: + # Find all logical volumes in the volume group + cmd = [ + "lvdisplay", "-C", "--noheadings", + "-o", "lv_name,path,lv_kernel_major,lv_kernel_minor", + "--separator", ";", + vg_name + ] + + res = subprocess.run(cmd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="UTF-8") + + if res.returncode != 0: + raise RuntimeError(res.stderr.strip()) + + data = res.stdout.strip() + parsed = list(map(lambda l: l.split(";"), data.split("\n"))) + volumes = OrderedDict() + + for vol in parsed: + vol = list(map(lambda v: v.strip(), vol)) + assert len(vol) == 4 + name, voldev, major, minor = vol + info = { + "device": voldev + } + ensure_device_file(voldev, int(major), int(minor)) + + read_partition(voldev, info) + volumes[name] = info + if name.startswith("root"): + volumes.move_to_end(name, last=False) + res = { + "lvm": True, + "lvm.vg": vg_name, + "lvm.volumes": volumes + } + + yield res + + finally: + r = subprocess.run(["vgchange", "-an", vg_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False) + if r.returncode != 0: + raise RuntimeError(r.stderr.strip()) + + def partition_is_lvm(part: Dict) -> bool: return part["type"].upper() in ["E6D6D379-F507-44C2-A23C-238F2A3DF928", "8E"] @@ -2384,7 +2494,6 @@ def partition_is_lvm(part: Dict) -> bool: def append_partitions(report, device, loctl): partitions = report["partitions"] - lvm = False with contextlib.ExitStack() as cm: # open each partition as a loop device filesystems = {} @@ -2393,17 +2502,32 @@ def append_partitions(report, device, loctl): dev = cm.enter_context(loop_open(loctl, device, offset=start, size=size)) read_partition(dev, part) if partition_is_lvm(part): - lvm = True + lvm = cm.enter_context(discover_lvm(dev)) + for vol in lvm["lvm.volumes"].values(): + if vol["fstype"]: + mntopts = [] + # we cannot recover since the underlying loopback device is mounted + # read-only but since we are using the it through the device mapper + # the fact might not be communicated and the kernel attempt a to + # a recovery of the filesystem, which will lead to a kernel panic + if vol["fstype"] in ("ext4", "ext3", "xfs"): + mntopts = ["norecovery"] + filesystems[vol["uuid"].upper()] = { + "device": vol["device"], + "mntops": mntopts + } + del vol["device"] + part.update(lvm) elif part["uuid"] and part["fstype"]: - filesystems[part["uuid"].upper()] = dev - - if lvm: - return + filesystems[part["uuid"].upper()] = { + "device": dev + } # find partition with fstab and read it fstab = [] - for dev in filesystems.values(): - with mount(dev) as tree: + for fs in filesystems.values(): + dev, opts = fs["device"], fs.get("mntops") + with mount(dev, opts) as tree: if os.path.exists(f"{tree}/etc/fstab"): fstab.extend(read_fstab(tree)) break @@ -2414,16 +2538,17 @@ def append_partitions(report, device, loctl): root_tree = "" for n, fstab_entry in enumerate(fstab): part_uuid = fstab_entry[0].split("=")[1].upper() - part_device = filesystems[part_uuid] + part_device = filesystems[part_uuid]["device"] part_mountpoint = fstab_entry[1] part_fstype = fstab_entry[2] part_options = fstab_entry[3].split(",") + part_options += filesystems[part_uuid].get("mntops", []) # the first mount point should be root if n == 0: if part_mountpoint != "/": raise RuntimeError("The first mountpoint in sorted fstab entries is not '/'") - root_tree = cm.enter_context(mount(part_device)) + root_tree = cm.enter_context(mount(part_device, part_options)) continue cm.enter_context(mount_at(part_device, f"{root_tree}{part_mountpoint}", options=part_options, extra=["-t", part_fstype]))