2021-12-19 01:06:50 +00:00
|
|
|
import os
|
2023-05-24 13:37:59 +00:00
|
|
|
import re
|
2023-11-16 04:20:00 +00:00
|
|
|
import signal
|
2021-12-19 01:06:50 +00:00
|
|
|
import tempfile
|
2023-11-16 04:20:00 +00:00
|
|
|
import threading
|
2023-10-09 19:29:22 +00:00
|
|
|
from contextlib import contextmanager
|
|
|
|
from pathlib import Path
|
|
|
|
from typing import Any, Callable, ContextManager, Dict, Iterator, List, Optional, Union
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2024-04-21 15:54:59 +00:00
|
|
|
from colorama import Fore, Style
|
|
|
|
|
2021-12-19 01:06:50 +00:00
|
|
|
from test_driver.logger import rootlog
|
|
|
|
from test_driver.machine import Machine, NixStartScript, retry
|
2022-01-13 20:06:32 +00:00
|
|
|
from test_driver.polling_condition import PollingCondition
|
2023-10-09 19:29:22 +00:00
|
|
|
from test_driver.vlan import VLan
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2024-02-29 20:09:43 +00:00
|
|
|
SENTINEL = object()
|
|
|
|
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2022-01-27 00:19:43 +00:00
|
|
|
def get_tmp_dir() -> Path:
|
|
|
|
"""Returns a temporary directory that is defined by TMPDIR, TEMP, TMP or CWD
|
|
|
|
Raises an exception in case the retrieved temporary directory is not writeable
|
|
|
|
See https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir
|
|
|
|
"""
|
|
|
|
tmp_dir = Path(tempfile.gettempdir())
|
|
|
|
tmp_dir.mkdir(mode=0o700, exist_ok=True)
|
|
|
|
if not tmp_dir.is_dir():
|
|
|
|
raise NotADirectoryError(
|
2023-01-11 07:51:40 +00:00
|
|
|
f"The directory defined by TMPDIR, TEMP, TMP or CWD: {tmp_dir} is not a directory"
|
2022-01-27 00:19:43 +00:00
|
|
|
)
|
|
|
|
if not os.access(tmp_dir, os.W_OK):
|
|
|
|
raise PermissionError(
|
2023-01-11 07:51:40 +00:00
|
|
|
f"The directory defined by TMPDIR, TEMP, TMP, or CWD: {tmp_dir} is not writeable"
|
2022-01-27 00:19:43 +00:00
|
|
|
)
|
|
|
|
return tmp_dir
|
|
|
|
|
|
|
|
|
2023-05-24 13:37:59 +00:00
|
|
|
def pythonize_name(name: str) -> str:
|
|
|
|
return re.sub(r"^[^A-z_]|[^A-z0-9_]", "_", name)
|
|
|
|
|
|
|
|
|
2021-12-19 01:06:50 +00:00
|
|
|
class Driver:
|
|
|
|
"""A handle to the driver that sets up the environment
|
|
|
|
and runs the tests"""
|
|
|
|
|
|
|
|
tests: str
|
|
|
|
vlans: List[VLan]
|
|
|
|
machines: List[Machine]
|
2022-01-13 20:06:32 +00:00
|
|
|
polling_conditions: List[PollingCondition]
|
2023-11-16 04:20:00 +00:00
|
|
|
global_timeout: int
|
|
|
|
race_timer: threading.Timer
|
2021-12-19 01:06:50 +00:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
start_scripts: List[str],
|
|
|
|
vlans: List[int],
|
|
|
|
tests: str,
|
2022-01-27 00:19:43 +00:00
|
|
|
out_dir: Path,
|
2021-12-19 01:06:50 +00:00
|
|
|
keep_vm_state: bool = False,
|
2023-11-16 04:20:00 +00:00
|
|
|
global_timeout: int = 24 * 60 * 60 * 7,
|
2021-12-19 01:06:50 +00:00
|
|
|
):
|
|
|
|
self.tests = tests
|
2022-01-27 00:19:43 +00:00
|
|
|
self.out_dir = out_dir
|
2023-11-16 04:20:00 +00:00
|
|
|
self.global_timeout = global_timeout
|
|
|
|
self.race_timer = threading.Timer(global_timeout, self.terminate_test)
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2022-01-27 00:19:43 +00:00
|
|
|
tmp_dir = get_tmp_dir()
|
2021-12-19 01:06:50 +00:00
|
|
|
|
|
|
|
with rootlog.nested("start all VLans"):
|
2022-03-30 09:31:56 +00:00
|
|
|
vlans = list(set(vlans))
|
2021-12-19 01:06:50 +00:00
|
|
|
self.vlans = [VLan(nr, tmp_dir) for nr in vlans]
|
|
|
|
|
|
|
|
def cmd(scripts: List[str]) -> Iterator[NixStartScript]:
|
|
|
|
for s in scripts:
|
|
|
|
yield NixStartScript(s)
|
|
|
|
|
2022-01-13 20:06:32 +00:00
|
|
|
self.polling_conditions = []
|
|
|
|
|
2021-12-19 01:06:50 +00:00
|
|
|
self.machines = [
|
|
|
|
Machine(
|
|
|
|
start_command=cmd,
|
|
|
|
keep_vm_state=keep_vm_state,
|
|
|
|
name=cmd.machine_name,
|
|
|
|
tmp_dir=tmp_dir,
|
2022-01-13 20:06:32 +00:00
|
|
|
callbacks=[self.check_polling_conditions],
|
2022-01-27 00:19:43 +00:00
|
|
|
out_dir=self.out_dir,
|
2021-12-19 01:06:50 +00:00
|
|
|
)
|
|
|
|
for cmd in cmd(start_scripts)
|
|
|
|
]
|
|
|
|
|
|
|
|
def __enter__(self) -> "Driver":
|
|
|
|
return self
|
|
|
|
|
|
|
|
def __exit__(self, *_: Any) -> None:
|
|
|
|
with rootlog.nested("cleanup"):
|
2023-11-16 04:20:00 +00:00
|
|
|
self.race_timer.cancel()
|
2021-12-19 01:06:50 +00:00
|
|
|
for machine in self.machines:
|
|
|
|
machine.release()
|
|
|
|
|
|
|
|
def subtest(self, name: str) -> Iterator[None]:
|
|
|
|
"""Group logs under a given test name"""
|
2022-04-15 01:41:22 +00:00
|
|
|
with rootlog.nested("subtest: " + name):
|
2021-12-19 01:06:50 +00:00
|
|
|
try:
|
|
|
|
yield
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
|
|
rootlog.error(f'Test "{name}" failed with error: "{e}"')
|
|
|
|
raise e
|
|
|
|
|
|
|
|
def test_symbols(self) -> Dict[str, Any]:
|
|
|
|
@contextmanager
|
|
|
|
def subtest(name: str) -> Iterator[None]:
|
|
|
|
return self.subtest(name)
|
|
|
|
|
|
|
|
general_symbols = dict(
|
|
|
|
start_all=self.start_all,
|
|
|
|
test_script=self.test_script,
|
|
|
|
machines=self.machines,
|
|
|
|
vlans=self.vlans,
|
|
|
|
driver=self,
|
|
|
|
log=rootlog,
|
|
|
|
os=os,
|
|
|
|
create_machine=self.create_machine,
|
|
|
|
subtest=subtest,
|
|
|
|
run_tests=self.run_tests,
|
|
|
|
join_all=self.join_all,
|
|
|
|
retry=retry,
|
|
|
|
serial_stdout_off=self.serial_stdout_off,
|
|
|
|
serial_stdout_on=self.serial_stdout_on,
|
2022-01-13 20:06:32 +00:00
|
|
|
polling_condition=self.polling_condition,
|
2021-12-19 01:06:50 +00:00
|
|
|
Machine=Machine, # for typing
|
|
|
|
)
|
2023-05-24 13:37:59 +00:00
|
|
|
machine_symbols = {pythonize_name(m.name): m for m in self.machines}
|
2021-12-19 01:06:50 +00:00
|
|
|
# If there's exactly one machine, make it available under the name
|
|
|
|
# "machine", even if it's not called that.
|
|
|
|
if len(self.machines) == 1:
|
|
|
|
(machine_symbols["machine"],) = self.machines
|
|
|
|
vlan_symbols = {
|
|
|
|
f"vlan{v.nr}": self.vlans[idx] for idx, v in enumerate(self.vlans)
|
|
|
|
}
|
|
|
|
print(
|
|
|
|
"additionally exposed symbols:\n "
|
|
|
|
+ ", ".join(map(lambda m: m.name, self.machines))
|
|
|
|
+ ",\n "
|
|
|
|
+ ", ".join(map(lambda v: f"vlan{v.nr}", self.vlans))
|
|
|
|
+ ",\n "
|
|
|
|
+ ", ".join(list(general_symbols.keys()))
|
|
|
|
)
|
|
|
|
return {**general_symbols, **machine_symbols, **vlan_symbols}
|
|
|
|
|
|
|
|
def test_script(self) -> None:
|
|
|
|
"""Run the test script"""
|
|
|
|
with rootlog.nested("run the VM test script"):
|
|
|
|
symbols = self.test_symbols() # call eagerly
|
|
|
|
exec(self.tests, symbols, None)
|
|
|
|
|
|
|
|
def run_tests(self) -> None:
|
|
|
|
"""Run the test script (for non-interactive test runs)"""
|
2023-11-16 04:20:00 +00:00
|
|
|
rootlog.info(
|
|
|
|
f"Test will time out and terminate in {self.global_timeout} seconds"
|
|
|
|
)
|
|
|
|
self.race_timer.start()
|
2021-12-19 01:06:50 +00:00
|
|
|
self.test_script()
|
|
|
|
# TODO: Collect coverage data
|
|
|
|
for machine in self.machines:
|
|
|
|
if machine.is_up():
|
|
|
|
machine.execute("sync")
|
|
|
|
|
|
|
|
def start_all(self) -> None:
|
|
|
|
"""Start all machines"""
|
|
|
|
with rootlog.nested("start all VMs"):
|
|
|
|
for machine in self.machines:
|
|
|
|
machine.start()
|
|
|
|
|
|
|
|
def join_all(self) -> None:
|
|
|
|
"""Wait for all machines to shut down"""
|
|
|
|
with rootlog.nested("wait for all VMs to finish"):
|
|
|
|
for machine in self.machines:
|
|
|
|
machine.wait_for_shutdown()
|
2023-11-16 04:20:00 +00:00
|
|
|
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)
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2024-02-29 20:09:43 +00:00
|
|
|
def create_machine(
|
|
|
|
self,
|
|
|
|
start_command: str | dict,
|
|
|
|
*,
|
|
|
|
name: Optional[str] = None,
|
|
|
|
keep_vm_state: bool = False,
|
|
|
|
) -> Machine:
|
|
|
|
# Legacy args handling
|
|
|
|
# FIXME: remove after 24.05
|
|
|
|
if isinstance(start_command, dict):
|
|
|
|
if name is not None or keep_vm_state:
|
|
|
|
raise TypeError(
|
|
|
|
"Dictionary passed to create_machine must be the only argument"
|
|
|
|
)
|
|
|
|
|
|
|
|
args = start_command
|
|
|
|
start_command = args.pop("startCommand", SENTINEL)
|
|
|
|
|
|
|
|
if start_command is SENTINEL:
|
|
|
|
raise TypeError(
|
|
|
|
"Dictionary passed to create_machine must contain startCommand"
|
|
|
|
)
|
|
|
|
|
|
|
|
if not isinstance(start_command, str):
|
|
|
|
raise TypeError(
|
|
|
|
f"startCommand must be a string, got: {repr(start_command)}"
|
|
|
|
)
|
|
|
|
|
|
|
|
name = args.pop("name", None)
|
|
|
|
keep_vm_state = args.pop("keep_vm_state", False)
|
|
|
|
|
|
|
|
if args:
|
|
|
|
raise TypeError(
|
|
|
|
f"Unsupported arguments passed to create_machine: {args}"
|
|
|
|
)
|
|
|
|
|
|
|
|
rootlog.warning(
|
2024-04-21 15:54:59 +00:00
|
|
|
Fore.YELLOW
|
|
|
|
+ Style.BRIGHT
|
|
|
|
+ "WARNING: Using create_machine with a single dictionary argument is deprecated and will be removed in NixOS 24.11"
|
|
|
|
+ Style.RESET_ALL
|
2024-02-29 20:09:43 +00:00
|
|
|
)
|
|
|
|
# End legacy args handling
|
|
|
|
|
2022-01-27 00:19:43 +00:00
|
|
|
tmp_dir = get_tmp_dir()
|
2021-12-19 01:06:50 +00:00
|
|
|
|
2024-02-29 20:09:43 +00:00
|
|
|
cmd = NixStartScript(start_command)
|
|
|
|
name = name or cmd.machine_name
|
2021-12-19 01:06:50 +00:00
|
|
|
|
|
|
|
return Machine(
|
|
|
|
tmp_dir=tmp_dir,
|
2022-01-27 00:19:43 +00:00
|
|
|
out_dir=self.out_dir,
|
2021-12-19 01:06:50 +00:00
|
|
|
start_command=cmd,
|
|
|
|
name=name,
|
2024-02-29 20:09:43 +00:00
|
|
|
keep_vm_state=keep_vm_state,
|
2021-12-19 01:06:50 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
def serial_stdout_on(self) -> None:
|
|
|
|
rootlog._print_serial_logs = True
|
|
|
|
|
|
|
|
def serial_stdout_off(self) -> None:
|
|
|
|
rootlog._print_serial_logs = False
|
2022-01-13 20:06:32 +00:00
|
|
|
|
|
|
|
def check_polling_conditions(self) -> None:
|
|
|
|
for condition in self.polling_conditions:
|
|
|
|
condition.maybe_raise()
|
|
|
|
|
|
|
|
def polling_condition(
|
|
|
|
self,
|
|
|
|
fun_: Optional[Callable] = None,
|
|
|
|
*,
|
|
|
|
seconds_interval: float = 2.0,
|
|
|
|
description: Optional[str] = None,
|
|
|
|
) -> Union[Callable[[Callable], ContextManager], ContextManager]:
|
|
|
|
driver = self
|
|
|
|
|
|
|
|
class Poll:
|
|
|
|
def __init__(self, fun: Callable):
|
|
|
|
self.condition = PollingCondition(
|
|
|
|
fun,
|
|
|
|
seconds_interval,
|
|
|
|
description,
|
|
|
|
)
|
|
|
|
|
|
|
|
def __enter__(self) -> None:
|
|
|
|
driver.polling_conditions.append(self.condition)
|
|
|
|
|
|
|
|
def __exit__(self, a, b, c) -> None: # type: ignore
|
|
|
|
res = driver.polling_conditions.pop()
|
|
|
|
assert res is self.condition
|
|
|
|
|
2022-12-28 21:21:41 +00:00
|
|
|
def wait(self, timeout: int = 900) -> None:
|
|
|
|
def condition(last: bool) -> bool:
|
|
|
|
if last:
|
|
|
|
rootlog.info(f"Last chance for {self.condition.description}")
|
|
|
|
ret = self.condition.check(force=True)
|
|
|
|
if not ret and not last:
|
|
|
|
rootlog.info(
|
|
|
|
f"({self.condition.description} failure not fatal yet)"
|
|
|
|
)
|
|
|
|
return ret
|
|
|
|
|
|
|
|
with rootlog.nested(f"waiting for {self.condition.description}"):
|
|
|
|
retry(condition, timeout=timeout)
|
|
|
|
|
2022-01-13 20:06:32 +00:00
|
|
|
if fun_ is None:
|
|
|
|
return Poll
|
|
|
|
else:
|
|
|
|
return Poll(fun_)
|