diff options
Diffstat (limited to 'nixos/lib')
23 files changed, 415 insertions, 131 deletions
diff --git a/nixos/lib/eval-config.nix b/nixos/lib/eval-config.nix index 81a5ea1750d..da099f86aa2 100644 --- a/nixos/lib/eval-config.nix +++ b/nixos/lib/eval-config.nix @@ -34,9 +34,6 @@ evalConfigArgs@ in lib.optional (e != "") (import e) }: -let pkgs_ = pkgs; -in - let inherit (lib) optional; @@ -58,8 +55,9 @@ let nixpkgs.system = lib.mkDefault system; }) ++ - (optional (pkgs_ != null) { - _module.args.pkgs = lib.mkForce pkgs_; + (optional (pkgs != null) { + # This should be default priority, so it conflicts with any user-defined pkgs. + nixpkgs.pkgs = pkgs; }) ); }; @@ -109,10 +107,10 @@ let nixosWithUserModules = noUserModules.extendModules { modules = allUserModules; }; - withExtraArgs = nixosSystem: nixosSystem // { + withExtraAttrs = configuration: configuration // { inherit extraArgs; - inherit (nixosSystem._module.args) pkgs; - extendModules = args: withExtraArgs (nixosSystem.extendModules args); + inherit (configuration._module.args) pkgs; + extendModules = args: withExtraAttrs (configuration.extendModules args); }; in -withWarnings (withExtraArgs nixosWithUserModules) +withWarnings (withExtraAttrs nixosWithUserModules) diff --git a/nixos/lib/make-btrfs-fs.nix b/nixos/lib/make-btrfs-fs.nix index 225666f9a50..277ff6a4dca 100644 --- a/nixos/lib/make-btrfs-fs.nix +++ b/nixos/lib/make-btrfs-fs.nix @@ -15,6 +15,8 @@ , volumeLabel , uuid ? "44444444-4444-4444-8888-888888888888" , btrfs-progs +, libfaketime +, fakeroot }: let @@ -23,7 +25,7 @@ in pkgs.stdenv.mkDerivation { name = "btrfs-fs.img${lib.optionalString compressImage ".zst"}"; - nativeBuildInputs = [ btrfs-progs ] ++ lib.optional compressImage zstd; + nativeBuildInputs = [ btrfs-progs libfaketime fakeroot ] ++ lib.optional compressImage zstd; buildCommand = '' @@ -50,7 +52,7 @@ pkgs.stdenv.mkDerivation { cp ${sdClosureInfo}/registration ./rootImage/nix-path-registration touch $img - mkfs.btrfs -L ${volumeLabel} -U ${uuid} -r ./rootImage --shrink $img + faketime -f "1970-01-01 00:00:01" fakeroot mkfs.btrfs -L ${volumeLabel} -U ${uuid} -r ./rootImage --shrink $img if ! btrfs check $img; then echo "--- 'btrfs check' failed for BTRFS image ---" diff --git a/nixos/lib/make-squashfs.nix b/nixos/lib/make-squashfs.nix index b7c7078b73b..4b6b5673994 100644 --- a/nixos/lib/make-squashfs.nix +++ b/nixos/lib/make-squashfs.nix @@ -1,15 +1,22 @@ { lib, stdenv, squashfsTools, closureInfo +, fileName ? "squashfs" , # The root directory of the squashfs filesystem is filled with the # closures of the Nix store paths listed here. storeContents ? [] + # Pseudo files to be added to squashfs image +, pseudoFiles ? [] +, noStrip ? false , # Compression parameters. # For zstd compression you can use "zstd -Xcompression-level 6". comp ? "xz -Xdict-size 100%" }: +let + pseudoFilesArgs = lib.concatMapStrings (f: ''-p "${f}" '') pseudoFiles; +in stdenv.mkDerivation { - name = "squashfs.img"; + name = "${fileName}.img"; __structuredAttrs = true; nativeBuildInputs = [ squashfsTools ]; @@ -31,8 +38,8 @@ stdenv.mkDerivation { '' + '' # Generate the squashfs image. - mksquashfs nix-path-registration $(cat $closureInfo/store-paths) $out \ - -no-hardlinks -keep-as-directory -all-root -b 1048576 -comp ${comp} \ + mksquashfs nix-path-registration $(cat $closureInfo/store-paths) $out ${pseudoFilesArgs} \ + -no-hardlinks ${lib.optionalString noStrip "-no-strip"} -keep-as-directory -all-root -b 1048576 -comp ${comp} \ -processors $NIX_BUILD_CORES ''; } diff --git a/nixos/lib/qemu-common.nix b/nixos/lib/qemu-common.nix index 4fff2e0a6f1..b946f62d93d 100644 --- a/nixos/lib/qemu-common.nix +++ b/nixos/lib/qemu-common.nix @@ -40,6 +40,7 @@ rec { otherHostGuestMatrix = { aarch64-darwin = { aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=hvf:tcg -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"; diff --git a/nixos/lib/systemd-lib.nix b/nixos/lib/systemd-lib.nix index f6535b51406..820ccbcbf72 100644 --- a/nixos/lib/systemd-lib.nix +++ b/nixos/lib/systemd-lib.nix @@ -20,12 +20,16 @@ in rec { pkgs.runCommand "unit-${mkPathSafeName name}" { preferLocalBuild = true; allowSubstitutes = false; - inherit (unit) text; + # unit.text can be null. But variables that are null listed in + # passAsFile are ignored by nix, resulting in no file being created, + # making the mv operation fail. + text = optionalString (unit.text != null) unit.text; + passAsFile = [ "text" ]; } '' name=${shellEscape name} mkdir -p "$out/$(dirname -- "$name")" - echo -n "$text" > "$out/$name" + mv "$textPath" "$out/$name" '' else pkgs.runCommand "unit-${mkPathSafeName name}-disabled" @@ -80,6 +84,10 @@ in rec { optional (attr ? ${name} && !elem attr.${name} values) "Systemd ${group} field `${name}' cannot have value `${toString attr.${name}}'."; + assertValuesSomeOfOr = name: values: default: group: attr: + optional (attr ? ${name} && !(all (x: elem x values) (splitString " " attr.${name}) || attr.${name} == default)) + "Systemd ${group} field `${name}' cannot have value `${toString attr.${name}}'."; + assertHasField = name: group: attr: optional (!(attr ? ${name})) "Systemd ${group} field `${name}' must exist."; @@ -368,24 +376,23 @@ in rec { serviceToUnit = name: def: { inherit (def) aliases wantedBy requiredBy enable overrideStrategy; - text = commonUnitText def + - '' - [Service] - ${let env = cfg.globalEnvironment // def.environment; - in concatMapStrings (n: - let s = optionalString (env.${n} != null) - "Environment=${builtins.toJSON "${n}=${env.${n}}"}\n"; - # systemd max line length is now 1MiB - # https://github.com/systemd/systemd/commit/e6dde451a51dc5aaa7f4d98d39b8fe735f73d2af - in if stringLength s >= 1048576 then throw "The value of the environment variable ‘${n}’ in systemd service ‘${name}.service’ is too long." else s) (attrNames env)} - ${if def ? reloadIfChanged && def.reloadIfChanged then '' - X-ReloadIfChanged=true - '' else if (def ? restartIfChanged && !def.restartIfChanged) then '' - X-RestartIfChanged=false - '' else ""} - ${optionalString (def ? stopIfChanged && !def.stopIfChanged) "X-StopIfChanged=false"} - ${attrsToSection def.serviceConfig} - ''; + text = commonUnitText def + '' + [Service] + '' + (let env = cfg.globalEnvironment // def.environment; + in concatMapStrings (n: + let s = optionalString (env.${n} != null) + "Environment=${builtins.toJSON "${n}=${env.${n}}"}\n"; + # systemd max line length is now 1MiB + # https://github.com/systemd/systemd/commit/e6dde451a51dc5aaa7f4d98d39b8fe735f73d2af + in if stringLength s >= 1048576 then throw "The value of the environment variable ‘${n}’ in systemd service ‘${name}.service’ is too long." else s) (attrNames env)) + + (if def ? reloadIfChanged && def.reloadIfChanged then '' + X-ReloadIfChanged=true + '' else if (def ? restartIfChanged && !def.restartIfChanged) then '' + X-RestartIfChanged=false + '' else "") + + optionalString (def ? stopIfChanged && !def.stopIfChanged) '' + X-StopIfChanged=false + '' + attrsToSection def.serviceConfig; }; socketToUnit = name: def: diff --git a/nixos/lib/systemd-network-units.nix b/nixos/lib/systemd-network-units.nix index 14ff0b3742e..1d5f823f367 100644 --- a/nixos/lib/systemd-network-units.nix +++ b/nixos/lib/systemd-network-units.nix @@ -23,6 +23,12 @@ in { '' + optionalString (def.vlanConfig != { }) '' [VLAN] ${attrsToSection def.vlanConfig} + '' + optionalString (def.ipvlanConfig != { }) '' + [IPVLAN] + ${attrsToSection def.ipvlanConfig} + '' + optionalString (def.ipvtapConfig != { }) '' + [IPVTAP] + ${attrsToSection def.ipvtapConfig} '' + optionalString (def.macvlanConfig != { }) '' [MACVLAN] ${attrsToSection def.macvlanConfig} @@ -65,6 +71,9 @@ in { '' + optionalString (def.vrfConfig != { }) '' [VRF] ${attrsToSection def.vrfConfig} + '' + optionalString (def.wlanConfig != { }) '' + [WLAN] + ${attrsToSection def.wlanConfig} '' + optionalString (def.batmanAdvancedConfig != { }) '' [BatmanAdvanced] ${attrsToSection def.batmanAdvancedConfig} diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index 33313059fff..09d80deb854 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -4,19 +4,21 @@ , qemu_pkg ? qemu_test , coreutils , imagemagick_light -, libtiff , netpbm , qemu_test , socat +, ruff , tesseract4 , vde2 , extraPythonPackages ? (_ : []) +, nixosTests }: -python3Packages.buildPythonApplication rec { +python3Packages.buildPythonApplication { pname = "nixos-test-driver"; version = "1.1"; src = ./.; + format = "pyproject"; propagatedBuildInputs = [ coreutils @@ -30,15 +32,18 @@ python3Packages.buildPythonApplication rec { ++ (lib.optionals enableOCR [ imagemagick_light tesseract4 ]) ++ extraPythonPackages python3Packages; + passthru.tests = { + inherit (nixosTests.nixos-test-driver) driver-timeout; + }; + doCheck = true; - nativeCheckInputs = with python3Packages; [ mypy pylint black ]; + nativeCheckInputs = with python3Packages; [ mypy ruff black ]; checkPhase = '' - mypy --disallow-untyped-defs \ - --no-implicit-optional \ - --pretty \ - --no-color-output \ - --ignore-missing-imports ${src}/test_driver - pylint --errors-only --enable=unused-import ${src}/test_driver - black --check --diff ${src}/test_driver + echo -e "\x1b[32m## run mypy\x1b[0m" + mypy test_driver extract-docstrings.py + echo -e "\x1b[32m## run ruff\x1b[0m" + ruff . + echo -e "\x1b[32m## run black\x1b[0m" + black --check --diff . ''; } diff --git a/nixos/lib/test-driver/extract-docstrings.py b/nixos/lib/test-driver/extract-docstrings.py index 5aec4c89a9d..64850ca711f 100644 --- a/nixos/lib/test-driver/extract-docstrings.py +++ b/nixos/lib/test-driver/extract-docstrings.py @@ -1,5 +1,6 @@ import ast import sys +from pathlib import Path """ This program takes all the Machine class methods and prints its methods in @@ -40,27 +41,34 @@ some_function(param1, param2) """ -assert len(sys.argv) == 2 -with open(sys.argv[1], "r") as f: - module = ast.parse(f.read()) +def main() -> None: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} <path-to-test-driver>") + sys.exit(1) -class_definitions = (node for node in module.body if isinstance(node, ast.ClassDef)) + module = ast.parse(Path(sys.argv[1]).read_text()) -machine_class = next(filter(lambda x: x.name == "Machine", class_definitions)) -assert machine_class is not None + class_definitions = (node for node in module.body if isinstance(node, ast.ClassDef)) -function_definitions = [ - node for node in machine_class.body if isinstance(node, ast.FunctionDef) -] -function_definitions.sort(key=lambda x: x.name) + machine_class = next(filter(lambda x: x.name == "Machine", class_definitions)) + assert machine_class is not None -for f in function_definitions: - docstr = ast.get_docstring(f) - if docstr is not None: - args = ", ".join((a.arg for a in f.args.args[1:])) - args = f"({args})" + function_definitions = [ + node for node in machine_class.body if isinstance(node, ast.FunctionDef) + ] + function_definitions.sort(key=lambda x: x.name) - docstr = "\n".join((f" {l}" for l in docstr.strip().splitlines())) + for function in function_definitions: + docstr = ast.get_docstring(function) + if docstr is not None: + args = ", ".join(a.arg for a in function.args.args[1:]) + args = f"({args})" - print(f"{f.name}{args}\n\n:{docstr[1:]}\n") + docstr = "\n".join(f" {line}" for line in docstr.strip().splitlines()) + + print(f"{function.name}{args}\n\n:{docstr[1:]}\n") + + +if __name__ == "__main__": + main() diff --git a/nixos/lib/test-driver/pyproject.toml b/nixos/lib/test-driver/pyproject.toml new file mode 100644 index 00000000000..8638f14dfda --- /dev/null +++ b/nixos/lib/test-driver/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "nixos-test-driver" +version = "0.0.0" + +[project.scripts] +nixos-test-driver = "test_driver:main" +generate-driver-symbols = "test_driver:generate_driver_symbols" + +[tool.setuptools.packages] +find = {} + +[tool.setuptools.package-data] +test_driver = ["py.typed"] + +[tool.ruff] +line-length = 88 + +select = ["E", "F", "I", "U", "N"] +ignore = ["E501"] + +# 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.black] +line-length = 88 +target-version = ['py39'] +include = '\.pyi?$' + +[tool.mypy] +python_version = "3.10" +warn_redundant_casts = true +disallow_untyped_calls = true +disallow_untyped_defs = true +no_implicit_optional = true diff --git a/nixos/lib/test-driver/setup.py b/nixos/lib/test-driver/setup.py deleted file mode 100644 index 1719b988db6..00000000000 --- a/nixos/lib/test-driver/setup.py +++ /dev/null @@ -1,14 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="nixos-test-driver", - version='1.1', - packages=find_packages(), - package_data={"test_driver": ["py.typed"]}, - entry_points={ - "console_scripts": [ - "nixos-test-driver=test_driver:main", - "generate-driver-symbols=test_driver:generate_driver_symbols" - ] - }, -) diff --git a/nixos/lib/test-driver/shell.nix b/nixos/lib/test-driver/shell.nix new file mode 100644 index 00000000000..367bbad556c --- /dev/null +++ b/nixos/lib/test-driver/shell.nix @@ -0,0 +1,2 @@ +with import ../../.. {}; +pkgs.callPackage ./default.nix {} diff --git a/nixos/lib/test-driver/test_driver/__init__.py b/nixos/lib/test-driver/test_driver/__init__.py index c90e3d9e1cd..9daae1e941a 100755 --- a/nixos/lib/test-driver/test_driver/__init__.py +++ b/nixos/lib/test-driver/test_driver/__init__.py @@ -1,11 +1,12 @@ -from pathlib import Path import argparse -import ptpython.repl import os import time +from pathlib import Path + +import ptpython.repl -from test_driver.logger import rootlog from test_driver.driver import Driver +from test_driver.logger import rootlog class EnvDefault(argparse.Action): @@ -25,9 +26,7 @@ class EnvDefault(argparse.Action): ) if required and default: required = False - super(EnvDefault, self).__init__( - default=default, required=required, nargs=nargs, **kwargs - ) + super().__init__(default=default, required=required, nargs=nargs, **kwargs) def __call__(self, parser, namespace, values, option_string=None): # type: ignore setattr(namespace, self.dest, values) @@ -78,6 +77,14 @@ def main() -> None: help="vlans to span by the driver", ) arg_parser.add_argument( + "--global-timeout", + type=int, + metavar="GLOBAL_TIMEOUT", + action=EnvDefault, + envvar="globalTimeout", + help="Timeout in seconds for the whole test", + ) + arg_parser.add_argument( "-o", "--output_directory", help="""The path to the directory where outputs copied from the VM will be placed. @@ -104,6 +111,7 @@ def main() -> None: args.testscript.read_text(), args.output_directory.resolve(), args.keep_vm_state, + args.global_timeout, ) as driver: if args.interactive: history_dir = os.getcwd() diff --git a/nixos/lib/test-driver/test_driver/driver.py b/nixos/lib/test-driver/test_driver/driver.py index 835d60ec3b4..786821b0cc0 100644 --- a/nixos/lib/test-driver/test_driver/driver.py +++ b/nixos/lib/test-driver/test_driver/driver.py @@ -1,14 +1,16 @@ -from contextlib import contextmanager -from pathlib import Path -from typing import Any, Dict, Iterator, List, Union, Optional, Callable, ContextManager import os import re +import signal import tempfile +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, ContextManager, Dict, Iterator, List, Optional, Union from test_driver.logger import rootlog from test_driver.machine import Machine, NixStartScript, retry -from test_driver.vlan import VLan from test_driver.polling_condition import PollingCondition +from test_driver.vlan import VLan def get_tmp_dir() -> Path: @@ -41,6 +43,8 @@ class Driver: vlans: List[VLan] machines: List[Machine] polling_conditions: List[PollingCondition] + global_timeout: int + race_timer: threading.Timer def __init__( self, @@ -49,9 +53,12 @@ class Driver: tests: str, out_dir: Path, keep_vm_state: bool = False, + global_timeout: int = 24 * 60 * 60 * 7, ): self.tests = tests self.out_dir = out_dir + self.global_timeout = global_timeout + self.race_timer = threading.Timer(global_timeout, self.terminate_test) tmp_dir = get_tmp_dir() @@ -82,6 +89,7 @@ class Driver: def __exit__(self, *_: Any) -> None: with rootlog.nested("cleanup"): + self.race_timer.cancel() for machine in self.machines: machine.release() @@ -144,6 +152,10 @@ class Driver: def run_tests(self) -> None: """Run the test script (for non-interactive test runs)""" + rootlog.info( + f"Test will time out and terminate in {self.global_timeout} seconds" + ) + self.race_timer.start() self.test_script() # TODO: Collect coverage data for machine in self.machines: @@ -161,6 +173,19 @@ class Driver: with rootlog.nested("wait for all VMs to finish"): for machine in self.machines: machine.wait_for_shutdown() + self.race_timer.cancel() + + def terminate_test(self) -> None: + # This will be usually running in another thread than + # the thread actually executing the test script. + with rootlog.nested("timeout reached; test terminating..."): + for machine in self.machines: + machine.release() + # As we cannot `sys.exit` from another thread + # We can at least force the main thread to get SIGTERM'ed. + # This will prevent any user who caught all the exceptions + # to swallow them and prevent itself from terminating. + os.kill(os.getpid(), signal.SIGTERM) def create_machine(self, args: Dict[str, Any]) -> Machine: tmp_dir = get_tmp_dir() diff --git a/nixos/lib/test-driver/test_driver/logger.py b/nixos/lib/test-driver/test_driver/logger.py index e6182ff7c76..116244b5e4a 100644 --- a/nixos/lib/test-driver/test_driver/logger.py +++ b/nixos/lib/test-driver/test_driver/logger.py @@ -1,13 +1,17 @@ -from colorama import Style, Fore -from contextlib import contextmanager -from typing import Any, Dict, Iterator -from queue import Queue, Empty -from xml.sax.saxutils import XMLGenerator +# mypy: disable-error-code="no-untyped-call" +# drop the above line when mypy is upgraded to include +# https://github.com/python/typeshed/commit/49b717ca52bf0781a538b04c0d76a5513f7119b8 import codecs import os import sys import time import unicodedata +from contextlib import contextmanager +from queue import Empty, Queue +from typing import Any, Dict, Iterator +from xml.sax.saxutils import XMLGenerator + +from colorama import Fore, Style class Logger: diff --git a/nixos/lib/test-driver/test_driver/machine.py b/nixos/lib/test-driver/test_driver/machine.py index 2afcbc95c66..f430321bb60 100644 --- a/nixos/lib/test-driver/test_driver/machine.py +++ b/nixos/lib/test-driver/test_driver/machine.py @@ -1,7 +1,3 @@ -from contextlib import _GeneratorContextManager, nullcontext -from pathlib import Path -from queue import Queue -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple import base64 import io import os @@ -16,9 +12,15 @@ import sys import tempfile import threading import time +from contextlib import _GeneratorContextManager, nullcontext +from pathlib import Path +from queue import Queue +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple from test_driver.logger import rootlog +from .qmp import QMPSession + CHAR_TO_KEY = { "A": "shift-a", "N": "shift-n", @@ -144,6 +146,7 @@ class StartCommand: def cmd( self, monitor_socket_path: Path, + qmp_socket_path: Path, shell_socket_path: Path, allow_reboot: bool = False, ) -> str: @@ -167,6 +170,7 @@ class StartCommand: return ( f"{self._cmd}" + f" -qmp unix:{qmp_socket_path},server=on,wait=off" f" -monitor unix:{monitor_socket_path}" f" -chardev socket,id=shell,path={shell_socket_path}" f"{qemu_opts}" @@ -194,11 +198,14 @@ class StartCommand: state_dir: Path, shared_dir: Path, monitor_socket_path: Path, + qmp_socket_path: Path, shell_socket_path: Path, allow_reboot: bool, ) -> subprocess.Popen: return subprocess.Popen( - self.cmd(monitor_socket_path, shell_socket_path, allow_reboot), + self.cmd( + monitor_socket_path, qmp_socket_path, shell_socket_path, allow_reboot + ), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -236,14 +243,14 @@ class LegacyStartCommand(StartCommand): def __init__( self, - netBackendArgs: Optional[str] = None, - netFrontendArgs: Optional[str] = None, + netBackendArgs: Optional[str] = None, # noqa: N803 + netFrontendArgs: Optional[str] = None, # noqa: N803 hda: Optional[Tuple[Path, str]] = None, cdrom: Optional[str] = None, usb: Optional[str] = None, bios: Optional[str] = None, - qemuBinary: Optional[str] = None, - qemuFlags: Optional[str] = None, + qemuBinary: Optional[str] = None, # noqa: N803 + qemuFlags: Optional[str] = None, # noqa: N803 ): if qemuBinary is not None: self._cmd = qemuBinary @@ -309,6 +316,7 @@ class Machine: shared_dir: Path state_dir: Path monitor_path: Path + qmp_path: Path shell_path: Path start_command: StartCommand @@ -317,6 +325,7 @@ class Machine: process: Optional[subprocess.Popen] pid: Optional[int] monitor: Optional[socket.socket] + qmp_client: Optional[QMPSession] shell: Optional[socket.socket] serial_thread: Optional[threading.Thread] @@ -352,6 +361,7 @@ class Machine: self.state_dir = self.tmp_dir / f"vm-state-{self.name}" self.monitor_path = self.state_dir / "monitor" + self.qmp_path = self.state_dir / "qmp" self.shell_path = self.state_dir / "shell" if (not self.keep_vm_state) and self.state_dir.exists(): self.cleanup_statedir() @@ -360,6 +370,7 @@ class Machine: self.process = None self.pid = None self.monitor = None + self.qmp_client = None self.shell = None self.serial_thread = None @@ -599,7 +610,7 @@ class Machine: return (-1, output.decode()) # Get the return code - self.shell.send("echo ${PIPESTATUS[0]}\n".encode()) + self.shell.send(b"echo ${PIPESTATUS[0]}\n") rc = int(self._next_newline_closed_block_from_shell().strip()) return (rc, output.decode(errors="replace")) @@ -791,6 +802,28 @@ class Machine: with self.nested(f"waiting for TCP port {port} on {addr}"): retry(port_is_open, timeout) + def wait_for_open_unix_socket( + self, addr: str, is_datagram: bool = False, timeout: int = 900 + ) -> None: + """ + Wait until a process is listening on the given UNIX-domain socket + (default to a UNIX-domain stream socket). + """ + + nc_flags = [ + "-z", + "-uU" if is_datagram else "-U", + ] + + def socket_is_open(_: Any) -> bool: + status, _ = self.execute(f"nc {' '.join(nc_flags)} {addr}") + return status == 0 + + with self.nested( + f"waiting for UNIX-domain {'datagram' if is_datagram else 'stream'} on '{addr}'" + ): + retry(socket_is_open, timeout) + def wait_for_closed_port( self, port: int, addr: str = "localhost", timeout: int = 900 ) -> None: @@ -843,6 +876,9 @@ class Machine: while True: chunk = self.shell.recv(1024) + # No need to print empty strings, it means we are waiting. + if len(chunk) == 0: + continue self.log(f"Guest shell says: {chunk!r}") # NOTE: for this to work, nothing must be printed after this line! if b"Spawning backdoor root shell..." in chunk: @@ -1087,11 +1123,13 @@ class Machine: self.state_dir, self.shared_dir, self.monitor_path, + self.qmp_path, self.shell_path, allow_reboot, ) self.monitor, _ = monitor_socket.accept() self.shell, _ = shell_socket.accept() + self.qmp_client = QMPSession.from_path(self.qmp_path) # Store last serial console lines for use # of wait_for_console_text @@ -1129,7 +1167,7 @@ class Machine: return assert self.shell - self.shell.send("poweroff\n".encode()) + self.shell.send(b"poweroff\n") self.wait_for_shutdown() def crash(self) -> None: @@ -1240,3 +1278,19 @@ class Machine: def run_callbacks(self) -> None: for callback in self.callbacks: callback() + + def switch_root(self) -> None: + """ + Transition from stage 1 to stage 2. This requires the + machine to be configured with `testing.initrdBackdoor = true` + and `boot.initrd.systemd.enable = true`. + """ + self.wait_for_unit("initrd.target") + self.execute( + "systemctl isolate --no-block initrd-switch-root.target 2>/dev/null >/dev/null", + check_return=False, + check_output=False, + ) + self.wait_for_console_text(r"systemd\[1\]:.*Switching root\.") + self.connected = False + self.connect() diff --git a/nixos/lib/test-driver/test_driver/polling_condition.py b/nixos/lib/test-driver/test_driver/polling_condition.py index 02ca0a03ab3..12cbad69e34 100644 --- a/nixos/lib/test-driver/test_driver/polling_condition.py +++ b/nixos/lib/test-driver/test_driver/polling_condition.py @@ -1,11 +1,11 @@ -from typing import Callable, Optional -from math import isfinite import time +from math import isfinite +from typing import Callable, Optional from .logger import rootlog -class PollingConditionFailed(Exception): +class PollingConditionError(Exception): pass @@ -60,7 +60,7 @@ class PollingCondition: def maybe_raise(self) -> None: if not self.check(): - raise PollingConditionFailed(self.status_message(False)) + raise PollingConditionError(self.status_message(False)) def status_message(self, status: bool) -> str: return f"Polling condition {'succeeded' if status else 'failed'}: {self.description}" diff --git a/nixos/lib/test-driver/test_driver/qmp.py b/nixos/lib/test-driver/test_driver/qmp.py new file mode 100644 index 00000000000..62ca6d7d5b8 --- /dev/null +++ b/nixos/lib/test-driver/test_driver/qmp.py @@ -0,0 +1,98 @@ +import json +import logging +import os +import socket +from collections.abc import Iterator +from pathlib import Path +from queue import Queue +from typing import Any + +logger = logging.getLogger(__name__) + + +class QMPAPIError(RuntimeError): + def __init__(self, message: dict[str, Any]): + assert "error" in message, "Not an error message!" + try: + self.class_name = message["class"] + self.description = message["desc"] + # NOTE: Some errors can occur before the Server is able to read the + # id member; in these cases the id member will not be part of the + # error response, even if provided by the client. + self.transaction_id = message.get("id") + except KeyError: + raise RuntimeError("Malformed QMP API error response") + + def __str__(self) -> str: + return f"<QMP API error related to transaction {self.transaction_id} [{self.class_name}]: {self.description}>" + + +class QMPSession: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.results: Queue[dict[str, str]] = Queue() + self.pending_events: Queue[dict[str, Any]] = Queue() + self.reader = sock.makefile("r") + self.writer = sock.makefile("w") + # Make the reader non-blocking so we can kind of select on it. + os.set_blocking(self.reader.fileno(), False) + hello = self._wait_for_new_result() + logger.debug(f"Got greeting from QMP API: {hello}") + # The greeting message format is: + # { "QMP": { "version": json-object, "capabilities": json-array } } + assert "QMP" in hello, f"Unexpected result: {hello}" + self.send("qmp_capabilities") + + @classmethod + def from_path(cls, path: Path) -> "QMPSession": + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(str(path)) + return cls(sock) + + def __del__(self) -> None: + self.sock.close() + + def _wait_for_new_result(self) -> dict[str, str]: + assert self.results.empty(), "Results set is not empty, missed results!" + while self.results.empty(): + self.read_pending_messages() + return self.results.get() + + def read_pending_messages(self) -> None: + line = self.reader.readline() + if not line: + return + evt_or_result = json.loads(line) + logger.debug(f"Received a message: {evt_or_result}") + + # It's a result + if "return" in evt_or_result or "QMP" in evt_or_result: + self.results.put(evt_or_result) + # It's an event + elif "event" in evt_or_result: + self.pending_events.put(evt_or_result) + else: + raise QMPAPIError(evt_or_result) + + def wait_for_event(self, timeout: int = 10) -> dict[str, Any]: + while self.pending_events.empty(): + self.read_pending_messages() + + return self.pending_events.get(timeout=timeout) + + def events(self, timeout: int = 10) -> Iterator[dict[str, Any]]: + while not self.pending_events.empty(): + yield self.pending_events.get(timeout=timeout) + + def send(self, cmd: str, args: dict[str, str] = {}) -> dict[str, str]: + self.read_pending_messages() + assert self.results.empty(), "Results set is not empty, missed results!" + data: dict[str, Any] = dict(execute=cmd) + if args != {}: + data["arguments"] = args + + logger.debug(f"Sending {data} to QMP...") + json.dump(data, self.writer) + self.writer.write("\n") + self.writer.flush() + return self._wait_for_new_result() diff --git a/nixos/lib/test-driver/test_driver/vlan.py b/nixos/lib/test-driver/test_driver/vlan.py index f2a7f250d1d..ec9679108e5 100644 --- a/nixos/lib/test-driver/test_driver/vlan.py +++ b/nixos/lib/test-driver/test_driver/vlan.py @@ -1,8 +1,8 @@ -from pathlib import Path import io import os import pty import subprocess +from pathlib import Path from test_driver.logger import rootlog diff --git a/nixos/lib/testing-python.nix b/nixos/lib/testing-python.nix index 4904ad6e359..f5222351518 100644 --- a/nixos/lib/testing-python.nix +++ b/nixos/lib/testing-python.nix @@ -42,6 +42,7 @@ rec { , nodes ? {} , testScript , enableOCR ? false + , globalTimeout ? (60 * 60) , name ? "unnamed" , skipTypeCheck ? false # Skip linting (mainly intended for faster dev cycles) diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index cc97ca72083..b6f01c38191 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -94,6 +94,7 @@ let wrapProgram $out/bin/nixos-test-driver \ --set startScripts "''${vmStartScripts[*]}" \ --set testScript "$out/test-script" \ + --set globalTimeout "${toString config.globalTimeout}" \ --set vlans '${toString vlans}' \ ${lib.escapeShellArgs (lib.concatMap (arg: ["--add-flags" arg]) config.extraDriverArgs)} ''; @@ -123,6 +124,18 @@ in defaultText = "hostPkgs.qemu_test"; }; + globalTimeout = mkOption { + description = mdDoc '' + A global timeout for the complete test, expressed in seconds. + Beyond that timeout, every resource will be killed and released and the test will fail. + + By default, we use a 1 hour timeout. + ''; + type = types.int; + default = 60 * 60; + example = 10 * 60; + }; + enableOCR = mkOption { description = mdDoc '' Whether to enable Optical Character Recognition functionality for diff --git a/nixos/lib/testing/nodes.nix b/nixos/lib/testing/nodes.nix index f58759b4cdb..73e6d386fd1 100644 --- a/nixos/lib/testing/nodes.nix +++ b/nixos/lib/testing/nodes.nix @@ -28,15 +28,14 @@ let { virtualisation.qemu.package = testModuleArgs.config.qemu.package; }) - (optionalAttrs (!config.node.pkgsReadOnly) { + ({ options, ... }: { key = "nodes.nix-pkgs"; - config = { - # Ensure we do not use aliases. Ideally this is only set - # when the test framework is used by Nixpkgs NixOS tests. - nixpkgs.config.allowAliases = false; - # TODO: switch to nixpkgs.hostPlatform and make sure containers-imperative test still evaluates. - nixpkgs.system = hostPkgs.stdenv.hostPlatform.system; - }; + config = optionalAttrs (!config.node.pkgsReadOnly) ( + mkIf (!options.nixpkgs.pkgs.isDefined) { + # TODO: switch to nixpkgs.hostPlatform and make sure containers-imperative test still evaluates. + nixpkgs.system = hostPkgs.stdenv.hostPlatform.system; + } + ); }) testModuleArgs.config.extraBaseModules ]; diff --git a/nixos/lib/testing/run.nix b/nixos/lib/testing/run.nix index 0cd07d8afd2..9440c1acdfd 100644 --- a/nixos/lib/testing/run.nix +++ b/nixos/lib/testing/run.nix @@ -16,6 +16,15 @@ in ''; }; + rawTestDerivation = mkOption { + type = types.package; + description = mdDoc '' + Unfiltered version of `test`, for troubleshooting the test framework and `testBuildFailure` in the test framework's test suite. + This is not intended for general use. Use `test` instead. + ''; + internal = true; + }; + test = mkOption { type = types.package; # TODO: can the interactive driver be configured to access the network? @@ -29,25 +38,26 @@ in }; config = { - test = lib.lazyDerivation { # lazyDerivation improves performance when only passthru items and/or meta are used. - derivation = hostPkgs.stdenv.mkDerivation { - name = "vm-test-run-${config.name}"; + rawTestDerivation = hostPkgs.stdenv.mkDerivation { + name = "vm-test-run-${config.name}"; - requiredSystemFeatures = [ "kvm" "nixos-test" ]; + requiredSystemFeatures = [ "kvm" "nixos-test" ]; - buildCommand = '' - mkdir -p $out + buildCommand = '' + mkdir -p $out - # effectively mute the XMLLogger - export LOGFILE=/dev/null + # effectively mute the XMLLogger + export LOGFILE=/dev/null - ${config.driver}/bin/nixos-test-driver -o $out - ''; + ${config.driver}/bin/nixos-test-driver -o $out + ''; - passthru = config.passthru; + passthru = config.passthru; - meta = config.meta; - }; + meta = config.meta; + }; + test = lib.lazyDerivation { # lazyDerivation improves performance when only passthru items and/or meta are used. + derivation = config.rawTestDerivation; inherit (config) passthru meta; }; diff --git a/nixos/lib/utils.nix b/nixos/lib/utils.nix index 7ea9d6a5c71..e618cf2f861 100644 --- a/nixos/lib/utils.nix +++ b/nixos/lib/utils.nix @@ -177,6 +177,7 @@ rec { genJqSecretsReplacementSnippet' = attr: set: output: let secrets = recursiveGetAttrWithJqPrefix set attr; + stringOrDefault = str: def: if str == "" then def else str; in '' if [[ -h '${output}' ]]; then rm '${output}' @@ -195,10 +196,12 @@ rec { (attrNames secrets)) + "\n" + "${pkgs.jq}/bin/jq >'${output}' " - + lib.escapeShellArg (concatStringsSep - " | " - (imap1 (index: name: ''${name} = $ENV.secret${toString index}'') - (attrNames secrets))) + + lib.escapeShellArg (stringOrDefault + (concatStringsSep + " | " + (imap1 (index: name: ''${name} = $ENV.secret${toString index}'') + (attrNames secrets))) + ".") + '' <<'EOF' ${builtins.toJSON set} |