git.iko.soy/typeable/nixpkgs
on master help sign in
Merge pull request #149329 from marijanp/test-driver-restructuring

nixos/test-driver: make the test-driver a python package

commitb6bf1ca717b0ead86769102c2e94d591cc45ee9b authorJacek Galowicz <jacek@galowicz.de> dateFri 10 Dec 2021, 18:41 (5 years ago) parent5030bccab693de
8 files changed+498−464
nixos/lib/test-driver/default.nix+32 nixos/lib/test-driver/setup.py+13 nixos/lib/test-driver/test_driver/__init__.py+100 nixos/lib/test-driver/test_driver/driver.py+161 nixos/lib/test-driver/test_driver/logger.py+101 nixos/lib/test-driver/test-driver.py → nixos/lib/test-driver/test_driver/machine.py+22−402 nixos/lib/test-driver/test_driver/vlan.py+58 nixos/lib/testing-python.nix+11−62
nixos/lib/test-driver/default.nixnew file+32
@@ -0,0 +1,32 @@
1+{ lib
2+, python3Packages
3+, enableOCR ? false
4+, qemu_pkg ? qemu_test
5+, coreutils
6+, imagemagick_light
7+, libtiff
8+, netpbm
9+, qemu_test
10+, socat
11+, tesseract4
12+, vde2
13+}:
14+
15+python3Packages.buildPythonApplication rec {
16+ pname = "nixos-test-driver";
17+ version = "1.0";
18+ src = ./.;
19+
20+ propagatedBuildInputs = [ coreutils netpbm python3Packages.colorama python3Packages.ptpython qemu_pkg socat vde2 ]
21+ ++ (lib.optionals enableOCR [ imagemagick_light tesseract4 ]);
22+
23+ doCheck = true;
24+ checkInputs = with python3Packages; [ mypy pylint black ];
25+ checkPhase = ''
26+ mypy --disallow-untyped-defs \
27+ --no-implicit-optional \
28+ --ignore-missing-imports ${src}/test_driver
29+ pylint --errors-only ${src}/test_driver
30+ black --check --diff ${src}/test_driver
31+ '';
32+}
nixos/lib/test-driver/setup.pynew file+13
@@ -0,0 +1,13 @@
1+from setuptools import setup, find_packages
2+
3+setup(
4+ name="nixos-test-driver",
5+ version='1.0',
6+ packages=find_packages(),
7+ entry_points={
8+ "console_scripts": [
9+ "nixos-test-driver=test_driver:main",
10+ "generate-driver-symbols=test_driver:generate_driver_symbols"
11+ ]
12+ },
13+)
nixos/lib/test-driver/test_driver/__init__.pynew file+100
@@ -0,0 +1,100 @@
1+from pathlib import Path
2+import argparse
3+import ptpython.repl
4+import os
5+import time
6+
7+from test_driver.logger import rootlog
8+from test_driver.driver import Driver
9+
10+
11+class EnvDefault(argparse.Action):
12+ """An argpars Action that takes values from the specified
13+ environment variable as the flags default value.
14+ """
15+
16+ def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs): # type: ignore
17+ if not default and envvar:
18+ if envvar in os.environ:
19+ if nargs is not None and (nargs.isdigit() or nargs in ["*", "+"]):
20+ default = os.environ[envvar].split()
21+ else:
22+ default = os.environ[envvar]
23+ kwargs["help"] = (
24+ kwargs["help"] + f" (default from environment: {default})"
25+ )
26+ if required and default:
27+ required = False
28+ super(EnvDefault, self).__init__(
29+ default=default, required=required, nargs=nargs, **kwargs
30+ )
31+
32+ def __call__(self, parser, namespace, values, option_string=None): # type: ignore
33+ setattr(namespace, self.dest, values)
34+
35+
36+def main() -> None:
37+ arg_parser = argparse.ArgumentParser(prog="nixos-test-driver")
38+ arg_parser.add_argument(
39+ "-K",
40+ "--keep-vm-state",
41+ help="re-use a VM state coming from a previous run",
42+ action="store_true",
43+ )
44+ arg_parser.add_argument(
45+ "-I",
46+ "--interactive",
47+ help="drop into a python repl and run the tests interactively",
48+ action="store_true",
49+ )
50+ arg_parser.add_argument(
51+ "--start-scripts",
52+ metavar="START-SCRIPT",
53+ action=EnvDefault,
54+ envvar="startScripts",
55+ nargs="*",
56+ help="start scripts for participating virtual machines",
57+ )
58+ arg_parser.add_argument(
59+ "--vlans",
60+ metavar="VLAN",
61+ action=EnvDefault,
62+ envvar="vlans",
63+ nargs="*",
64+ help="vlans to span by the driver",
65+ )
66+ arg_parser.add_argument(
67+ "testscript",
68+ action=EnvDefault,
69+ envvar="testScript",
70+ help="the test script to run",
71+ type=Path,
72+ )
73+
74+ args = arg_parser.parse_args()
75+
76+ if not args.keep_vm_state:
77+ rootlog.info("Machine state will be reset. To keep it, pass --keep-vm-state")
78+
79+ with Driver(
80+ args.start_scripts, args.vlans, args.testscript.read_text(), args.keep_vm_state
81+ ) as driver:
82+ if args.interactive:
83+ ptpython.repl.embed(driver.test_symbols(), {})
84+ else:
85+ tic = time.time()
86+ driver.run_tests()
87+ toc = time.time()
88+ rootlog.info(f"test script finished in {(toc-tic):.2f}s")
89+
90+
91+def generate_driver_symbols() -> None:
92+ """
93+ This generates a file with symbols of the test-driver code that can be used
94+ in user's test scripts. That list is then used by pyflakes to lint those
95+ scripts.
96+ """
97+ d = Driver([], [], "")
98+ test_symbols = d.test_symbols()
99+ with open("driver-symbols", "w") as fp:
100+ fp.write(",".join(test_symbols.keys()))
nixos/lib/test-driver/test_driver/driver.pynew file+161
@@ -0,0 +1,161 @@
1+from contextlib import contextmanager
2+from pathlib import Path
3+from typing import Any, Dict, Iterator, List
4+import os
5+import tempfile
6+
7+from test_driver.logger import rootlog
8+from test_driver.machine import Machine, NixStartScript, retry
9+from test_driver.vlan import VLan
10+
11+
12+class Driver:
13+ """A handle to the driver that sets up the environment
14+ and runs the tests"""
15+
16+ tests: str
17+ vlans: List[VLan]
18+ machines: List[Machine]
19+
20+ def __init__(
21+ self,
22+ start_scripts: List[str],
23+ vlans: List[int],
24+ tests: str,
25+ keep_vm_state: bool = False,
26+ ):
27+ self.tests = tests
28+
29+ tmp_dir = Path(os.environ.get("TMPDIR", tempfile.gettempdir()))
30+ tmp_dir.mkdir(mode=0o700, exist_ok=True)
31+
32+ with rootlog.nested("start all VLans"):
33+ self.vlans = [VLan(nr, tmp_dir) for nr in vlans]
34+
35+ def cmd(scripts: List[str]) -> Iterator[NixStartScript]:
36+ for s in scripts:
37+ yield NixStartScript(s)
38+
39+ self.machines = [
40+ Machine(
41+ start_command=cmd,
42+ keep_vm_state=keep_vm_state,
43+ name=cmd.machine_name,
44+ tmp_dir=tmp_dir,
45+ )
46+ for cmd in cmd(start_scripts)
47+ ]
48+
49+ def __enter__(self) -> "Driver":
50+ return self
51+
52+ def __exit__(self, *_: Any) -> None:
53+ with rootlog.nested("cleanup"):
54+ for machine in self.machines:
55+ machine.release()
56+
57+ def subtest(self, name: str) -> Iterator[None]:
58+ """Group logs under a given test name"""
59+ with rootlog.nested(name):
60+ try:
61+ yield
62+ return True
63+ except Exception as e:
64+ rootlog.error(f'Test "{name}" failed with error: "{e}"')
65+ raise e
66+
67+ def test_symbols(self) -> Dict[str, Any]:
68+ @contextmanager
69+ def subtest(name: str) -> Iterator[None]:
70+ return self.subtest(name)
71+
72+ general_symbols = dict(
73+ start_all=self.start_all,
74+ test_script=self.test_script,
75+ machines=self.machines,
76+ vlans=self.vlans,
77+ driver=self,
78+ log=rootlog,
79+ os=os,
80+ create_machine=self.create_machine,
81+ subtest=subtest,
82+ run_tests=self.run_tests,
83+ join_all=self.join_all,
84+ retry=retry,
85+ serial_stdout_off=self.serial_stdout_off,
86+ serial_stdout_on=self.serial_stdout_on,
87+ Machine=Machine, # for typing
88+ )
89+ machine_symbols = {m.name: m for m in self.machines}
90+ # If there's exactly one machine, make it available under the name
91+ # "machine", even if it's not called that.
92+ if len(self.machines) == 1:
93+ (machine_symbols["machine"],) = self.machines
94+ vlan_symbols = {
95+ f"vlan{v.nr}": self.vlans[idx] for idx, v in enumerate(self.vlans)
96+ }
97+ print(
98+ "additionally exposed symbols:\n "
99+ + ", ".join(map(lambda m: m.name, self.machines))
100+ + ",\n "
101+ + ", ".join(map(lambda v: f"vlan{v.nr}", self.vlans))
102+ + ",\n "
103+ + ", ".join(list(general_symbols.keys()))
104+ )
105+ return {**general_symbols, **machine_symbols, **vlan_symbols}
106+
107+ def test_script(self) -> None:
108+ """Run the test script"""
109+ with rootlog.nested("run the VM test script"):
110+ symbols = self.test_symbols() # call eagerly
111+ exec(self.tests, symbols, None)
112+
113+ def run_tests(self) -> None:
114+ """Run the test script (for non-interactive test runs)"""
115+ self.test_script()
116+ # TODO: Collect coverage data
117+ for machine in self.machines:
118+ if machine.is_up():
119+ machine.execute("sync")
120+
121+ def start_all(self) -> None:
122+ """Start all machines"""
123+ with rootlog.nested("start all VMs"):
124+ for machine in self.machines:
125+ machine.start()
126+
127+ def join_all(self) -> None:
128+ """Wait for all machines to shut down"""
129+ with rootlog.nested("wait for all VMs to finish"):
130+ for machine in self.machines:
131+ machine.wait_for_shutdown()
132+
133+ def create_machine(self, args: Dict[str, Any]) -> Machine:
134+ rootlog.warning(
135+ "Using legacy create_machine(), please instantiate the"
136+ "Machine class directly, instead"
137+ )
138+ tmp_dir = Path(os.environ.get("TMPDIR", tempfile.gettempdir()))
139+ tmp_dir.mkdir(mode=0o700, exist_ok=True)
140+
141+ if args.get("startCommand"):
142+ start_command: str = args.get("startCommand", "")
143+ cmd = NixStartScript(start_command)
144+ name = args.get("name", cmd.machine_name)
145+ else:
146+ cmd = Machine.create_startcommand(args) # type: ignore
147+ name = args.get("name", "machine")
148+
149+ return Machine(
150+ tmp_dir=tmp_dir,
151+ start_command=cmd,
152+ name=name,
153+ keep_vm_state=args.get("keep_vm_state", False),
154+ allow_reboot=args.get("allow_reboot", False),
155+ )
156+
157+ def serial_stdout_on(self) -> None:
158+ rootlog._print_serial_logs = True
159+
160+ def serial_stdout_off(self) -> None:
161+ rootlog._print_serial_logs = False
nixos/lib/test-driver/test_driver/logger.pynew file+101
@@ -0,0 +1,101 @@
1+from colorama import Style
2+from contextlib import contextmanager
3+from typing import Any, Dict, Iterator
4+from queue import Queue, Empty
5+from xml.sax.saxutils import XMLGenerator
6+import codecs
7+import os
8+import sys
9+import time
10+import unicodedata
11+
12+
13+class Logger:
14+ def __init__(self) -> None:
15+ self.logfile = os.environ.get("LOGFILE", "/dev/null")
16+ self.logfile_handle = codecs.open(self.logfile, "wb")
17+ self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8")
18+ self.queue: "Queue[Dict[str, str]]" = Queue()
19+
20+ self.xml.startDocument()
21+ self.xml.startElement("logfile", attrs={})
22+
23+ self._print_serial_logs = True
24+
25+ @staticmethod
26+ def _eprint(*args: object, **kwargs: Any) -> None:
27+ print(*args, file=sys.stderr, **kwargs)
28+
29+ def close(self) -> None:
30+ self.xml.endElement("logfile")
31+ self.xml.endDocument()
32+ self.logfile_handle.close()
33+
34+ def sanitise(self, message: str) -> str:
35+ return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
36+
37+ def maybe_prefix(self, message: str, attributes: Dict[str, str]) -> str:
38+ if "machine" in attributes:
39+ return "{}: {}".format(attributes["machine"], message)
40+ return message
41+
42+ def log_line(self, message: str, attributes: Dict[str, str]) -> None:
43+ self.xml.startElement("line", attributes)
44+ self.xml.characters(message)
45+ self.xml.endElement("line")
46+
47+ def info(self, *args, **kwargs) -> None: # type: ignore
48+ self.log(*args, **kwargs)
49+
50+ def warning(self, *args, **kwargs) -> None: # type: ignore
51+ self.log(*args, **kwargs)
52+
53+ def error(self, *args, **kwargs) -> None: # type: ignore
54+ self.log(*args, **kwargs)
55+ sys.exit(1)
56+
57+ def log(self, message: str, attributes: Dict[str, str] = {}) -> None:
58+ self._eprint(self.maybe_prefix(message, attributes))
59+ self.drain_log_queue()
60+ self.log_line(message, attributes)
61+
62+ def log_serial(self, message: str, machine: str) -> None:
63+ self.enqueue({"msg": message, "machine": machine, "type": "serial"})
64+ if self._print_serial_logs:
65+ self._eprint(
66+ Style.DIM + "{} # {}".format(machine, message) + Style.RESET_ALL
67+ )
68+
69+ def enqueue(self, item: Dict[str, str]) -> None:
70+ self.queue.put(item)
71+
72+ def drain_log_queue(self) -> None:
73+ try:
74+ while True:
75+ item = self.queue.get_nowait()
76+ msg = self.sanitise(item["msg"])
77+ del item["msg"]
78+ self.log_line(msg, item)
79+ except Empty:
80+ pass
81+
82+ @contextmanager
83+ def nested(self, message: str, attributes: Dict[str, str] = {}) -> Iterator[None]:
84+ self._eprint(self.maybe_prefix(message, attributes))
85+
86+ self.xml.startElement("nest", attrs={})
87+ self.xml.startElement("head", attributes)
88+ self.xml.characters(message)
89+ self.xml.endElement("head")
90+
91+ tic = time.time()
92+ self.drain_log_queue()
93+ yield
94+ self.drain_log_queue()
95+ toc = time.time()
96+ self.log("(finished: {}, in {:.2f} seconds)".format(message, toc - tic))
97+
98+ self.xml.endElement("nest")
99+
100+
101+rootlog = Logger()
nixos/lib/test-driver/test-driver.py → nixos/lib/test-driver/test_driver/machine.pyrenamed+22−402
@@ -1,19 +1,11 @@
1−#! /somewhere/python3
2−from contextlib import contextmanager, _GeneratorContextManager
3−from queue import Queue, Empty
4−from typing import Tuple, Any, Callable, Dict, Iterator, Optional, List, Iterable
5−from xml.sax.saxutils import XMLGenerator
6−from colorama import Style
1+from contextlib import _GeneratorContextManager
72from pathlib import Path
8−import queue
9−import io
10−import threading
11−import argparse
3+from queue import Queue
4+from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
125import base64
13−import codecs
6+import io
147import os
15−import ptpython.repl
16−import pty
8+import queue
179import re
1810import shlex
1911import shutil
@@ -21,8 +13,10 @@ import socket
2113import subprocess
2214import sys
2315import tempfile
16+import threading
2417import time
25−import unicodedata
18+
19+from test_driver.logger import rootlog
2620
2721CHAR_TO_KEY = {
2822 "A": "shift-a",
@@ -88,115 +82,10 @@ CHAR_TO_KEY = {
8882}
8983
9084
91−class Logger:
92− def __init__(self) -> None:
93− self.logfile = os.environ.get("LOGFILE", "/dev/null")
94− self.logfile_handle = codecs.open(self.logfile, "wb")
95− self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8")
96− self.queue: "Queue[Dict[str, str]]" = Queue()
97−
98− self.xml.startDocument()
99− self.xml.startElement("logfile", attrs={})
100−
101− self._print_serial_logs = True
102−
103− @staticmethod
104− def _eprint(*args: object, **kwargs: Any) -> None:
105− print(*args, file=sys.stderr, **kwargs)
106−
107− def close(self) -> None:
108− self.xml.endElement("logfile")
109− self.xml.endDocument()
110− self.logfile_handle.close()
111−
112− def sanitise(self, message: str) -> str:
113− return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
114−
115− def maybe_prefix(self, message: str, attributes: Dict[str, str]) -> str:
116− if "machine" in attributes:
117− return "{}: {}".format(attributes["machine"], message)
118− return message
119−
120− def log_line(self, message: str, attributes: Dict[str, str]) -> None:
121− self.xml.startElement("line", attributes)
122− self.xml.characters(message)
123− self.xml.endElement("line")
124−
125− def info(self, *args, **kwargs) -> None: # type: ignore
126− self.log(*args, **kwargs)
127−
128− def warning(self, *args, **kwargs) -> None: # type: ignore
129− self.log(*args, **kwargs)
130−
131− def error(self, *args, **kwargs) -> None: # type: ignore
132− self.log(*args, **kwargs)
133− sys.exit(1)
134−
135− def log(self, message: str, attributes: Dict[str, str] = {}) -> None:
136− self._eprint(self.maybe_prefix(message, attributes))
137− self.drain_log_queue()
138− self.log_line(message, attributes)
139−
140− def log_serial(self, message: str, machine: str) -> None:
141− self.enqueue({"msg": message, "machine": machine, "type": "serial"})
142− if self._print_serial_logs:
143− self._eprint(
144− Style.DIM + "{} # {}".format(machine, message) + Style.RESET_ALL
145− )
146−
147− def enqueue(self, item: Dict[str, str]) -> None:
148− self.queue.put(item)
149−
150− def drain_log_queue(self) -> None:
151− try:
152− while True:
153− item = self.queue.get_nowait()
154− msg = self.sanitise(item["msg"])
155− del item["msg"]
156− self.log_line(msg, item)
157− except Empty:
158− pass
159−
160− @contextmanager
161− def nested(self, message: str, attributes: Dict[str, str] = {}) -> Iterator[None]:
162− self._eprint(self.maybe_prefix(message, attributes))
163−
164− self.xml.startElement("nest", attrs={})
165− self.xml.startElement("head", attributes)
166− self.xml.characters(message)
167− self.xml.endElement("head")
168−
169− tic = time.time()
170− self.drain_log_queue()
171− yield
172− self.drain_log_queue()
173− toc = time.time()
174− self.log("(finished: {}, in {:.2f} seconds)".format(message, toc - tic))
175−
176− self.xml.endElement("nest")
177−
178−
179−rootlog = Logger()
180−
181−
18285def make_command(args: list) -> str:
18386 return " ".join(map(shlex.quote, (map(str, args))))
18487
18588
186−def retry(fn: Callable, timeout: int = 900) -> None:
187− """Call the given function repeatedly, with 1 second intervals,
188− until it returns True or a timeout is reached.
189− """
190−
191− for _ in range(timeout):
192− if fn(False):
193− return
194− time.sleep(1)
195−
196− if not fn(True):
197− raise Exception(f"action timed out after {timeout} seconds")
198−
199−
20089def _perform_ocr_on_screenshot(
20190 screenshot_path: str, model_ids: Iterable[int]
20291) -> List[str]:
@@ -228,6 +117,20 @@ def _perform_ocr_on_screenshot(
228117 return model_results
229118
230119
120+def retry(fn: Callable, timeout: int = 900) -> None:
121+ """Call the given function repeatedly, with 1 second intervals,
122+ until it returns True or a timeout is reached.
123+ """
124+
125+ for _ in range(timeout):
126+ if fn(False):
127+ return
128+ time.sleep(1)
129+
130+ if not fn(True):
131+ raise Exception(f"action timed out after {timeout} seconds")
132+
133+
231134class StartCommand:
232135 """The Base Start Command knows how to append the necesary
233136 runtime qemu options as determined by a particular test driver
@@ -1066,286 +969,3 @@ class Machine:
1066969 self.shell.close()
1067970 self.monitor.close()
1068971 self.serial_thread.join()
1069−
1070−
1071−class VLan:
1072− """This class handles a VLAN that the run-vm scripts identify via its
1073− number handles. The network's lifetime equals the object's lifetime.
1074− """
1075−
1076− nr: int
1077− socket_dir: Path
1078−
1079− process: subprocess.Popen
1080− pid: int
1081− fd: io.TextIOBase
1082−
1083− def __repr__(self) -> str:
1084− return f"<Vlan Nr. {self.nr}>"
1085−
1086− def __init__(self, nr: int, tmp_dir: Path):
1087− self.nr = nr
1088− self.socket_dir = tmp_dir / f"vde{self.nr}.ctl"
1089−
1090− # TODO: don't side-effect environment here
1091− os.environ[f"QEMU_VDE_SOCKET_{self.nr}"] = str(self.socket_dir)
1092−
1093− rootlog.info("start vlan")
1094− pty_master, pty_slave = pty.openpty()
1095−
1096− self.process = subprocess.Popen(
1097− ["vde_switch", "-s", self.socket_dir, "--dirmode", "0700"],
1098− stdin=pty_slave,
1099− stdout=subprocess.PIPE,
1100− stderr=subprocess.PIPE,
1101− shell=False,
1102− )
1103− self.pid = self.process.pid
1104− self.fd = os.fdopen(pty_master, "w")
1105− self.fd.write("version\n")
1106−
1107− # TODO: perl version checks if this can be read from
1108− # an if not, dies. we could hang here forever. Fix it.
1109− assert self.process.stdout is not None
1110− self.process.stdout.readline()
1111− if not (self.socket_dir / "ctl").exists():
1112− rootlog.error("cannot start vde_switch")
1113−
1114− rootlog.info(f"running vlan (pid {self.pid})")
1115−
1116− def __del__(self) -> None:
1117− rootlog.info(f"kill vlan (pid {self.pid})")
1118− self.fd.close()
1119− self.process.terminate()
1120−
1121−
1122−class Driver:
1123− """A handle to the driver that sets up the environment
1124− and runs the tests"""
1125−
1126− tests: str
1127− vlans: List[VLan]
1128− machines: List[Machine]
1129−
1130− def __init__(
1131− self,
1132− start_scripts: List[str],
1133− vlans: List[int],
1134− tests: str,
1135− keep_vm_state: bool = False,
1136− ):
1137− self.tests = tests
1138−
1139− tmp_dir = Path(os.environ.get("TMPDIR", tempfile.gettempdir()))
1140− tmp_dir.mkdir(mode=0o700, exist_ok=True)
1141−
1142− with rootlog.nested("start all VLans"):
1143− self.vlans = [VLan(nr, tmp_dir) for nr in vlans]
1144−
1145− def cmd(scripts: List[str]) -> Iterator[NixStartScript]:
1146− for s in scripts:
1147− yield NixStartScript(s)
1148−
1149− self.machines = [
1150− Machine(
1151− start_command=cmd,
1152− keep_vm_state=keep_vm_state,
1153− name=cmd.machine_name,
1154− tmp_dir=tmp_dir,
1155− )
1156− for cmd in cmd(start_scripts)
1157− ]
1158−
1159− def __enter__(self) -> "Driver":
1160− return self
1161−
1162− def __exit__(self, *_: Any) -> None:
1163− with rootlog.nested("cleanup"):
1164− for machine in self.machines:
1165− machine.release()
1166−
1167− def subtest(self, name: str) -> Iterator[None]:
1168− """Group logs under a given test name"""
1169− with rootlog.nested(name):
1170− try:
1171− yield
1172− return True
1173− except Exception as e:
1174− rootlog.error(f'Test "{name}" failed with error: "{e}"')
1175− raise e
1176−
1177− def test_symbols(self) -> Dict[str, Any]:
1178− @contextmanager
1179− def subtest(name: str) -> Iterator[None]:
1180− return self.subtest(name)
1181−
1182− general_symbols = dict(
1183− start_all=self.start_all,
1184− test_script=self.test_script,
1185− machines=self.machines,
1186− vlans=self.vlans,
1187− driver=self,
1188− log=rootlog,
1189− os=os,
1190− create_machine=self.create_machine,
1191− subtest=subtest,
1192− run_tests=self.run_tests,
1193− join_all=self.join_all,
1194− retry=retry,
1195− serial_stdout_off=self.serial_stdout_off,
1196− serial_stdout_on=self.serial_stdout_on,
1197− Machine=Machine, # for typing
1198− )
1199− machine_symbols = {m.name: m for m in self.machines}
1200− # If there's exactly one machine, make it available under the name
1201− # "machine", even if it's not called that.
1202− if len(self.machines) == 1:
1203− (machine_symbols["machine"],) = self.machines
1204− vlan_symbols = {
1205− f"vlan{v.nr}": self.vlans[idx] for idx, v in enumerate(self.vlans)
1206− }
1207− print(
1208− "additionally exposed symbols:\n "
1209− + ", ".join(map(lambda m: m.name, self.machines))
1210− + ",\n "
1211− + ", ".join(map(lambda v: f"vlan{v.nr}", self.vlans))
1212− + ",\n "
1213− + ", ".join(list(general_symbols.keys()))
1214− )
1215− return {**general_symbols, **machine_symbols, **vlan_symbols}
1216−
1217− def test_script(self) -> None:
1218− """Run the test script"""
1219− with rootlog.nested("run the VM test script"):
1220− symbols = self.test_symbols() # call eagerly
1221− exec(self.tests, symbols, None)
1222−
1223− def run_tests(self) -> None:
1224− """Run the test script (for non-interactive test runs)"""
1225− self.test_script()
1226− # TODO: Collect coverage data
1227− for machine in self.machines:
1228− if machine.is_up():
1229− machine.execute("sync")
1230−
1231− def start_all(self) -> None:
1232− """Start all machines"""
1233− with rootlog.nested("start all VMs"):
1234− for machine in self.machines:
1235− machine.start()
1236−
1237− def join_all(self) -> None:
1238− """Wait for all machines to shut down"""
1239− with rootlog.nested("wait for all VMs to finish"):
1240− for machine in self.machines:
1241− machine.wait_for_shutdown()
1242−
1243− def create_machine(self, args: Dict[str, Any]) -> Machine:
1244− rootlog.warning(
1245− "Using legacy create_machine(), please instantiate the"
1246− "Machine class directly, instead"
1247− )
1248− tmp_dir = Path(os.environ.get("TMPDIR", tempfile.gettempdir()))
1249− tmp_dir.mkdir(mode=0o700, exist_ok=True)
1250−
1251− if args.get("startCommand"):
1252− start_command: str = args.get("startCommand", "")
1253− cmd = NixStartScript(start_command)
1254− name = args.get("name", cmd.machine_name)
1255− else:
1256− cmd = Machine.create_startcommand(args) # type: ignore
1257− name = args.get("name", "machine")
1258−
1259− return Machine(
1260− tmp_dir=tmp_dir,
1261− start_command=cmd,
1262− name=name,
1263− keep_vm_state=args.get("keep_vm_state", False),
1264− allow_reboot=args.get("allow_reboot", False),
1265− )
1266−
1267− def serial_stdout_on(self) -> None:
1268− rootlog._print_serial_logs = True
1269−
1270− def serial_stdout_off(self) -> None:
1271− rootlog._print_serial_logs = False
1272−
1273−
1274−class EnvDefault(argparse.Action):
1275− """An argpars Action that takes values from the specified
1276− environment variable as the flags default value.
1277− """
1278−
1279− def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs): # type: ignore
1280− if not default and envvar:
1281− if envvar in os.environ:
1282− if nargs is not None and (nargs.isdigit() or nargs in ["*", "+"]):
1283− default = os.environ[envvar].split()
1284− else:
1285− default = os.environ[envvar]
1286− kwargs["help"] = (
1287− kwargs["help"] + f" (default from environment: {default})"
1288− )
1289− if required and default:
1290− required = False
1291− super(EnvDefault, self).__init__(
1292− default=default, required=required, nargs=nargs, **kwargs
1293− )
1294−
1295− def __call__(self, parser, namespace, values, option_string=None): # type: ignore
1296− setattr(namespace, self.dest, values)
1297−
1298−
1299−if __name__ == "__main__":
1300− arg_parser = argparse.ArgumentParser(prog="nixos-test-driver")
1301− arg_parser.add_argument(
1302− "-K",
1303− "--keep-vm-state",
1304− help="re-use a VM state coming from a previous run",
1305− action="store_true",
1306− )
1307− arg_parser.add_argument(
1308− "-I",
1309− "--interactive",
1310− help="drop into a python repl and run the tests interactively",
1311− action="store_true",
1312− )
1313− arg_parser.add_argument(
1314− "--start-scripts",
1315− metavar="START-SCRIPT",
1316− action=EnvDefault,
1317− envvar="startScripts",
1318− nargs="*",
1319− help="start scripts for participating virtual machines",
1320− )
1321− arg_parser.add_argument(
1322− "--vlans",
1323− metavar="VLAN",
1324− action=EnvDefault,
1325− envvar="vlans",
1326− nargs="*",
1327− help="vlans to span by the driver",
1328− )
1329− arg_parser.add_argument(
1330− "testscript",
1331− action=EnvDefault,
1332− envvar="testScript",
1333− help="the test script to run",
1334− type=Path,
1335− )
1336−
1337− args = arg_parser.parse_args()
1338−
1339− if not args.keep_vm_state:
1340− rootlog.info("Machine state will be reset. To keep it, pass --keep-vm-state")
1341−
1342− with Driver(
1343− args.start_scripts, args.vlans, args.testscript.read_text(), args.keep_vm_state
1344− ) as driver:
1345− if args.interactive:
1346− ptpython.repl.embed(driver.test_symbols(), {})
1347− else:
1348− tic = time.time()
1349− driver.run_tests()
1350− toc = time.time()
1351− rootlog.info(f"test script finished in {(toc-tic):.2f}s")
nixos/lib/test-driver/test_driver/vlan.pynew file+58
@@ -0,0 +1,58 @@
1+from pathlib import Path
2+import io
3+import os
4+import pty
5+import subprocess
6+
7+from test_driver.logger import rootlog
8+
9+
10+class VLan:
11+ """This class handles a VLAN that the run-vm scripts identify via its
12+ number handles. The network's lifetime equals the object's lifetime.
13+ """
14+
15+ nr: int
16+ socket_dir: Path
17+
18+ process: subprocess.Popen
19+ pid: int
20+ fd: io.TextIOBase
21+
22+ def __repr__(self) -> str:
23+ return f"<Vlan Nr. {self.nr}>"
24+
25+ def __init__(self, nr: int, tmp_dir: Path):
26+ self.nr = nr
27+ self.socket_dir = tmp_dir / f"vde{self.nr}.ctl"
28+
29+ # TODO: don't side-effect environment here
30+ os.environ[f"QEMU_VDE_SOCKET_{self.nr}"] = str(self.socket_dir)
31+
32+ rootlog.info("start vlan")
33+ pty_master, pty_slave = pty.openpty()
34+
35+ self.process = subprocess.Popen(
36+ ["vde_switch", "-s", self.socket_dir, "--dirmode", "0700"],
37+ stdin=pty_slave,
38+ stdout=subprocess.PIPE,
39+ stderr=subprocess.PIPE,
40+ shell=False,
41+ )
42+ self.pid = self.process.pid
43+ self.fd = os.fdopen(pty_master, "w")
44+ self.fd.write("version\n")
45+
46+ # TODO: perl version checks if this can be read from
47+ # an if not, dies. we could hang here forever. Fix it.
48+ assert self.process.stdout is not None
49+ self.process.stdout.readline()
50+ if not (self.socket_dir / "ctl").exists():
51+ rootlog.error("cannot start vde_switch")
52+
53+ rootlog.info(f"running vlan (pid {self.pid})")
54+
55+ def __del__(self) -> None:
56+ rootlog.info(f"kill vlan (pid {self.pid})")
57+ self.fd.close()
58+ self.process.terminate()
nixos/lib/testing-python.nix+11−62
@@ -16,65 +16,6 @@ rec {
1616
1717 inherit pkgs;
1818
19− # Reifies and correctly wraps the python test driver for
20− # the respective qemu version and with or without ocr support
21− pythonTestDriver = {
22− qemu_pkg ? pkgs.qemu_test
23− , enableOCR ? false
24− }:
25− let
26− name = "nixos-test-driver";
27− testDriverScript = ./test-driver/test-driver.py;
28− ocrProg = tesseract4.override { enableLanguages = [ "eng" ]; };
29− imagemagick_tiff = imagemagick_light.override { inherit libtiff; };
30− in stdenv.mkDerivation {
31− inherit name;
32−
33− nativeBuildInputs = [ makeWrapper ];
34− buildInputs = [ (python3.withPackages (p: [ p.ptpython p.colorama ])) ];
35− checkInputs = with python3Packages; [ pylint black mypy ];
36−
37− dontUnpack = true;
38−
39− preferLocalBuild = true;
40−
41− buildPhase = ''
42− python <<EOF
43− from pydoc import importfile
44− with open('driver-symbols', 'w') as fp:
45− t = importfile('${testDriverScript}')
46− d = t.Driver([],[],"")
47− test_symbols = d.test_symbols()
48− fp.write(','.join(test_symbols.keys()))
49− EOF
50− '';
51−
52− doCheck = true;
53− checkPhase = ''
54− mypy --disallow-untyped-defs \
55− --no-implicit-optional \
56− --ignore-missing-imports ${testDriverScript}
57− pylint --errors-only ${testDriverScript}
58− black --check --diff ${testDriverScript}
59− '';
60−
61− installPhase =
62− ''
63− mkdir -p $out/bin
64− cp ${testDriverScript} $out/bin/nixos-test-driver
65− chmod u+x $out/bin/nixos-test-driver
66− # TODO: copy user script part into this file (append)
67−
68− wrapProgram $out/bin/nixos-test-driver \
69− --argv0 ${name} \
70− --prefix PATH : "${lib.makeBinPath [ qemu_pkg vde2 netpbm coreutils socat ]}" \
71− ${lib.optionalString enableOCR
72− "--prefix PATH : '${ocrProg}/bin:${imagemagick_tiff}/bin'"} \
73−
74− install -m 0644 -vD driver-symbols $out/nix-support/driver-symbols
75− '';
76− };
77−
7819 # Run an automated test suite in the given virtual network.
7920 runTests = { driver, pos }:
8021 stdenv.mkDerivation {
@@ -112,8 +53,15 @@ rec {
11253 , passthru ? {}
11354 }:
11455 let
115− # FIXME: get this pkg from the module system
116− testDriver = pythonTestDriver { inherit qemu_pkg enableOCR;};
56+ # Reifies and correctly wraps the python test driver for
57+ # the respective qemu version and with or without ocr support
58+ testDriver = pkgs.callPackage ./test-driver {
59+ inherit enableOCR;
60+ qemu_pkg = qemu_test;
61+ imagemagick_light = imagemagick_light.override { inherit libtiff; };
62+ tesseract4 = tesseract4.override { enableLanguages = [ "eng" ]; };
63+ };
64+
11765
11866 testDriverName =
11967 let
@@ -178,10 +126,11 @@ rec {
178126 echo -n "$testScript" > $out/test-script
179127 ln -s ${testDriver}/bin/nixos-test-driver $out/bin/nixos-test-driver
180128
129+ ${testDriver}/bin/generate-driver-symbols
181130 ${lib.optionalString (!skipLint) ''
182131 PYFLAKES_BUILTINS="$(
183132 echo -n ${lib.escapeShellArg (lib.concatStringsSep "," nodeHostNames)},
184− < ${lib.escapeShellArg "${testDriver}/nix-support/driver-symbols"}
133+ < ${lib.escapeShellArg "driver-symbols"}
185134 )" ${python3Packages.pyflakes}/bin/pyflakes $out/test-script
186135 ''}
187136