Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

import dataclasses
import itertools
import logging
import os
Expand Down Expand Up @@ -404,16 +405,52 @@ def _host_disks(host: Host, hosts_cli_disks: list[DiskDevName] | None) -> Iterab
# check all disks in --disks=host:... exist
for cli_disk in hosts_cli_disks:
for disk in host_disks:
if disk['name'] == cli_disk:
if disk.name == cli_disk:
yield disk
break # names are unique, don't expect another one
else:
raise Exception(f"no {cli_disk!r} disk on host {host.hostname_or_ip}, "
f"has {','.join(disk['name'] for disk in host_disks)}")
f"has {','.join(disk.name for disk in host_disks)}")

ret = {host: list(_host_disks(host, cli_disks.get(host.hostname_or_ip)))
for host in pools_hosts_by_name_or_ip.values()
}
# Cross-host deduplication: a LUN in use on any host (same WWN) is unavailable on all hosts.
# This matters for shared FC/iSCSI LUNs visible on multiple hosts simultaneously.
used_wwns = {
disk.wwn
for host_disks in ret.values()
for disk in host_disks
if disk.wwn and not disk.available
}
if used_wwns:
logging.debug("cross-host used WWNs: %s", used_wwns)
ret = {
host: [
dataclasses.replace(disk, available=False) if (disk.wwn and disk.wwn in used_wwns) else disk
for disk in host_disks
]
for host, host_disks in ret.items()
}
# LUNs reserved for lvmohba/lvmoiscsi: sort them to the end so they are
# only picked if no other disk is available.
reserved_wwns: set[str] = set()
try:
import data
for key in ('LVMOHBA_DEVICE_CONFIG', 'LVMOISCSI_DEVICE_CONFIG'):
cfg = getattr(data, key, None)
if isinstance(cfg, dict):
scsiid = cfg.get('SCSIid', '').lower().lstrip('0x')
if scsiid:
reserved_wwns.add(scsiid)
except ImportError:
pass
if reserved_wwns:
logging.debug("reserved WWNs (lvmohba/lvmoiscsi): %s", reserved_wwns)
ret = {
host: sorted(host_disks, key=lambda d: d.wwn in reserved_wwns)
for host, host_disks in ret.items()
}
logging.debug("disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})
return ret

Expand All @@ -422,7 +459,7 @@ def unused_512B_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
) -> dict[Host, list[Host.BlockDeviceInfo]]:
"""Dict identifying names of all 512-bytes-blocks disks for on all hosts of first pool."""
ret = {host: [disk for disk in host_disks
if disk["log-sec"] == "512" and host.disk_is_available(disk["name"])]
if disk.log_sec == 512 and disk.available]
for host, host_disks in disks.items()
}
logging.debug("available disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})
Expand All @@ -433,7 +470,7 @@ def unused_4k_disks(disks: dict[Host, list[Host.BlockDeviceInfo]]
) -> dict[Host, list[Host.BlockDeviceInfo]]:
"""Dict identifying names of all 4K-blocks disks for on all hosts of first pool."""
ret = {host: [disk for disk in host_disks
if disk["log-sec"] == "4096" and host.disk_is_available(disk["name"])]
if disk.log_sec == 4096 and disk.available]
for host, host_disks in disks.items()
}
logging.debug("available 4k disks collected: %s", {host.hostname_or_ip: value for host, value in ret.items()})
Expand Down
170 changes: 125 additions & 45 deletions lib/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import subprocess
import tempfile
import uuid
from dataclasses import dataclass

from packaging import version

import lib.commands as commands
from lib.common import (
DiskDevName,
_param_add,
_param_clear,
_param_get,
Expand All @@ -31,7 +31,7 @@
from lib.vm import VM
from lib.xo import xo_cli, xo_object_exists

from typing import TYPE_CHECKING, Literal, TypedDict, cast, overload
from typing import TYPE_CHECKING, Literal, cast, overload

if TYPE_CHECKING:
from lib.pool import Pool
Expand All @@ -55,14 +55,15 @@ class Host:
pool: "Pool"

# Data extraction is automatic, no conversion from str is done.
BlockDeviceInfo = TypedDict('BlockDeviceInfo', {"name": str,
"kname": str,
"pkname": str,
"size": str,
"log-sec": str,
"type": str,
})
BLOCK_DEVICES_FIELDS = ','.join(k.upper() for k in BlockDeviceInfo.__annotations__)
@dataclass
class BlockDeviceInfo:
name: str # short kernel name: "sda", "md0", "dm-3"
path: str # full device path: "/dev/sda", "/dev/md/myarray", "/dev/mapper/mpathb"
size: int # bytes
log_sec: int # logical sector size; 0 for md/mpath
type: str # "disk", "md", "mpath"
available: bool # not mounted, not member of md/lvm/mpath/zfs
wwn: str = '' # LUN WWN (hex, no 0x prefix); same LUN has same WWN across hosts

block_devices_info: list[BlockDeviceInfo]

Expand Down Expand Up @@ -643,48 +644,127 @@ def management_pif(self) -> PIF:

def rescan_block_devices_info(self) -> None:
"""
Initalize static informations about the disks.
Initialize information about block devices: local disks, mdadm arrays, and multipath devices.

Despite those being static, it can be necessary to rescan,
Despite those being mostly static, it can be necessary to rescan,
when we test how XCP-ng reacts to changes of hardware (or
reconfiguration of device blocksize), or after a reboot.
"""
output_string = self.ssh(
f'lsblk --pairs --bytes -I 8,259 --output {Host.BLOCK_DEVICES_FIELDS}'
) # limit to: sd, blkext

self.block_devices_info = [
Host.BlockDeviceInfo({key.lower(): value.strip('"') # type: ignore[misc]
for key, value in re.findall(r'(\S+)=(".*?"|\S+)', line)})
for line in output_string.strip().splitlines()
]
logging.debug("blockdevs found: %s", [disk["name"] for disk in self.block_devices_info])
# Majors: 8=SCSI/SATA, 65-71,128-135=SCSI extended, 259=NVMe/blkext
LOCAL_MAJORS = '8,65,66,67,68,69,70,71,128,129,130,131,132,133,134,135,259'
LSBLK_FIELDS = 'NAME,KNAME,PKNAME,SIZE,LOG-SEC,TYPE,WWN'

devices: list[Host.BlockDeviceInfo] = []

# --- Local block devices ---
raw = self.ssh(f'lsblk --pairs --bytes -I {LOCAL_MAJORS} --output {LSBLK_FIELDS}')
rows = [
{key.lower(): val.strip('"')
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
for line in raw.strip().splitlines()
] if raw.strip() else []

# build set of device names that are parents of something (have children)
pknames = {r['pkname'] for r in rows if r.get('pkname')}
# leaf disks: no parent, not a partition, and not themselves parents
for r in rows:
if r.get('pkname') or r.get('type') == 'part' or r['kname'] in pknames:
continue
disk_name = r['name']
dev = f'/dev/{disk_name}'
available = self._disk_is_available_local(disk_name)
raw_wwn = r.get('wwn', '').strip()
wwn = raw_wwn[2:] if raw_wwn.startswith('0x') else raw_wwn
devices.append(Host.BlockDeviceInfo(
name=disk_name,
path=dev,
size=int(r['size']),
log_sec=int(r['log-sec']),
type='disk',
available=available,
wwn=wwn,
))

# --- mdadm arrays ---
# lsblk -I 9 does not reliably enumerate md devices on all kernels/versions,
# so enumerate via /sys/block/md* and query each device individually.
md_names = self.ssh('ls /sys/block/ 2>/dev/null | grep -E "^md[0-9]" || true').strip().splitlines()
for md_name in md_names:
md_name = md_name.strip()
if not md_name:
continue
raw = self.ssh(
f'lsblk --pairs --bytes /dev/{md_name} --output NAME,SIZE,LOG-SEC,TYPE 2>/dev/null || true'
)
for line in raw.strip().splitlines():
r = {key.lower(): val.strip('"')
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
if r.get('name') != md_name:
continue
if r.get('type') not in ('raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'linear'):
break
mountpoint = self.ssh(
f'lsblk --noheadings -o MOUNTPOINT /dev/{md_name} 2>/dev/null || true'
).strip()
devices.append(Host.BlockDeviceInfo(
name=md_name,
path=f'/dev/{md_name}',
size=int(r['size']),
log_sec=int(r.get('log-sec', '0') or '0'),
type='md',
available=len(mountpoint) == 0,
))
break

def disks(self) -> list[Host.BlockDeviceInfo]:
""" List of BlockDeviceInfo for all disks. """
# store the names of the parent devices to filter out the devices with children
pknames = set(disk['pkname'] for disk in self.block_devices_info if disk['pkname'])
# filter out partitions from block_devices
return sorted(
(
disk
for disk in self.block_devices_info
if (not disk["pkname"] or disk['type'] == 'raid0') and disk['kname'] not in pknames
),
key=lambda disk: disk["name"],
# --- multipath devices ---
raw_mpath = self.ssh(
'lsblk --pairs --bytes -I 253 --output NAME,SIZE,LOG-SEC,TYPE,DM-NAME 2>/dev/null || true'
)
for line in raw_mpath.strip().splitlines():
r = {key.lower(): val.strip('"')
for key, val in re.findall(r'(\S+)=(".*?"|\S+)', line)}
if not r or r.get('type') != 'mpath':
continue
dm_name = r['name'] # e.g. "dm-3"
dm_alias = r.get('dm-name', '').strip() # e.g. "mpathb" or "3600507681381022548000000000001ec"
path = f'/dev/mapper/{dm_alias}' if dm_alias else f'/dev/{dm_name}'
mountpoint = self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{dm_name} 2>/dev/null || true').strip()
# DM alias is the LUN WWN when multipath names the device after it (all-hex, ≥16 chars)
wwn = dm_alias if re.fullmatch(r'[0-9a-f]{16,}', dm_alias) else ''
devices.append(Host.BlockDeviceInfo(
name=dm_name,
path=path,
size=int(r['size']),
log_sec=int(r.get('log-sec', '0') or '0'),
type='mpath',
available=len(mountpoint) == 0,
wwn=wwn,
))

self.block_devices_info = sorted(devices, key=lambda d: d.name)
logging.debug("blockdevs found: %s", [d.name for d in self.block_devices_info])

def _disk_is_available_local(self, disk: str) -> bool:
"""Check if a local block device is not in use (not mounted, not a member of md/lvm/mpath/zfs)."""
# 1. Check mountpoints
mountpoint = self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{disk}').strip()
if mountpoint:
return False
# 2. Check if md member
result = self.ssh_with_result(f'mdadm --examine /dev/{disk} 2>/dev/null')
if result.returncode == 0:
return False
# 3. Check if LVM member
result = self.ssh_with_result(f'pvs /dev/{disk} 2>/dev/null')
if result.returncode == 0:
return False
# 4. Check if ZFS pool member (zpool may not be installed)
zpool_status = self.ssh('zpool status 2>/dev/null || true').strip()
return not (zpool_status and f'/dev/{disk}' in zpool_status)

def disk_is_available(self, disk: DiskDevName) -> bool:
"""
Check if a disk is unmounted and appears available for use.

It may or may not contain identifiable filesystem or partition label.
If there are no mountpoints, it is assumed that the disk is not in use.

Warn: This function may misclassify LVM_member disks (e.g. in XOSTOR, RAID, ZFS) as "available".
Such disks may not have mountpoints but still be in use.
"""
return len(self.ssh(f'lsblk --noheadings -o MOUNTPOINT /dev/{disk}').strip()) == 0
def disks(self) -> list[Host.BlockDeviceInfo]:
""" List of all block devices (local disks, mdadm arrays, multipath devices). """
return list(self.block_devices_info)

def file_exists(self, filepath: str, regular_file: bool = True) -> bool:
option = '-f' if regular_file else '-e'
Expand Down
4 changes: 2 additions & 2 deletions pkgfixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def sr_disk_wiped(host: Host, unused_512B_disks: dict[Host, list[Host.BlockDevic
"""A disk on MASTER HOST OF FIRST POOL which we wipe."""
host_disks = unused_512B_disks[host]
assert host_disks, f"No 512B disk available on host {host}"
sr_disk = host_disks[0]["name"]
sr_disk = host_disks[0].name
logging.info(">> wipe disk %s" % sr_disk)
host.ssh(f'wipefs -a /dev/{sr_disk}')
yield sr_disk
Expand All @@ -38,7 +38,7 @@ def formatted_and_mounted_ext4_disk(host: Host, unused_512B_disks: dict[Host, li
mountpoint = '/var/tmp/sr_disk_mountpoint'
host_disks = unused_512B_disks[host]
assert host_disks, f"No 512B disk available on host {host}"
sr_disk = host_disks[0]["name"]
sr_disk = host_disks[0].name
setup_formatted_and_mounted_disk(host, sr_disk, 'ext4', mountpoint)
yield mountpoint
teardown_formatted_and_mounted_disk(host, mountpoint)
Expand Down
4 changes: 2 additions & 2 deletions tests/storage/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def xfs_sr_on_hostA2(
_xfs_config_on_hostA2: XfsConfig,
) -> Generator[SR, None, None]:
""" A XFS SR on first host. """
sr_disk = unused_512B_disks[hostA2_with_xfsprogs][0]["name"]
sr_disk = unused_512B_disks[hostA2_with_xfsprogs][0].name
sr = hostA2_with_xfsprogs.sr_create('xfs', "XFS-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format})
Expand Down Expand Up @@ -123,7 +123,7 @@ def xfs_sr_on_hostB1(
_xfs_config_on_hostB1: XfsConfig,
) -> Generator[SR, None, None]:
""" A XFS SR on first host. """
sr_disk = unused_512B_disks[hostB1_with_xfsprogs][0]["name"]
sr_disk = unused_512B_disks[hostB1_with_xfsprogs][0].name
sr = hostB1_with_xfsprogs.sr_create('xfs', "XFS-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format})
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/ext/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def ext_sr(host: Host,
image_format: ImageFormat
) -> Generator[SR, None, None]:
""" An EXT SR on first host. """
sr_disk = unused_512B_disks[host][0]["name"]
sr_disk = unused_512B_disks[host][0].name
sr = host.sr_create('ext', "EXT-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format})
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/ext/test_ext_sr.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_create_and_destroy_sr(self, host: Host,
image_format: ImageFormat
) -> None:
# Create and destroy tested in the same test to leave the host as unchanged as possible
sr_disk = unused_512B_disks[host][0]["name"]
sr_disk = unused_512B_disks[host][0].name
sr = host.sr_create('ext', "EXT-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format}, verify=True)
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/glusterfs/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def gluster_disk(
pool = pool_with_unused_512B_disk
mountpoint = '/mnt/sr_disk'
for h in pool.hosts:
sr_disk = unused_512B_disks[h][0]["name"]
sr_disk = unused_512B_disks[h][0].name
setup_formatted_and_mounted_disk(h, sr_disk, 'xfs', mountpoint)

yield
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/largeblock/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def largeblock_sr(host: Host,
unused_4k_disks: dict[Host, list[Host.BlockDeviceInfo]],
image_format: ImageFormat) -> Generator[SR, None, None]:
""" A LARGEBLOCK SR on first host. """
sr_disk = unused_4k_disks[host][0]["name"]
sr_disk = unused_4k_disks[host][0].name
sr = host.sr_create('largeblock', "LARGEBLOCK-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format})
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/largeblock/test_largeblock_sr.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_create_and_destroy_sr(self, host: Host,
unused_4k_disks: dict[Host, list[Host.BlockDeviceInfo]],
image_format: ImageFormat) -> None:
# Create and destroy tested in the same test to leave the host as unchanged as possible
sr_disk = unused_4k_disks[host][0]["name"]
sr_disk = unused_4k_disks[host][0].name
sr = host.sr_create('largeblock', "LARGEBLOCK-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format}, verify=True)
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/linstor/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def lvm_disks(

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

for host in hosts:
devices = host_devices(host)
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/lvm/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def lvm_sr(host: Host,
image_format: ImageFormat
) -> Generator[SR, None, None]:
""" An LVM SR on first host. """
sr_disk = unused_512B_disks[host][0]["name"]
sr_disk = unused_512B_disks[host][0].name
sr = host.sr_create('lvm', "LVM-local-SR-test",
{'device': '/dev/' + sr_disk,
'preferred-image-formats': image_format})
Expand Down
2 changes: 1 addition & 1 deletion tests/storage/lvm/test_lvm_sr.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def test_create_and_destroy_sr(self, host: Host,
unused_512B_disks: dict[Host, list[Host.BlockDeviceInfo]],
image_format: ImageFormat
) -> None:
sr_disk = unused_512B_disks[host][0]["name"]
sr_disk = unused_512B_disks[host][0].name
# Create and destroy tested in the same test to leave the host as unchanged as possible
sr = host.sr_create('lvm', "LVM-local-SR-test", {
'device': '/dev/' + sr_disk,
Expand Down
Loading
Loading