Skip to content

Commit c9a6ff5

Browse files
committed
Improve block device enumeration and availability detection
Replace the BlockDeviceInfo TypedDict with a typed dataclass that includes availability as a field, computed once at scan time from lsblk output (mountpoint and device type). Extend enumeration beyond local disks to also cover mdadm arrays and multipath devices, using a single lsblk call. Mark disk with unused partitions as available. Update all callers to use attribute access instead of dict subscript. Add a unit test suite and a CI workflow to run it. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent 78479a1 commit c9a6ff5

16 files changed

Lines changed: 636 additions & 63 deletions

File tree

.github/workflows/unit.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Run unit tests
2+
3+
on: [push]
4+
5+
permissions: {}
6+
7+
jobs:
8+
unit:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
12+
with:
13+
persist-credentials: false
14+
- uses: ./.github/actions/uv-setup/
15+
with:
16+
dev: false
17+
- name: Create a dummy data.py
18+
run: cp data.py-dist data.py
19+
- run: pytest tests/unit/*.py

conftest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,12 +425,12 @@ def _host_disks(host: Host, hosts_cli_disks: list[DiskDevName] | None) -> Iterab
425425
# check all disks in --disks=host:... exist
426426
for cli_disk in hosts_cli_disks:
427427
for disk in host_disks:
428-
if disk['name'] == cli_disk:
428+
if disk.name == cli_disk:
429429
yield disk
430430
break # names are unique, don't expect another one
431431
else:
432432
raise Exception(f"no {cli_disk!r} disk on host {host.hostname_or_ip}, "
433-
f"has {','.join(disk['name'] for disk in host_disks)}")
433+
f"has {','.join(disk.name for disk in host_disks)}")
434434

435435
ret = {host: list(_host_disks(host, cli_disks.get(host.hostname_or_ip)))
436436
for host in pools_hosts_by_name_or_ip.values()
@@ -443,7 +443,7 @@ def unused_512B_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
443443
) -> dict[Host, list[Host.BlockDeviceInfo]]:
444444
"""Dict identifying names of all 512-bytes-blocks disks for on all hosts of first pool."""
445445
ret = {host: [disk for disk in host_disks
446-
if disk["log-sec"] == "512" and host.disk_is_available(disk["name"])]
446+
if disk.log_sec == 512 and disk.available]
447447
for host, host_disks in disks.items()
448448
}
449449
logging.debug("available disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})
@@ -454,7 +454,7 @@ def unused_4k_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
454454
) -> dict[Host, list[Host.BlockDeviceInfo]]:
455455
"""Dict identifying names of all 4K-blocks disks for on all hosts of first pool."""
456456
ret = {host: [disk for disk in host_disks
457-
if disk["log-sec"] == "4096" and host.disk_is_available(disk["name"])]
457+
if disk.log_sec == 4096 and disk.available]
458458
for host, host_disks in disks.items()
459459
}
460460
logging.debug("available 4k disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})

lib/host.py

Lines changed: 123 additions & 44 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,15 @@ 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
64+
type: str # "disk", "md", "mpath"
65+
available: bool # not mounted, not member of md/lvm/mpath/zfs
66+
wwn: str = '' # LUN WWN (hex, no 0x prefix); same LUN has same WWN across hosts
6667

6768
block_devices_info: list[BlockDeviceInfo]
6869

@@ -669,48 +670,126 @@ def management_pif(self) -> PIF:
669670

670671
def rescan_block_devices_info(self) -> None:
671672
"""
672-
Initalize static informations about the disks.
673+
Initialize information about block devices: local disks, mdadm arrays, and multipath devices.
673674
674-
Despite those being static, it can be necessary to rescan,
675+
Despite those being mostly static, it can be necessary to rescan,
675676
when we test how XCP-ng reacts to changes of hardware (or
676677
reconfiguration of device blocksize), or after a reboot.
678+
679+
Handled device scenarios and their effect on `available`:
680+
681+
- Plain disk, no children: available unless the disk itself has a
682+
mountpoint.
683+
- Partitioned disk: available if no partition is mounted and no partition
684+
has a used child (lvm, md, mpath, crypt); unavailable otherwise.
685+
- Disk member of an mdadm array: the disk itself is unavailable; the md
686+
array is added as a separate entry (type 'md'), deduplicated across
687+
member appearances, and available if it has no mountpoint and no used
688+
children.
689+
- LUN with multipath configured: each path appears as a 'disk' entry and
690+
a shared 'mpath' entry as its child. The path disks are unavailable
691+
(mpath child); the mpath device is added once (type 'mpath'),
692+
deduplicated by kname across path appearances.
693+
- LUN accessible through multiple paths without multipath configured:
694+
multiple 'disk' entries with no children but sharing the same WWN.
695+
Deduplicated to a single entry (first path seen) based on WWN.
677696
"""
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])
697+
RAID_TYPES = {'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'linear'}
698+
USED_TYPES = RAID_TYPES | {'lvm', 'mpath', 'crypt'}
699+
LSBLK_FIELDS = 'NAME,KNAME,PKNAME,SIZE,LOG-SEC,TYPE,MOUNTPOINT,WWN'
688700

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"],
701-
)
701+
devices: list[Host.BlockDeviceInfo] = []
702702

703-
def disk_is_available(self, disk: DiskDevName) -> bool:
704-
"""
705-
Check if a disk is unmounted and appears available for use.
703+
raw = self.ssh(f'lsblk --pairs --bytes --output {LSBLK_FIELDS}')
706704

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.
705+
def _split_keys(line: str) -> list[tuple[str, str]]:
706+
return re.findall(r'(\S+)=(".*?"|\S+)', line)
709707

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
708+
rows = [
709+
{key.lower(): val.strip('"') for key, val in _split_keys(line)}
710+
for line in raw.strip().splitlines()
711+
]
712+
713+
# build children map: kname -> list of child knames
714+
children: dict[str, list[str]] = {}
715+
for r in rows:
716+
if r['pkname']:
717+
children.setdefault(r['pkname'], []).append(r['kname'])
718+
719+
# availability from lsblk fields: no mountpoint, not a "used" type, all descendants also free
720+
def _row_by_kname(kname: str) -> dict[str, str] | None:
721+
for r in rows:
722+
if r['kname'] == kname:
723+
return r
724+
return None
725+
726+
def _all_available(kname: str) -> bool:
727+
r = _row_by_kname(kname)
728+
if r is None:
729+
return False
730+
if r['mountpoint'] or r['type'] in USED_TYPES:
731+
return False
732+
return all(_all_available(c) for c in children.get(kname, []))
733+
734+
seen_knames: set[str] = set()
735+
seen_wwns: set[str] = set()
736+
737+
for r in rows:
738+
# --- local disks ---
739+
if r['type'] == 'disk' and not r['pkname']:
740+
wwn = r['wwn']
741+
if wwn:
742+
if wwn in seen_wwns:
743+
continue
744+
seen_wwns.add(wwn)
745+
devices.append(Host.BlockDeviceInfo(
746+
name=r['name'],
747+
path=f'/dev/{r["name"]}',
748+
size=int(r['size']),
749+
log_sec=int(r['log-sec']),
750+
type='disk',
751+
available=_all_available(r['kname']),
752+
wwn=wwn.removeprefix('0x'),
753+
))
754+
755+
# --- mdadm arrays (may appear once per member, deduplicate) ---
756+
elif r['type'] in RAID_TYPES:
757+
if r['kname'] in seen_knames:
758+
continue
759+
seen_knames.add(r['kname'])
760+
available = not r['mountpoint'] and all(_all_available(c) for c in children.get(r['kname'], []))
761+
devices.append(Host.BlockDeviceInfo(
762+
name=r['name'],
763+
path=f'/dev/{r["name"]}',
764+
size=int(r['size']),
765+
log_sec=int(r['log-sec']),
766+
type='md',
767+
available=available,
768+
))
769+
770+
# --- multipath devices (may appear once per path, deduplicate) ---
771+
elif r['type'] == 'mpath':
772+
if r['kname'] in seen_knames:
773+
continue
774+
seen_knames.add(r['kname'])
775+
available = not r['mountpoint'] and all(_all_available(c) for c in children.get(r['kname'], []))
776+
wwn = r['name'][1:17] if re.fullmatch(r'3[0-9a-f]{32}', r['name']) else ''
777+
devices.append(Host.BlockDeviceInfo(
778+
name=r['kname'],
779+
path=f'/dev/mapper/{r["name"]}',
780+
size=int(r['size']),
781+
log_sec=int(r['log-sec']),
782+
type='mpath',
783+
available=available,
784+
wwn=wwn,
785+
))
786+
787+
self.block_devices_info = sorted(devices, key=lambda d: d.size, reverse=True)
788+
logging.debug("blockdevs found: %s", [d.name for d in self.block_devices_info])
789+
790+
def disks(self) -> list[Host.BlockDeviceInfo]:
791+
""" List of all block devices (local disks, mdadm arrays, multipath devices). """
792+
return list(self.block_devices_info)
714793

715794
def file_exists(self, filepath: str, regular_file: bool = True) -> bool:
716795
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)

0 commit comments

Comments
 (0)