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.
This commit is contained in:
Christian Kellner 2022-02-28 13:12:21 +01:00
parent 932a8a0333
commit 98aba06ca5

View file

@ -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]))