Skip to content

Commit 6acaa65

Browse files
committed
Improve block device enumeration and availability detection
Replace the BlockDeviceInfo TypedDict and the per-call disk_is_available() check with a dataclass that includes availability as a field, determined once at scan time using mountpoint, mdadm, LVM, and ZFS checks. Extend enumeration to cover mdadm arrays and multipath devices in addition to local disks. Update all callers to use attribute access instead of dict subscript. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent 36bdee3 commit 6acaa65

14 files changed

Lines changed: 137 additions & 64 deletions

File tree

conftest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,12 @@ def _host_disks(host: Host, hosts_cli_disks: list[DiskDevName] | None) -> Iterab
415415
# check all disks in --disks=host:... exist
416416
for cli_disk in hosts_cli_disks:
417417
for disk in host_disks:
418-
if disk['name'] == cli_disk:
418+
if disk.name == cli_disk:
419419
yield disk
420420
break # names are unique, don't expect another one
421421
else:
422422
raise Exception(f"no {cli_disk!r} disk on host {host.hostname_or_ip}, "
423-
f"has {','.join(disk['name'] for disk in host_disks)}")
423+
f"has {','.join(disk.name for disk in host_disks)}")
424424

425425
ret = {host: list(_host_disks(host, cli_disks.get(host.hostname_or_ip)))
426426
for host in pools_hosts_by_name_or_ip.values()
@@ -433,7 +433,7 @@ def unused_512B_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
433433
) -> dict[Host, list[Host.BlockDeviceInfo]]:
434434
"""Dict identifying names of all 512-bytes-blocks disks for on all hosts of first pool."""
435435
ret = {host: [disk for disk in host_disks
436-
if disk["log-sec"] == "512" and host.disk_is_available(disk["name"])]
436+
if disk.log_sec == 512 and disk.available]
437437
for host, host_disks in disks.items()
438438
}
439439
logging.debug("available disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})
@@ -444,7 +444,7 @@ def unused_4k_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
444444
) -> dict[Host, list[Host.BlockDeviceInfo]]:
445445
"""Dict identifying names of all 4K-blocks disks for on all hosts of first pool."""
446446
ret = {host: [disk for disk in host_disks
447-
if disk["log-sec"] == "4096" and host.disk_is_available(disk["name"])]
447+
if disk.log_sec == 4096 and disk.available]
448448
for host, host_disks in disks.items()
449449
}
450450
logging.debug("available 4k disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})

lib/host.py

Lines changed: 118 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
import subprocess
88
import tempfile
99
import uuid
10+
from dataclasses import dataclass
1011

1112
from packaging import version
1213

1314
import lib.commands as commands
1415
from lib.common import (
15-
DiskDevName,
1616
_param_add,
1717
_param_clear,
1818
_param_get,
@@ -31,7 +31,7 @@
3131
from lib.vm import VM
3232
from lib.xo import xo_cli, xo_object_exists
3333

34-
from typing import TYPE_CHECKING, Literal, TypedDict, overload
34+
from typing import TYPE_CHECKING, Literal, overload
3535

3636
if TYPE_CHECKING:
3737
from lib.pool import Pool
@@ -55,14 +55,14 @@ class Host:
5555
pool: "Pool"
5656

5757
# Data extraction is automatic, no conversion from str is done.
58-
BlockDeviceInfo = TypedDict('BlockDeviceInfo', {"name": str,
59-
"kname": str,
60-
"pkname": str,
61-
"size": str,
62-
"log-sec": str,
63-
"type": str,
64-
})
65-
BLOCK_DEVICES_FIELDS = ','.join(k.upper() for k in BlockDeviceInfo.__annotations__)
58+
@dataclass
59+
class BlockDeviceInfo:
60+
name: str # short kernel name: "sda", "md0", "dm-3"
61+
path: str # full device path: "/dev/sda", "/dev/md/myarray", "/dev/mapper/mpathb"
62+
size: int # bytes
63+
log_sec: int # logical sector size; 0 for md/mpath
64+
type: str # "disk", "md", "mpath"
65+
available: bool # not mounted, not member of md/lvm/mpath/zfs
6666

6767
block_devices_info: list[BlockDeviceInfo]
6868

@@ -669,48 +669,121 @@ def management_pif(self) -> PIF:
669669

670670
def rescan_block_devices_info(self) -> None:
671671
"""
672-
Initalize static informations about the disks.
672+
Initialize information about block devices: local disks, mdadm arrays, and multipath devices.
673673
674-
Despite those being static, it can be necessary to rescan,
674+
Despite those being mostly static, it can be necessary to rescan,
675675
when we test how XCP-ng reacts to changes of hardware (or
676676
reconfiguration of device blocksize), or after a reboot.
677677
"""
678-
output_string = self.ssh(
679-
f'lsblk --pairs --bytes -I 8,259 --output {Host.BLOCK_DEVICES_FIELDS}'
680-
) # limit to: sd, blkext
681-
682-
self.block_devices_info = [
683-
Host.BlockDeviceInfo({key.lower(): value.strip('"') # type: ignore[misc]
684-
for key, value in re.findall(r'(\S+)=(".*?"|\S+)', line)})
685-
for line in output_string.strip().splitlines()
686-
]
687-
logging.debug("blockdevs found: %s", [disk["name"] for disk in self.block_devices_info])
678+
# Majors: 8=SCSI/SATA, 65-71,128-135=SCSI extended, 259=NVMe/blkext
679+
LOCAL_MAJORS = '8,65,66,67,68,69,70,71,128,129,130,131,132,133,134,135,259'
680+
LSBLK_FIELDS = 'NAME,KNAME,PKNAME,SIZE,LOG-SEC,TYPE'
681+
682+
devices: list[Host.BlockDeviceInfo] = []
683+
684+
# --- Local block devices ---
685+
raw = self.ssh(f'lsblk --pairs --bytes -I {LOCAL_MAJORS} --output {LSBLK_FIELDS}')
686+
rows = [
687+
{key.lower(): val.strip('"')
688+
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
689+
for line in raw.strip().splitlines()
690+
] if raw.strip() else []
691+
692+
# build set of device names that are parents of something (have children)
693+
pknames = {r['pkname'] for r in rows if r.get('pkname')}
694+
# leaf disks: no parent, not a partition, and not themselves parents
695+
for r in rows:
696+
if r.get('pkname') or r.get('type') == 'part' or r['kname'] in pknames:
697+
continue
698+
disk_name = r['name']
699+
dev = f'/dev/{disk_name}'
700+
available = self._disk_is_available_local(disk_name)
701+
devices.append(Host.BlockDeviceInfo(
702+
name=disk_name,
703+
path=dev,
704+
size=int(r['size']),
705+
log_sec=int(r['log-sec']),
706+
type='disk',
707+
available=available,
708+
))
709+
710+
# --- mdadm arrays ---
711+
# lsblk -I 9 does not reliably enumerate md devices on all kernels/versions,
712+
# so enumerate via /sys/block/md* and query each device individually.
713+
md_names = self.ssh('ls /sys/block/ 2>/dev/null | grep -E "^md[0-9]" || true').strip().splitlines()
714+
for md_name in md_names:
715+
md_name = md_name.strip()
716+
if not md_name:
717+
continue
718+
raw = self.ssh(
719+
f'lsblk --pairs --bytes /dev/{md_name} --output NAME,SIZE,LOG-SEC,TYPE 2>/dev/null || true'
720+
)
721+
for line in raw.strip().splitlines():
722+
r = {key.lower(): val.strip('"')
723+
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
724+
if r.get('name') != md_name:
725+
continue
726+
if r.get('type') not in ('raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'linear'):
727+
break
728+
mountpoint = self.ssh(
729+
f'lsblk --noheadings -o MOUNTPOINT /dev/{md_name} 2>/dev/null || true'
730+
).strip()
731+
devices.append(Host.BlockDeviceInfo(
732+
name=md_name,
733+
path=f'/dev/{md_name}',
734+
size=int(r['size']),
735+
log_sec=int(r.get('log-sec', '0') or '0'),
736+
type='md',
737+
available=len(mountpoint) == 0,
738+
))
739+
break
688740

689-
def disks(self) -> list[Host.BlockDeviceInfo]:
690-
""" List of BlockDeviceInfo for all disks. """
691-
# store the names of the parent devices to filter out the devices with children
692-
pknames = set(disk['pkname'] for disk in self.block_devices_info if disk['pkname'])
693-
# filter out partitions from block_devices
694-
return sorted(
695-
(
696-
disk
697-
for disk in self.block_devices_info
698-
if (not disk["pkname"] or disk['type'] == 'raid0') and disk['kname'] not in pknames
699-
),
700-
key=lambda disk: disk["name"],
741+
# --- multipath devices ---
742+
raw_mpath = self.ssh(
743+
'lsblk --pairs --bytes -I 253 --output NAME,SIZE,LOG-SEC,TYPE,DM-NAME 2>/dev/null || true'
701744
)
745+
for line in raw_mpath.strip().splitlines():
746+
r = {key.lower(): val.strip('"')
747+
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
748+
if not r or r.get('type') != 'mpath':
749+
continue
750+
dm_name = r['name'] # e.g. "dm-3"
751+
dm_alias = r.get('dm-name', '').strip() # e.g. "mpathb"
752+
path = f'/dev/mapper/{dm_alias}' if dm_alias else f'/dev/{dm_name}'
753+
mountpoint = self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{dm_name} 2>/dev/null || true').strip()
754+
devices.append(Host.BlockDeviceInfo(
755+
name=dm_name,
756+
path=path,
757+
size=int(r['size']),
758+
log_sec=int(r.get('log-sec', '0') or '0'),
759+
type='mpath',
760+
available=len(mountpoint) == 0,
761+
))
762+
763+
self.block_devices_info = sorted(devices, key=lambda d: d.name)
764+
logging.debug("blockdevs found: %s", [d.name for d in self.block_devices_info])
765+
766+
def _disk_is_available_local(self, disk: str) -> bool:
767+
"""Check if a local block device is not in use (not mounted, not a member of md/lvm/mpath/zfs)."""
768+
# 1. Check mountpoints
769+
mountpoint = self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{disk}').strip()
770+
if mountpoint:
771+
return False
772+
# 2. Check if md member
773+
result = self.ssh_with_result(f'mdadm --examine /dev/{disk} 2>/dev/null')
774+
if result.returncode == 0:
775+
return False
776+
# 3. Check if LVM member
777+
result = self.ssh_with_result(f'pvs /dev/{disk} 2>/dev/null')
778+
if result.returncode == 0:
779+
return False
780+
# 4. Check if ZFS pool member (zpool may not be installed)
781+
zpool_status = self.ssh('zpool status 2>/dev/null || true').strip()
782+
return not (zpool_status and f'/dev/{disk}' in zpool_status)
702783

703-
def disk_is_available(self, disk: DiskDevName) -> bool:
704-
"""
705-
Check if a disk is unmounted and appears available for use.
706-
707-
It may or may not contain identifiable filesystem or partition label.
708-
If there are no mountpoints, it is assumed that the disk is not in use.
709-
710-
Warn: This function may misclassify LVM_member disks (e.g. in XOSTOR, RAID, ZFS) as "available".
711-
Such disks may not have mountpoints but still be in use.
712-
"""
713-
return len(self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{disk}').strip()) == 0
784+
def disks(self) -> list[Host.BlockDeviceInfo]:
785+
""" List of all block devices (local disks, mdadm arrays, multipath devices). """
786+
return list(self.block_devices_info)
714787

715788
def file_exists(self, filepath: str, regular_file: bool = True) -> bool:
716789
option = '-f' if regular_file else '-e'

pkgfixtures.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def sr_disk_wiped(host: Host, unused_512B_disks: dict[Host, list[Host.BlockDevic
2525
"""A disk on MASTER HOST OF FIRST POOL which we wipe."""
2626
host_disks = unused_512B_disks[host]
2727
assert host_disks, f"No 512B disk available on host {host}"
28-
sr_disk = host_disks[0]["name"]
28+
sr_disk = host_disks[0].name
2929
logging.info(">> wipe disk %s" % sr_disk)
3030
host.ssh(f'wipefs -a /dev/{sr_disk}')
3131
yield sr_disk
@@ -38,7 +38,7 @@ def formatted_and_mounted_ext4_disk(host: Host, unused_512B_disks: dict[Host, li
3838
mountpoint = '/var/tmp/sr_disk_mountpoint'
3939
host_disks = unused_512B_disks[host]
4040
assert host_disks, f"No 512B disk available on host {host}"
41-
sr_disk = host_disks[0]["name"]
41+
sr_disk = host_disks[0].name
4242
setup_formatted_and_mounted_disk(host, sr_disk, 'ext4', mountpoint)
4343
yield mountpoint
4444
teardown_formatted_and_mounted_disk(host, mountpoint)

tests/storage/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def xfs_sr_on_hostA2(
8383
_xfs_config_on_hostA2: XfsConfig,
8484
) -> Generator[SR, None, None]:
8585
""" A XFS SR on first host. """
86-
sr_disk = unused_512B_disks[hostA2_with_xfsprogs][0]["name"]
86+
sr_disk = unused_512B_disks[hostA2_with_xfsprogs][0].name
8787
sr = hostA2_with_xfsprogs.sr_create('xfs', "XFS-local-SR-test",
8888
{'device': '/dev/' + sr_disk,
8989
'preferred-image-formats': image_format})
@@ -123,7 +123,7 @@ def xfs_sr_on_hostB1(
123123
_xfs_config_on_hostB1: XfsConfig,
124124
) -> Generator[SR, None, None]:
125125
""" A XFS SR on first host. """
126-
sr_disk = unused_512B_disks[hostB1_with_xfsprogs][0]["name"]
126+
sr_disk = unused_512B_disks[hostB1_with_xfsprogs][0].name
127127
sr = hostB1_with_xfsprogs.sr_create('xfs', "XFS-local-SR-test",
128128
{'device': '/dev/' + sr_disk,
129129
'preferred-image-formats': image_format})

tests/storage/ext/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def ext_sr(host: Host,
1818
image_format: ImageFormat
1919
) -> Generator[SR, None, None]:
2020
""" An EXT SR on first host. """
21-
sr_disk = unused_512B_disks[host][0]["name"]
21+
sr_disk = unused_512B_disks[host][0].name
2222
sr = host.sr_create('ext', "EXT-local-SR-test",
2323
{'device': '/dev/' + sr_disk,
2424
'preferred-image-formats': image_format})

tests/storage/ext/test_ext_sr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_create_and_destroy_sr(self, host: Host,
4242
image_format: ImageFormat
4343
) -> None:
4444
# Create and destroy tested in the same test to leave the host as unchanged as possible
45-
sr_disk = unused_512B_disks[host][0]["name"]
45+
sr_disk = unused_512B_disks[host][0].name
4646
sr = host.sr_create('ext', "EXT-local-SR-test",
4747
{'device': '/dev/' + sr_disk,
4848
'preferred-image-formats': image_format}, verify=True)

tests/storage/glusterfs/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def gluster_disk(
122122
pool = pool_with_unused_512B_disk
123123
mountpoint = '/mnt/sr_disk'
124124
for h in pool.hosts:
125-
sr_disk = unused_512B_disks[h][0]["name"]
125+
sr_disk = unused_512B_disks[h][0].name
126126
setup_formatted_and_mounted_disk(h, sr_disk, 'xfs', mountpoint)
127127

128128
yield

tests/storage/largeblock/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def largeblock_sr(host: Host,
2020
unused_4k_disks: dict[Host, list[Host.BlockDeviceInfo]],
2121
image_format: ImageFormat) -> Generator[SR, None, None]:
2222
""" A LARGEBLOCK SR on first host. """
23-
sr_disk = unused_4k_disks[host][0]["name"]
23+
sr_disk = unused_4k_disks[host][0].name
2424
sr = host.sr_create('largeblock', "LARGEBLOCK-local-SR-test",
2525
{'device': '/dev/' + sr_disk,
2626
'preferred-image-formats': image_format})

tests/storage/largeblock/test_largeblock_sr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_create_and_destroy_sr(self, host: Host,
3131
unused_4k_disks: dict[Host, list[Host.BlockDeviceInfo]],
3232
image_format: ImageFormat) -> None:
3333
# Create and destroy tested in the same test to leave the host as unchanged as possible
34-
sr_disk = unused_4k_disks[host][0]["name"]
34+
sr_disk = unused_4k_disks[host][0].name
3535
sr = host.sr_create('largeblock', "LARGEBLOCK-local-SR-test",
3636
{'device': '/dev/' + sr_disk,
3737
'preferred-image-formats': image_format}, verify=True)

tests/storage/linstor/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def lvm_disks(
6060

6161
@functools.cache
6262
def host_devices(host: Host) -> list[str]:
63-
return [os.path.join("/dev", disk["name"]) for disk in unused_512B_disks[host][0:1]]
63+
return [disk.path for disk in unused_512B_disks[host][0:1]]
6464

6565
for host in hosts:
6666
devices = host_devices(host)

0 commit comments

Comments
 (0)