Skip to content

Commit 8185065

Browse files
committed
vng, run: support specifying / autodetecting disk emulation options
This implements support for various disk topology and I/O driver options for both `--disk` and `--blk-disk` arguments. Options are accepted as a comma-separated list after the image file path e.g., `--disk /path/to/file,format=qcow2,...`. In addition to general QEMU options (format=), I/O driver options (cache=, aio=, discard=, detect-zeroes=, queues=) and topology options (log-sec=, phy-sec=, min-io=, opt-io=, disc-gran=) a "topology=" metaoption is accepted to pass through host device queue limits. The names for these options were chosen to match `lsblk` columns rather than QEMU's own -drive/-device options, because the latter are underdocumented and non-uniform. Signed-off-by: Ivan Shapovalov <intelfx@intelfx.name>
1 parent df8fb2b commit 8185065

File tree

3 files changed

+242
-8
lines changed

3 files changed

+242
-8
lines changed

virtme/commands/run.py

Lines changed: 221 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pathlib import Path
2525
from shutil import which
2626
from time import sleep
27-
from typing import Any, NoReturn
27+
from typing import Any, Callable, NoReturn
2828

2929
from virtme_ng.utils import (
3030
CACHE_DIR,
@@ -36,6 +36,7 @@
3636
VIRTME_SSH_HOSTNAME_CID_SEPARATORS,
3737
get_conf,
3838
scsi_device_id,
39+
strtobool,
3940
)
4041

4142
from .. import architectures, mkinitramfs, modfinder, qemu_helpers, resources, virtmods
@@ -885,21 +886,131 @@ def quote_karg(arg: str) -> str:
885886
class DiskArg:
886887
name: str
887888
path: str
889+
opts: dict[str, str]
890+
891+
_OPTS_HELP = {
892+
# meta parameters
893+
"topology": ("bool", "Forward host device topology (sector and I/O sizes)"),
894+
"iothread": ("bool", "Create a dedicated I/O thread for the disk"),
895+
# general format parameters
896+
"format": ("str", "Disk image format (raw|qcow2)"),
897+
# I/O driver parameters
898+
"cache": ("str", "Cache mode (none|writeback|writethrough|directsync|unsafe)"),
899+
"aio": ("str", "Asynchronous I/O mode (native|threads|io_uring)"),
900+
"discard": ("bool", "Pass through TRIM/UNMAP requests (true=unmap, false=ignore)"),
901+
"detect-zeroes": ("bool", "Detect all-zero writes (true=on/unmap, false=off)"),
902+
"queues": ("int", "Number of I/O queues"),
903+
# topology parameters
904+
# "alignment": ("bytes", "Block alignment offset"),
905+
"log-sec": ("bytes", "Logical (LBA) sector size (typically 512 or 4096)"),
906+
"phy-sec": ("bytes", "Physical (underlying) sector size (>=log-sec)"),
907+
"min-io": ("bytes", "Minimum I/O request size"),
908+
"opt-io": ("bytes", "Optimal I/O request size"),
909+
"rota": ("bool", "Device is rotational"),
910+
# "wzeroes": ("bytes", "Maximum WRITE ZEROES request size"),
911+
# "disc-aln": ("bytes", "TRIM/UNMAP alignment offset"),
912+
"disc-gran": ("bytes", "TRIM/UNMAP request granularity"),
913+
# "disc-max": ("bytes", "Maximum TRIM/UNMAP request size"),
914+
# "disc-zero": ("bool", "TRIM/UNMAP zeroes data"),
915+
}
916+
917+
def __post_init__(self):
918+
if self.pop_opt("topology", strtobool, False):
919+
self.opts = self.topology() | self.opts
920+
921+
def get_opt(self, name: str, parser: Callable[[str], Any] = str, default: Any = None) -> Any:
922+
opt = self.opts.get(name, None)
923+
return parser(opt) if opt is not None else default
924+
925+
def pop_opt(self, name: str, parser: Callable[[str], Any] = str, default: Any = None) -> Any:
926+
opt = self.opts.pop(name, None)
927+
return parser(opt) if opt is not None else default
928+
929+
def pop_opt_qemu(self, name: str, default: Any = None, *, parser: Callable[[str], Any] = str, dest: str | None = None) -> str | None:
930+
opt = self.pop_opt(name, parser, default)
931+
# return DiskArg.qemu_opt(name=qemu if qemu is not None else name, value=opt)
932+
if opt is None:
933+
return None
934+
if isinstance(opt, bool):
935+
opt = "on" if opt else "off"
936+
return f"{dest if dest is not None else name}={opt}"
937+
938+
def topology(self) -> dict[str, str]:
939+
# Get the real device name (handles symlinks like /dev/mapper -> /dev/dm-X)
940+
real_path = os.path.realpath(self.path, strict=True)
941+
dev_name = os.path.basename(real_path)
942+
sys_base = Path(f'/sys/block/{dev_name}')
943+
944+
attributes = {
945+
# 'alignment': ('alignment_offset', int),
946+
'log-sec': ('queue/logical_block_size', int),
947+
'phy-sec': ('queue/physical_block_size', int),
948+
'min-io': ('queue/minimum_io_size', int),
949+
'opt-io': ('queue/optimal_io_size', int),
950+
'rota': ('queue/rotational', bool),
951+
# 'wzeroes': ('queue/write_zeroes_max_bytes', int),
952+
953+
# 'disc-aln': ('discard_alignment', int),
954+
'disc-gran': ('queue/discard_granularity', int),
955+
# 'disc-max': ('queue/discard_max_bytes', int),
956+
# 'disc-zero': ('queue/discard_zeroes_data', bool),
957+
}
958+
959+
result = {}
960+
for key, (path, parser) in attributes.items():
961+
try:
962+
value = sys_base.joinpath(path).read_text().strip()
963+
if parser is int:
964+
parsed = parser(value)
965+
if parsed <= 0:
966+
continue
967+
result[key] = value
968+
except FileNotFoundError:
969+
pass
970+
except ValueError:
971+
pass
972+
return result
888973

889974
# Validate name=path arguments from --disk and --blk-disk
890975
@classmethod
891976
def parse(cls, func: str, arg: str) -> 'DiskArg':
892-
name, sep, fn = arg.partition("=")
977+
items = arg.split(",")
978+
979+
namefile = items[0]
980+
extra = items[1:]
981+
982+
name, sep, fn = namefile.partition("=")
893983
if not (name and sep and fn):
894984
arg_fail(f"invalid argument to {func}: {arg}")
895985
if "=" in fn or "," in fn:
896986
arg_fail(f"{func} filenames cannot contain '=' or ',': {fn}")
897987
if "=" in name or "," in name:
898988
arg_fail(f"{func} device names cannot contain '=' or ',': {name}")
899989

990+
opts = dict()
991+
for i in extra:
992+
key, sep, value = i.partition("=")
993+
if not key:
994+
arg_fail(f"invalid argument to {func}: {arg}")
995+
if sep:
996+
opts[key] = value
997+
else:
998+
opts[key] = "1"
999+
1000+
if "help" in opts:
1001+
print("\n".join([
1002+
f"Possible {func} options:",
1003+
] + [
1004+
"{:<20} {}".format(f"{key}=({typ})", value)
1005+
for key, (typ, value) in
1006+
DiskArg._OPTS_HELP.items()
1007+
]))
1008+
sys.exit(0)
1009+
9001010
return cls(
9011011
name=name,
9021012
path=fn,
1013+
opts=opts,
9031014
)
9041015

9051016

@@ -1576,6 +1687,8 @@ def do_it() -> int:
15761687
if args.cpus:
15771688
qemuargs.extend(["-smp", args.cpus])
15781689

1690+
iothread_index = 0
1691+
15791692
if args.blk_disk:
15801693
for i, d in enumerate(args.blk_disk):
15811694
driveid = f"blk-disk{i}"
@@ -1585,55 +1698,157 @@ def do_it() -> int:
15851698
"if=none",
15861699
f"id={driveid}",
15871700
f"file={disk.path}",
1588-
"format=raw",
15891701
]
15901702
device_opts = [
15911703
arch.virtio_dev_type("blk"),
15921704
f"drive={driveid}",
15931705
f"serial={disk.name}",
15941706
]
15951707

1708+
# we need those parameters multiple times
1709+
discard = disk.pop_opt("discard", parser=strtobool, default=None)
1710+
detect_zeroes = disk.pop_opt("detect-zeroes", parser=strtobool, default=None)
1711+
# we need this parameter both to transform other parameters and as itself later
1712+
# log_sec = disk.get_opt("log-sec", parser=int, default=512)
1713+
1714+
drive_opts.extend([
1715+
disk.pop_opt_qemu("format", "raw"),
1716+
disk.pop_opt_qemu("cache", None),
1717+
disk.pop_opt_qemu("aio", None),
1718+
f"discard={"unmap" if discard else "ignore"}"
1719+
if discard is not None else None,
1720+
f"detect-zeroes={("unmap" if discard else "on") if detect_zeroes else "off"}"
1721+
if detect_zeroes is not None else None,
1722+
])
1723+
1724+
device_opts.extend([
1725+
f"discard={"on" if discard else "off"}"
1726+
if discard is not None else None,
1727+
disk.pop_opt_qemu("disc-gran", dest="discard_granularity"),
1728+
disk.pop_opt_qemu("log-sec", dest="logical_block_size"),
1729+
disk.pop_opt_qemu("phy-sec", dest="physical_block_size"),
1730+
# disk.pop_qemu("disc-max", dest="max-discard-sectors", parser=lambda arg: int(arg) / log_sec),
1731+
# disk.pop_qemu("wzeroes", dest="max-write-zeroes-sectors", parser=lambda arg: int(arg) / log_sec),
1732+
disk.pop_opt_qemu("min-io", dest="min_io_size"),
1733+
disk.pop_opt_qemu("opt-io", dest="opt_io_size"),
1734+
disk.pop_opt_qemu("queues", dest="num-queues"),
1735+
])
1736+
# unused
1737+
disk.opts.pop("rota", None)
1738+
1739+
if disk.pop_opt("iothread", bool, False):
1740+
iothreadid = f"iothread{iothread_index}"
1741+
iothread_index += 1
1742+
qemuargs.extend([
1743+
"-object",
1744+
f"iothread,id={iothreadid}",
1745+
])
1746+
device_opts.append(
1747+
f"iothread={iothreadid}"
1748+
)
1749+
15961750
qemuargs.extend([
15971751
"-drive",
15981752
",".join(o for o in drive_opts if o is not None),
15991753
"-device",
16001754
",".join(o for o in device_opts if o is not None),
16011755
])
16021756

1603-
if args.disk:
1604-
qemuargs.extend(["-device", "{},id=scsi".format(arch.virtio_dev_type("scsi"))])
1757+
# any options that were not consumed are errors
1758+
if disk.opts:
1759+
raise ValueError(f"invalid --disk parameter: {d!r}\n(keys were not consumed: {disk.opts.keys()})")
16051760

1761+
if args.disk:
16061762
for i, d in enumerate(args.disk):
1763+
scsiid = f"scsi{i}"
16071764
driveid = f"disk{i}"
16081765
disk = DiskArg.parse("--disk", d)
16091766

16101767
# scsi-hd.device_id= is normally defaulted to scsi-hd.serial=,
16111768
# but it must not be longer than 20 characters
16121769
device_id = scsi_device_id(disk.name, 20)
16131770

1771+
scsi_opts = [
1772+
arch.virtio_dev_type("scsi"),
1773+
f"id={scsiid}",
1774+
]
16141775
drive_opts = [
16151776
"if=none",
16161777
f"id={driveid}",
16171778
f"file={disk.path}",
1618-
"format=raw",
16191779
]
16201780
device_opts = [
16211781
"scsi-hd",
16221782
f"drive={driveid}",
1783+
f"bus={scsiid}.0",
16231784
"vendor=virtme",
16241785
"product=disk",
16251786
f"serial={disk.name}",
16261787
f"device_id={device_id}"
16271788
if device_id != disk.name else None,
16281789
]
16291790

1791+
# we need those parameters multiple times
1792+
discard = disk.pop_opt("discard", parser=strtobool, default=None)
1793+
detect_zeroes = disk.pop_opt("detect-zeroes", parser=strtobool, default=None)
1794+
# we need this parameter both to transform other parameters and as itself later
1795+
log_sec = disk.get_opt("log-sec")
1796+
1797+
drive_opts.extend([
1798+
disk.pop_opt_qemu("format", "raw"),
1799+
disk.pop_opt_qemu("cache", None),
1800+
disk.pop_opt_qemu("aio", None),
1801+
f"discard={"unmap" if discard else "ignore"}"
1802+
if discard is not None else None,
1803+
f"detect-zeroes={("unmap" if discard else "on") if detect_zeroes else "off"}"
1804+
if detect_zeroes is not None else None,
1805+
])
1806+
1807+
scsi_opts.extend([
1808+
disk.pop_opt_qemu("queues", dest="num-queues"),
1809+
])
1810+
1811+
device_opts.extend([
1812+
disk.pop_opt_qemu("disc-gran", dest="discard_granularity"),
1813+
disk.pop_opt_qemu("log-sec", dest="logical_block_size"),
1814+
# convenience: QEMU does not automatically adjust physical_block_size
1815+
# to be not less than logical_block_size (it errors out instead), so we do it here
1816+
disk.pop_opt_qemu("phy-sec", dest="physical_block_size", default=log_sec),
1817+
# disk.pop_qemu("disc-max", dest="max_unmap_size"),
1818+
# disk.pop_qemu("wzeroes", dest="???"),
1819+
disk.pop_opt_qemu("min-io", dest="min_io_size"),
1820+
disk.pop_opt_qemu("opt-io", dest="opt_io_size"),
1821+
# sic: set rotation_rate to "1" for non-rotating disks ("1" is a special value
1822+
# that means "non-rotating medium"), but set to "0" for rotating disks
1823+
# ("0" means "rotation rate not reported").
1824+
disk.pop_opt_qemu("rota", dest="rotation_rate",
1825+
parser=lambda arg: "0" if strtobool(arg) else "1"),
1826+
])
1827+
1828+
if disk.pop_opt("iothread", bool, False):
1829+
iothreadid = f"iothread{iothread_index}"
1830+
iothread_index += 1
1831+
qemuargs.extend([
1832+
"-object",
1833+
f"iothread,id={iothreadid}",
1834+
])
1835+
scsi_opts.append(
1836+
f"iothread={iothreadid}"
1837+
)
1838+
16301839
qemuargs.extend([
16311840
"-drive",
16321841
",".join(o for o in drive_opts if o is not None),
16331842
"-device",
1843+
",".join(o for o in scsi_opts if o is not None),
1844+
"-device",
16341845
",".join(o for o in device_opts if o is not None),
16351846
])
16361847

1848+
# any options that were not consumed are errors
1849+
if disk.opts:
1850+
raise ValueError(f"invalid --disk parameter: {d!r}\n(keys were not consumed: {disk.opts.keys()})")
1851+
16371852
ret_path = None
16381853

16391854
def cleanup_script_retcode():

virtme_ng/run.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,8 +1171,17 @@ def _get_virtme_disk(self, args):
11711171
disk_str = ""
11721172

11731173
def ensure_name(dsk: str) -> str:
1174-
if '=' not in dsk:
1175-
return f'{dsk}={dsk}'
1174+
"""
1175+
`dsk` is a comma-separated list of disk options (KEY=VAL), with the first
1176+
option specifying the disk name and path (NAME=PATH). As an exception,
1177+
NAME can be omitted (but the underlying implementation does not know that).
1178+
This function ensures that the first option has a NAME, and adds one
1179+
equal to the PATH if it is missing.
1180+
"""
1181+
items = dsk.split(",")
1182+
first = items[0]
1183+
if '=' not in first:
1184+
return f'{first}={dsk}'
11761185
return dsk
11771186

11781187
if args.disk is not None:

virtme_ng/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""virtme-ng: configuration path."""
55

66
import json
7+
import os.path
78
from pathlib import Path
89

910
from virtme_ng.spinner import Spinner
@@ -84,6 +85,15 @@ def get_conf(key_path):
8485
conf = conf[key]
8586
return conf
8687

88+
def strtobool(arg: str) -> bool:
89+
lower = arg.strip().lower()
90+
if lower in ("yes", "true", "on", "1"):
91+
return True
92+
elif lower in ("no", "false", "off", "0"):
93+
return False
94+
else:
95+
raise ValueError(f"invalid boolean value: {arg!r}")
96+
8797
def scsi_device_id(name: str, max_len: int) -> str:
8898
"""
8999
Trim a longer string which may or may not be a path to fit within `max_len`

0 commit comments

Comments
 (0)