nixos/test-driver: add option to force kvm use (#509553)

This commit is contained in:
Jacek Galowicz
2026-04-15 13:31:34 +00:00
committed by GitHub
13 changed files with 167 additions and 77 deletions
+3
View File
@@ -2246,6 +2246,9 @@
"test-opt-passthru": [
"index.html#test-opt-passthru"
],
"test-opt-qemu.forceAccel": [
"index.html#test-opt-qemu.forceAccel"
],
"test-opt-qemu.package": [
"index.html#test-opt-qemu.package"
],
+15 -8
View File
@@ -24,30 +24,37 @@ rec {
else
throw "Unknown QEMU serial device for system '${stdenv.hostPlatform.system}'";
qemuBinary =
qemuPkg:
qemuBinary = qemuPkg: qemuBinaryWith { inherit qemuPkg; };
qemuBinaryWith =
{
qemuPkg,
forceAccel ? false,
}:
let
hostStdenv = qemuPkg.stdenv;
hostSystem = hostStdenv.system;
guestSystem = stdenv.hostPlatform.system;
accel = accelName: if forceAccel then accelName else "${accelName}:tcg";
linuxHostGuestMatrix = {
x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=kvm:tcg -cpu max";
armv7l-linux = "${qemuPkg}/bin/qemu-system-arm -machine virt,accel=kvm:tcg -cpu max";
aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=max,accel=kvm:tcg -cpu max";
x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=${accel "kvm"} -cpu max";
armv7l-linux = "${qemuPkg}/bin/qemu-system-arm -machine virt,accel=${accel "kvm"} -cpu max";
aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=max,accel=${accel "kvm"} -cpu max";
powerpc64le-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv";
powerpc64-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv";
riscv32-linux = "${qemuPkg}/bin/qemu-system-riscv32 -machine virt";
riscv64-linux = "${qemuPkg}/bin/qemu-system-riscv64 -machine virt";
x86_64-darwin = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=kvm:tcg -cpu max";
x86_64-darwin = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=${accel "kvm"} -cpu max";
};
otherHostGuestMatrix = {
aarch64-darwin = {
aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=hvf:tcg -cpu max";
aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=${accel "hvf"} -cpu max";
inherit (otherHostGuestMatrix.x86_64-darwin) x86_64-linux;
};
x86_64-darwin = {
x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine type=q35,accel=hvf:tcg -cpu max";
x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine type=q35,accel=${accel "hvf"} -cpu max";
};
};
+5 -5
View File
@@ -7,11 +7,11 @@
imagemagick_light,
ipython,
junit-xml,
mypy,
ptpython,
python,
ruff,
remote-pdb,
ruff,
ty,
netpbm,
nixosTests,
@@ -72,13 +72,13 @@ buildPythonApplication {
doCheck = true;
nativeCheckInputs = [
mypy
ruff
ty
];
checkPhase = ''
echo -e "\x1b[32m## run mypy\x1b[0m"
mypy test_driver extract-docstrings.py
echo -e "\x1b[32m## run ty\x1b[0m"
ty check --error-on-warning test_driver extract-docstrings.py
echo -e "\x1b[32m## run ruff check\x1b[0m"
ruff check .
echo -e "\x1b[32m## run ruff format\x1b[0m"
+1 -20
View File
@@ -17,27 +17,8 @@ find = {}
test_driver = ["py.typed"]
[tool.ruff]
target-version = "py312"
target-version = "py313"
line-length = 88
lint.select = ["E", "F", "I", "U", "N"]
lint.ignore = ["E501", "N818"]
# xxx: we can import https://pypi.org/project/types-colorama/ here
[[tool.mypy.overrides]]
module = "colorama.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "ptpython.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "junit_xml.*"
ignore_missing_imports = true
[tool.mypy]
warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true
no_implicit_optional = true
@@ -23,7 +23,7 @@ class EnvDefault(argparse.Action):
environment variable as the flags default value.
"""
def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs): # type: ignore
def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs):
if not default and envvar:
if envvar in os.environ:
if nargs is not None and (nargs.isdigit() or nargs in ["*", "+"]):
@@ -37,7 +37,7 @@ class EnvDefault(argparse.Action):
required = False
super().__init__(default=default, required=required, nargs=nargs, **kwargs)
def __call__(self, parser, namespace, values, option_string=None): # type: ignore
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
@@ -219,7 +219,7 @@ def main() -> None:
ptpython.ipython.embed(
user_ns=driver.test_symbols(),
history_filename=history_path,
) # type:ignore
)
else:
tic = time.time()
driver.run_tests()
@@ -6,7 +6,7 @@ import subprocess
import sys
from abc import ABC, abstractmethod
from remote_pdb import RemotePdb # type:ignore
from remote_pdb import RemotePdb
from test_driver.logger import AbstractLogger
@@ -42,12 +42,12 @@ class Debug(DebugAbstract):
self.logger.log_test_error(
f"Breakpoint reached, run 'sudo {self.attach} {pattern}'"
)
os.environ["bashInteractive"] = shutil.which("bash") # type:ignore
os.environ["bashInteractive"] = shutil.which("bash") # ty: ignore[invalid-assignment]
if os.fork() == 0:
subprocess.run(["sleep", pattern])
else:
# RemotePdb writes log messages to both stderr AND the logger,
# which is the same here. Hence, disabling the remote_pdb logger
# to avoid duplicate messages in the build log.
logging.root.manager.loggerDict["remote_pdb"].disabled = True # type:ignore
logging.root.manager.loggerDict["remote_pdb"].disabled = True # ty: ignore[invalid-assignment]
RemotePdb(host=host, port=port).set_trace(sys._getframe().f_back)
@@ -336,10 +336,22 @@ class Driver:
def start_all(self) -> None:
"""Start all machines"""
with self.logger.nested("start all VMs"):
errors: list[tuple[str, BaseException]] = []
def start_machine(machine: BaseMachine) -> None:
try:
machine.start()
except Exception as e:
errors.append((machine.name, e))
threads = []
for machine in self.machines:
# Create a thread for each machine's start method
t = threading.Thread(target=machine.start, name=f"start-{machine.name}")
t = threading.Thread(
target=start_machine,
args=(machine,),
name=f"start-{machine.name}",
)
threads.append(t)
t.start()
@@ -347,6 +359,12 @@ class Driver:
for t in threads:
t.join()
if errors:
messages = [f"{name}: {e}" for name, e in errors]
raise MachineError(
"Failed to start the following machines:\n" + "\n".join(messages)
)
def join_all(self) -> None:
"""Wait for all machines to shut down"""
with self.logger.nested("wait for all VMs to finish"):
@@ -418,7 +436,7 @@ class Driver:
def __enter__(self) -> None:
driver.polling_conditions.append(self.condition)
def __exit__(self, a, b, c) -> None: # type: ignore
def __exit__(self, a, b, c) -> None:
res = driver.polling_conditions.pop()
assert res is self.condition
+25 -25
View File
@@ -1,5 +1,4 @@
import atexit
import codecs
import os
import sys
import time
@@ -40,19 +39,19 @@ class AbstractLogger(ABC):
pass
@abstractmethod
def info(self, *args, **kwargs) -> None: # type: ignore
def info(self, *args, **kwargs) -> None:
pass
@abstractmethod
def warning(self, *args, **kwargs) -> None: # type: ignore
def warning(self, *args, **kwargs) -> None:
pass
@abstractmethod
def error(self, *args, **kwargs) -> None: # type: ignore
def error(self, *args, **kwargs) -> None:
pass
@abstractmethod
def log_test_error(self, *args, **kwargs) -> None: # type:ignore
def log_test_error(self, *args, **kwargs) -> None:
pass
@abstractmethod
@@ -103,19 +102,19 @@ class JunitXMLLogger(AbstractLogger):
self.log(message)
yield
def info(self, *args, **kwargs) -> None: # type: ignore
def info(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.INFO:
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def warning(self, *args, **kwargs) -> None: # type: ignore
def warning(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.WARNING:
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def error(self, *args, **kwargs) -> None: # type: ignore
def error(self, *args, **kwargs) -> None:
self.tests[self.currentSubtest].stderr += args[0] + os.linesep
self.tests[self.currentSubtest].failure = True
def log_test_error(self, *args, **kwargs) -> None: # type: ignore
def log_test_error(self, *args, **kwargs) -> None:
self.error(*args, **kwargs)
def log_serial(self, message: str, machine: str) -> None:
@@ -172,19 +171,19 @@ class CompositeLogger(AbstractLogger):
stack.enter_context(logger.nested(message, attributes))
yield
def info(self, *args, **kwargs) -> None: # type: ignore
def info(self, *args, **kwargs) -> None:
for logger in self.logger_list:
logger.info(*args, **kwargs)
def warning(self, *args, **kwargs) -> None: # type: ignore
def warning(self, *args, **kwargs) -> None:
for logger in self.logger_list:
logger.warning(*args, **kwargs)
def log_test_error(self, *args, **kwargs) -> None: # type: ignore
def log_test_error(self, *args, **kwargs) -> None:
for logger in self.logger_list:
logger.log_test_error(*args, **kwargs)
def error(self, *args, **kwargs) -> None: # type: ignore
def error(self, *args, **kwargs) -> None:
for logger in self.logger_list:
logger.error(*args, **kwargs)
sys.exit(1)
@@ -228,7 +227,8 @@ class TerminalLogger(AbstractLogger):
def nested(self, message: str, attributes: dict[str, str] = {}) -> Iterator[None]:
self._eprint(
self.maybe_prefix(
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, # ty: ignore[unsupported-operator]
attributes,
)
)
@@ -237,15 +237,15 @@ class TerminalLogger(AbstractLogger):
toc = time.time()
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)", attributes)
def info(self, *args, **kwargs) -> None: # type: ignore
def info(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.INFO:
self.log(*args, **kwargs)
def warning(self, *args, **kwargs) -> None: # type: ignore
def warning(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.WARNING:
self.log(*args, **kwargs)
def error(self, *args, **kwargs) -> None: # type: ignore
def error(self, *args, **kwargs) -> None:
self.log(*args, **kwargs)
def print_serial_logs(self, enable: bool) -> None:
@@ -258,17 +258,17 @@ class TerminalLogger(AbstractLogger):
if not self._print_serial_logs:
return
self._eprint(Style.DIM + f"{machine} # {message}" + Style.RESET_ALL)
self._eprint(Style.DIM + f"{machine} # {message}" + Style.RESET_ALL) # ty: ignore[unsupported-operator]
def log_test_error(self, *args, **kwargs) -> None: # type: ignore
prefix = Fore.RED + "!!! " + Style.RESET_ALL
def log_test_error(self, *args, **kwargs) -> None:
prefix = Fore.RED + "!!! " + Style.RESET_ALL # ty: ignore[unsupported-operator]
# NOTE: using `warning` instead of `error` to ensure it does not exit after printing the first log
self.warning(f"{prefix}{args[0]}", *args[1:], **kwargs)
class XMLLogger(AbstractLogger):
def __init__(self, outfile: str) -> None:
self.logfile_handle = codecs.open(outfile, "wb")
self.logfile_handle = open(outfile, "wb")
self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8")
self.queue: Queue[dict[str, str]] = Queue()
@@ -296,18 +296,18 @@ class XMLLogger(AbstractLogger):
self.xml.characters(message)
self.xml.endElement("line")
def info(self, *args, **kwargs) -> None: # type: ignore
def info(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.INFO:
self.log(*args, **kwargs)
def warning(self, *args, **kwargs) -> None: # type: ignore
def warning(self, *args, **kwargs) -> None:
if self._log_level <= LogLevel.WARNING:
self.log(*args, **kwargs)
def error(self, *args, **kwargs) -> None: # type: ignore
def error(self, *args, **kwargs) -> None:
self.log(*args, **kwargs)
def log_test_error(self, *args, **kwargs) -> None: # type: ignore
def log_test_error(self, *args, **kwargs) -> None:
self.log(*args, **kwargs)
def log(self, message: str, attributes: dict[str, str] = {}) -> None:
@@ -24,9 +24,11 @@ from typing import Any
from test_driver.errors import MachineError, RequestedAssertionFailed
from test_driver.logger import AbstractLogger
from .ocr import perform_ocr_on_screenshot, perform_ocr_variants_on_screenshot
from .qmp import QMPSession
from test_driver.machine.ocr import (
perform_ocr_on_screenshot,
perform_ocr_variants_on_screenshot,
)
from test_driver.machine.qmp import QMPSession
CHAR_TO_KEY = {
"A": "shift-a",
@@ -210,6 +212,7 @@ class QemuStartCommand:
),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
cwd=state_dir,
env=self.build_environment(state_dir, shared_dir),
@@ -1225,8 +1228,29 @@ class QemuMachine(BaseMachine):
self.shell_path,
allow_reboot,
)
self.monitor, _ = monitor_socket.accept()
self.shell, _ = shell_socket.accept()
def accept_or_fail(sock: socket.socket, name: str) -> socket.socket:
"""Accept a connection on a socket, polling the status to check
if the QEMU process is still alive. Without this, socket.accept()
would block forever if QEMU exits before connecting.
"""
assert self.process
while True:
readable, _, _ = select.select([sock], [], [], 1.0)
if readable:
conn, _ = sock.accept()
return conn
rc = self.process.poll()
if rc is not None:
output = ""
if self.process.stdout:
output = self.process.stdout.read().decode(errors="ignore")
raise MachineError(
f"QEMU process exited with code {rc} before connecting to {name} socket.\n{output}"
)
self.monitor = accept_or_fail(monitor_socket, "monitor")
self.shell = accept_or_fail(shell_socket, "shell")
self.qmp_client = QMPSession.from_path(self.qmp_path)
# Store last serial console lines for use
@@ -25,7 +25,7 @@ class PollingCondition:
seconds_interval: float = 2.0,
description: str | None = None,
):
self.condition = condition # type: ignore
self.condition = condition # ty: ignore[invalid-assignment]
self.seconds_interval = seconds_interval
self.logger = logger
@@ -33,7 +33,7 @@ class PollingCondition:
if condition.__doc__:
self.description = condition.__doc__
else:
self.description = condition.__name__
self.description = condition.__name__ # ty: ignore[unresolved-attribute]
else:
self.description = str(description)
@@ -54,7 +54,7 @@ class PollingCondition:
self.logger.info(last_message)
try:
res = self.condition() # type: ignore
res = self.condition()
except Exception:
res = False
res = res is None or res
@@ -89,7 +89,7 @@ class PollingCondition:
def __enter__(self) -> None:
self.entry_count += 1
def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore
def __exit__(self, exc_type, exc_value, traceback) -> None:
assert self.entered
self.entry_count -= 1
self.last_called = time.monotonic()
+10
View File
@@ -159,6 +159,16 @@ in
defaultText = "hostPkgs.qemu_test";
};
qemu.forceAccel = mkOption {
description = ''
Whether to force the use of hardware-accelerated virtualisation.
When enabled, QEMU will not fall back to the slower software emulation
(TCG) and will instead error out if the accelerator is not available.
'';
type = types.bool;
default = false;
};
globalTimeout = mkOption {
description = ''
A global timeout for the complete test, expressed in seconds.
+3 -1
View File
@@ -71,7 +71,9 @@ let
config.nodeDefaults
{
key = "base-qemu";
virtualisation.qemu.package = testModuleArgs.config.qemu.package;
virtualisation.qemu = {
inherit (testModuleArgs.config.qemu) package forceAccel;
};
virtualisation.host.pkgs = hostPkgs;
}
testModuleArgs.config.extraBaseNodeModules
+46 -1
View File
@@ -306,8 +306,42 @@ let
(builtins.concatStringsSep "")
]}
${lib.optionalString cfg.qemu.forceAccel (
if hostPkgs.stdenv.hostPlatform.isLinux then
''
# Check for hardware-accelerated virtualisation support (KVM)
if [ ! -e /dev/kvm ]; then
echo "forceAccel is enabled but /dev/kvm does not exist." >&2
echo "Hardware-accelerated virtualisation (KVM) is not available on this system." >&2
exit 1
elif [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then
echo "forceAccel is enabled but /dev/kvm is not accessible (permission denied)." >&2
echo "Check that the nix build user is in the 'kvm' group or that /dev/kvm has the correct permissions." >&2
exit 1
fi
''
else if hostPkgs.stdenv.hostPlatform.isDarwin then
''
# Check for hardware-accelerated virtualisation support (HVF)
if ! sysctl -n kern.hv_support 2>/dev/null | grep -q 1; then
echo "forceAccel is enabled but Hypervisor.framework is not available on this system." >&2
exit 1
fi
''
else
''
echo "forceAccel is enabled but no known accelerator is available for this platform." >&2
exit 1
''
)}
# Start QEMU.
exec ${qemu-common.qemuBinary qemu} \
exec ${
qemu-common.qemuBinaryWith {
qemuPkg = qemu;
forceAccel = cfg.qemu.forceAccel;
}
} \
-name ${config.system.name} \
-m ${toString config.virtualisation.memorySize} \
-smp ${toString config.virtualisation.cores} \
@@ -728,6 +762,17 @@ in
description = "QEMU package to use.";
};
forceAccel = mkOption {
type = types.bool;
default = false;
description = ''
Whether to force the use of hardware-accelerated virtualisation.
When enabled, QEMU will not fall back to the slower software
emulation (TCG) and will instead error out if the accelerator is not
available.
'';
};
options = mkOption {
type = types.listOf types.str;
default = [ ];