diff --git a/plugins/modules/connectivity_transaction.py b/plugins/modules/connectivity_transaction.py new file mode 100644 index 00000000..b43601db --- /dev/null +++ b/plugins/modules/connectivity_transaction.py @@ -0,0 +1,271 @@ +"""Standalone, host-locked transaction for a connectivity drop-in. + +The caller supplies a private, persistent host state directory and a reload callback. Recovery rolls back every transaction that did not durably commit. +""" + +import base64 +import ctypes +import sys +import fcntl +import json +import math +import multiprocessing +import os +import signal +import uuid +import stat +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Callable, Iterator, Optional + + +def _sync_directory(directory: Path) -> None: + fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _has_metadata(fd: int) -> bool: + if sys.platform == "darwin": + libc = ctypes.CDLL(None, use_errno=True) + size = libc.flistxattr(fd, None, 0, 0) + if size < 0: + raise OSError(ctypes.get_errno(), "flistxattr failed") + return size > 0 + return bool(os.listxattr(fd)) + + +_directory_has_metadata = _has_metadata + + +def _snapshot(path: Path) -> Optional[dict]: + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + raise ValueError("drop-in must be a regular file") + if info.st_nlink != 1: + raise ValueError("drop-in must not have multiple hard links") + if info.st_uid != os.geteuid() or info.st_gid != os.getegid(): + raise ValueError("drop-in ownership cannot be preserved") + if _has_metadata(fd): + raise ValueError("drop-in extended metadata cannot be preserved") + with os.fdopen(fd, "rb", closefd=False) as source: + content = source.read() + after = os.fstat(fd) + if (info.st_size, info.st_mtime_ns, info.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise RuntimeError("drop-in changed during snapshot") + return {"bytes": base64.b64encode(content).decode("ascii"), "mode": stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid} + finally: + os.close(fd) + + +def _write(path: Path, content: bytes, mode: int, *, check_metadata: bool = False) -> None: + fd, name = tempfile.mkstemp(prefix=".transaction-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as output: + output.write(content) + output.flush() + os.fchown(output.fileno(), os.geteuid(), os.getegid()) + os.fchmod(output.fileno(), mode) + if check_metadata and _has_metadata(output.fileno()): + raise ValueError("replacement inherited unsupported extended metadata") + os.fsync(output.fileno()) + os.replace(name, path) + _sync_directory(path.parent) + finally: + if os.path.exists(name): + os.unlink(name) + + +class ConnectivityTransaction: + """Manage one durable transaction per host state directory. + + The state directory and drop-in parent must already exist. Every writer to the drop-in must use the same state directory and lock, and the caller must prevent non-participating writers for the transaction's entire lifetime. The exclusive_writer argument is an assertion, not enforcement. The content check detects prior changes but cannot prevent a writer racing the check and replacement. Rollback restores bytes, mode, owner and group, or absence; inode identity and timestamps are not restored. Multiple hard links, other ownership, and extended attributes, including Linux POSIX ACLs, are refused. Reload callbacks must be repeatable because recovery can invoke them again after a crash. + """ + + def __init__(self, state_dir: Path, reload: Callable[[], None], *, exclusive_writer: bool) -> None: + if not exclusive_writer: + raise ValueError("exclusive control of all drop-in writers is required") + self.state_dir = Path(state_dir) + self.reload = reload + self.state_path = self.state_dir / "connectivity-transaction.json" + self.lock_path = self.state_dir / "connectivity-transaction.lock" + + @contextmanager + def _locked(self, *, blocking: bool = True) -> Iterator[int]: + directory = os.open(self.state_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + info = os.fstat(directory) + if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise PermissionError("state directory must be owned by the effective user and have mode 0700") + if _directory_has_metadata(directory): + raise PermissionError("state directory must not have extended metadata") + fd = os.open("connectivity-transaction.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600, dir_fd=directory) + try: + lock_info = os.fstat(fd) + if not stat.S_ISREG(lock_info.st_mode) or lock_info.st_uid != os.geteuid() or stat.S_IMODE(lock_info.st_mode) != 0o600 or lock_info.st_nlink != 1: + raise PermissionError("transaction lock must be a private regular file") + fcntl.flock(fd, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)) + yield fd + finally: + os.close(fd) + finally: + os.close(directory) + + def _read(self) -> dict: + with self.state_path.open("r", encoding="utf-8") as source: + return json.load(source) + + def _save(self, state: dict) -> None: + _write(self.state_path, json.dumps(state, sort_keys=True).encode("utf-8"), 0o600) + + def _reload_until(self, timeout: float, lock_fd: int) -> None: + if timeout <= 0: + raise TimeoutError("watchdog deadline elapsed before reload") + def reload_without_lock() -> None: + os.close(lock_fd) + self.reload() + + context = multiprocessing.get_context("fork") + process = context.Process(target=reload_without_lock) + process.start() + process.join(timeout) + if process.is_alive(): + process.terminate() + process.join(1) + if process.is_alive(): + process.kill() + process.join() + raise TimeoutError("reload exceeded watchdog deadline") + if process.exitcode != 0: + raise RuntimeError("reload failed") + + def begin(self, transaction_id: str, path: Path, content: bytes, mode: int = 0o644, *, deadline: Optional[float] = None, scheduler=None, now=None, boot_id=None) -> None: + """Prepare, optionally arm a scheduler, and apply the drop-in. + + A scheduler must durably arm independently of this process and verify that arm. The caller supplies a boot-scoped, suspend-aware monotonic clock and a boot identifier. A different boot expires the transaction immediately. Scheduled reloads run in a child process and must not depend on changes to the parent's memory. + """ + if any(value is None for value in (deadline, scheduler, now, boot_id)) and any(value is not None for value in (deadline, scheduler, now, boot_id)): + raise ValueError("deadline, scheduler, clock and boot identifier must be supplied together") + if deadline is not None and (not math.isfinite(deadline) or not isinstance(boot_id, str) or not boot_id): + raise ValueError("finite deadline and non-empty boot identifier required") + if scheduler is not None and deadline <= now(): + raise ValueError("deadline must be in the future") + path = Path(path) + if not path.is_absolute() or not transaction_id: + raise ValueError("an absolute drop-in path and transaction id are required") + if not isinstance(mode, int) or isinstance(mode, bool) or mode < 0 or mode != stat.S_IMODE(mode): + raise ValueError("mode must contain only permission and special bits") + if not mode & stat.S_IRUSR: + raise ValueError("mode must grant the owner read permission for recovery") + with self._locked() as lock_fd: + if self.state_path.exists() and self._read()["phase"] not in ("committed", "rolled_back"): + raise RuntimeError("a transaction is already active") + snapshot = _snapshot(path) + applied = {"bytes": base64.b64encode(content).decode("ascii"), "mode": mode, "uid": os.geteuid(), "gid": os.getegid()} + generation = uuid.uuid4().hex + state = {"id": transaction_id, "generation": generation, "path": str(path), "snapshot": snapshot, "applied": applied, "phase": "prepared", "deadline": deadline, "boot_id": boot_id} + self._save(state) + if scheduler is not None: + scheduler.arm(transaction_id, generation, deadline) + if not scheduler.is_armed(transaction_id, generation, deadline): + raise RuntimeError("watchdog arm could not be verified") + with self._locked() as lock_fd: + state = self._read() + if state["id"] != transaction_id or state["generation"] != generation or state["phase"] != "prepared": + raise RuntimeError("transaction changed while arming watchdog") + if scheduler is not None and now() >= deadline: + raise RuntimeError("watchdog deadline elapsed before application") + if _snapshot(path) != snapshot: + raise RuntimeError("drop-in changed outside the transaction") + _write(path, content, mode, check_metadata=True) + if scheduler is None: + self.reload() + else: + self._reload_until(deadline - now(), lock_fd) + state["phase"] = "pending" + self._save(state) + + def commit(self, transaction_id: str, *, now=None, boot_id=None) -> bool: + """Commit a pending transaction, or return false if rollback won.""" + with self._locked(): + state = self._read() + self._check_id(state, transaction_id) + if state["phase"] == "committed": + return True + if state["phase"] != "pending": + return False + if state["deadline"] is not None: + if now is None or not boot_id: + raise ValueError("a shared clock and boot identifier are required for scheduled commit") + if state["boot_id"] != boot_id or now() >= state["deadline"]: + return False + state["phase"] = "committed" + self._save(state) + return True + + def expire(self, transaction_id: str, generation: str, now: float, boot_id: str) -> bool: + """Roll back a matching scheduled transaction at or after its deadline.""" + with self._locked(): + state = self._read() + if state["id"] != transaction_id or state["generation"] != generation: + return False + deadline = state["deadline"] + if deadline is None or (state["boot_id"] == boot_id and now < deadline): + return False + return self._rollback(state) + + def rollback(self, transaction_id: str) -> bool: + """Restore the snapshot unless commit already won.""" + with self._locked(): + state = self._read() + self._check_id(state, transaction_id) + return self._rollback(state) + + def recover(self) -> str: + """Roll back an interrupted transaction and return its durable phase.""" + with self._locked(): + if not self.state_path.exists(): + return "empty" + state = self._read() + self._rollback(state) + return state["phase"] + + @staticmethod + def _check_id(state: dict, transaction_id: str) -> None: + if state["id"] != transaction_id: + raise ValueError("transaction id does not match") + + def _rollback(self, state: dict) -> bool: + if state["phase"] == "committed": + return False + if state["phase"] == "rolled_back": + return True + if state["phase"] not in ("prepared", "pending", "rolling_back"): + raise ValueError("unknown transaction phase") + path = Path(state["path"]) + snapshot = state["snapshot"] + current = _snapshot(path) + if current != state["applied"] and current != snapshot: + raise RuntimeError("drop-in changed outside the transaction") + state["phase"] = "rolling_back" + self._save(state) + if snapshot is None: + try: + path.unlink() + except FileNotFoundError: + pass + _sync_directory(path.parent) + else: + _write(path, base64.b64decode(snapshot["bytes"], validate=True), snapshot["mode"], check_metadata=True) + self.reload() + state["phase"] = "rolled_back" + self._save(state) + return True diff --git a/plugins/modules/connectivity_watchdog.py b/plugins/modules/connectivity_watchdog.py new file mode 100644 index 00000000..e750ec06 --- /dev/null +++ b/plugins/modules/connectivity_watchdog.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Host-owned recovery worker for connectivity transactions. + +The service is deliberately not wired into the role: a NetworkManager reload can outlive its caller and overlap a rollback reload. Installation alone does not make that transaction safe to use. +""" + +import argparse +import json +import socket +import subprocess +import time +import uuid +from pathlib import Path + +from connectivity_transaction import ConnectivityTransaction + + +POLL_INTERVAL_SECONDS = 1.0 +RELOAD_TIMEOUT_SECONDS = 30 +HANDSHAKE_TIMEOUT_SECONDS = 2 +MAX_REQUEST_BYTES = 4096 + + +class ServiceScheduler: + """Keep application disabled until the host safety boundaries are enforced.""" + + def __init__(self, state_dir: Path) -> None: + self.state_dir = Path(state_dir) + + def arm(self, transaction_id: str, generation: str, deadline: float) -> None: + raise RuntimeError("watchdog arm disabled: independent liveness, reload isolation and exclusive writers are not established") + + def is_armed(self, transaction_id: str, generation: str, deadline: float) -> bool: + return probe_worker(self.state_dir, transaction_id, generation, deadline) + + +def probe_worker(state_dir: Path, transaction_id: str, generation: str, deadline: float) -> bool: + """Challenge a live worker to confirm the exact durable prepared generation.""" + challenge = uuid.uuid4().hex + request = {"id": transaction_id, "generation": generation, "deadline": deadline, "challenge": challenge} + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(HANDSHAKE_TIMEOUT_SECONDS) + connection.connect(str(state_dir / "watchdog.sock")) + connection.sendall(json.dumps(request).encode() + b"\n") + response = connection.recv(MAX_REQUEST_BYTES) + return json.loads(response) == {"challenge": challenge, "ready": True} + except (OSError, ValueError): + return False + + +def answer_probe(manager: ConnectivityTransaction, request: dict, now: float, boot_id: str) -> dict: + """Check the generation under the transaction lock without waiting for a hung scan.""" + response = {"challenge": request.get("challenge"), "ready": False} + try: + with manager._locked(blocking=False): + if not manager.state_path.exists(): + return response + state = manager._read() + response["ready"] = (state["phase"] == "prepared" and state["id"] == request.get("id") and state["generation"] == request.get("generation") and state["deadline"] == request.get("deadline") and state["boot_id"] == boot_id and now < state["deadline"]) + except BlockingIOError: + pass + return response + + +def serve_once(listener: socket.socket, manager: ConnectivityTransaction, boot_id: str, clock) -> None: + """Answer one probe or return when the listener's poll interval expires.""" + try: + connection, _ = listener.accept() + except TimeoutError: + return + with connection: + connection.settimeout(HANDSHAKE_TIMEOUT_SECONDS) + try: + raw = connection.recv(MAX_REQUEST_BYTES) + request = json.loads(raw) + if not isinstance(request, dict): + return + response = answer_probe(manager, request, clock(), boot_id) + connection.sendall(json.dumps(response).encode()) + except (OSError, ValueError): + return + + +def reload_networkmanager() -> None: + subprocess.run(["nmcli", "general", "reload", "conf"], check=True, timeout=RELOAD_TIMEOUT_SECONDS) + + +def scan(manager: ConnectivityTransaction, now: float, boot_id: str) -> str: + """Expire a due generation or finish a rollback interrupted by a crash.""" + with manager._locked(): + if not manager.state_path.exists(): + return "empty" + state = manager._read() + if state["phase"] == "rolling_back": + manager._rollback(state) + return "rolled_back" + if state["phase"] in ("committed", "rolled_back"): + return state["phase"] + if state["phase"] not in ("prepared", "pending"): + raise ValueError("unknown transaction phase") + deadline = state["deadline"] + if deadline is None: + raise ValueError("watchdog transaction has no deadline") + if state["boot_id"] != boot_id or now >= deadline: + manager._rollback(state) + return "rolled_back" + return state["phase"] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state-dir", type=Path, required=True) + args = parser.parse_args() + manager = ConnectivityTransaction(args.state_dir, reload_networkmanager, exclusive_writer=True) + boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip() + clock = lambda: time.clock_gettime(time.CLOCK_BOOTTIME) + socket_path = args.state_dir / "watchdog.sock" + with manager._locked(): + socket_path.unlink(missing_ok=True) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + with listener: + listener.listen() + listener.settimeout(POLL_INTERVAL_SECONDS) + while True: + scan(manager, clock(), boot_id) + serve_once(listener, manager, boot_id, clock) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/networkmanager_connectivity.py b/plugins/modules/networkmanager_connectivity.py new file mode 100644 index 00000000..451cb19b --- /dev/null +++ b/plugins/modules/networkmanager_connectivity.py @@ -0,0 +1,98 @@ +#!/usr/bin/python +"""Serialise NetworkManager connectivity configuration and reload on one host.""" + +import fcntl +import os +import stat +import tempfile + +from ansible.module_utils.basic import AnsibleModule + +CONTENT = b"# Managed by the exadev.github_runner collection's github_runner_linux_host role. Do not edit by hand.\n[connectivity]\nenabled=false\n" +PROPERTY = ["busctl", "get-property", "org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", "ConnectivityCheckEnabled"] + + +def atomic_write(path, content): + directory = os.path.dirname(path) + fd, temporary = tempfile.mkstemp(dir=directory, prefix=".connectivity-") + try: + with os.fdopen(fd, "wb") as output: + output.write(content) + output.flush() + os.fchmod(output.fileno(), 0o644) + os.fsync(output.fileno()) + os.replace(temporary, path) + sync_directory(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def sync_directory(directory): + fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def run(module): + module.fail_json(msg="NetworkManager connectivity changes are not ready: reload isolation and the exclusive writer boundary are not established") + path = module.params["dropin"] + disabled = module.params["disabled"] + directory = os.path.dirname(path) + marker = path + ".reload-pending" + # The existing host-wide directory is a stable inode shared by all invocations. + lock = os.open("/var/run", os.O_RDONLY) + try: + fcntl.flock(lock, fcntl.LOCK_EX) + exists = os.path.exists(path) + if disabled and exists: + with open(path, "rb") as existing: + config_changed = existing.read() != CONTENT or stat.S_IMODE(os.stat(path).st_mode) != 0o644 + else: + config_changed = disabled or exists + pending = os.path.exists(marker) + runtime_changed = False + if disabled: + rc, stdout, stderr = module.run_command(PROPERTY) + if rc: + module.fail_json(msg="Reading NetworkManager connectivity failed", rc=rc, stderr=stderr) + if stdout.strip() not in ("b true", "b false"): + module.fail_json(msg="Unexpected NetworkManager connectivity property", stdout=stdout) + runtime_changed = stdout.strip() != "b false" + changed = config_changed or pending or runtime_changed + if module.check_mode or not changed: + module.exit_json(changed=changed) + os.makedirs(directory, mode=0o755, exist_ok=True) + atomic_write(marker, b"pending\n") + if config_changed: + if disabled: + atomic_write(path, CONTENT) + else: + os.unlink(path) + sync_directory(directory) + rc, stdout, stderr = module.run_command(["nmcli", "general", "reload", "conf"]) + if rc: + module.fail_json(msg="NetworkManager configuration reload failed", rc=rc, stdout=stdout, stderr=stderr, changed=True) + if disabled: + rc, stdout, stderr = module.run_command(PROPERTY) + if rc or stdout.strip() != "b false": + module.fail_json(msg="Verify NetworkManager connectivity checking is disabled failed", rc=rc, stdout=stdout, stderr=stderr, changed=True) + os.unlink(marker) + sync_directory(directory) + module.exit_json(changed=True) + finally: + os.close(lock) + + +def main(): + module = AnsibleModule(argument_spec={"dropin": {"type": "path", "required": True}, "disabled": {"type": "bool", "required": True}}, supports_check_mode=True) + try: + run(module) + except OSError as exc: + module.fail_json(msg=str(exc)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/networkmanager_metric_plan.py b/plugins/modules/networkmanager_metric_plan.py new file mode 100644 index 00000000..843421a1 --- /dev/null +++ b/plugins/modules/networkmanager_metric_plan.py @@ -0,0 +1,182 @@ +#!/usr/bin/python +"""Plan, but do not apply, NetworkManager route metric changes.""" + +import ipaddress +import json + +ETHERNET_METRIC = 100 +WIFI_METRIC = 20101 +CONNECTIVITY_PENALTY = 20000 + + +def collect_observations(run, profiles): + """Read narrow non-secret profile fields and IPv4 route/rule JSON without changing the host.""" + observed = [] + for uuid in profiles: + if not uuid or uuid.startswith("-"): + raise ValueError("invalid profile identifier") + fields = {} + for field in ("connection.uuid", "connection.type", "ipv4.route-metric", "ipv4.route-table", "ipv4.never-default", "ipv4.routes"): + fields[field] = run(["nmcli", "--get-values", field, "connection", "show", "uuid", uuid]).strip() + observed.append(fields) + active = run(["nmcli", "--terse", "--escape", "no", "--fields", "UUID,DEVICE", "connection", "show", "--active"]).splitlines() + rules = json.loads(run(["ip", "-j", "-4", "rule", "show"])) + routes = json.loads(run(["ip", "-j", "-4", "route", "show", "table", "all"])) + if not isinstance(rules, list) or not isinstance(routes, list): + raise ValueError("ip must return route and rule lists") + associations = {} + for line in active: + uuid, separator, device = line.partition(":") + if not separator or not uuid or not device or ":" in device or uuid in associations: + raise ValueError("ambiguous active profile association") + associations[uuid] = device + for profile in observed: + uuid = profile["connection.uuid"] + if uuid not in associations: + raise ValueError("requested profile is not uniquely active") + profile["device"] = associations[uuid] + if len({profile["device"] for profile in observed}) != len(observed): + raise ValueError("requested profiles share a device") + return {"profiles": observed, "rules": rules, "routes": routes} + + +def destination_tables(routes, destination): + """Classify route lookup outcomes per table for one IPv4 destination.""" + address = ipaddress.IPv4Address(destination) + matches = {} + for route in routes: + table = str(route.get("table", "main")) + network = ipaddress.IPv4Network("0.0.0.0/0" if route.get("dst") == "default" else route["dst"], strict=False) + if address not in network: + continue + if any(key in route for key in ("multipath", "nhid", "tos", "src")) or "linkdown" in route.get("flags", []): + raise ValueError("route lookup has unsupported or unavailable attributes") + matches.setdefault(table, []).append((network.prefixlen, route)) + outcomes = {} + for table, candidates in matches.items(): + longest = max(prefix for prefix, _ in candidates) + selected = [route for prefix, route in candidates if prefix == longest] + if any(route.get("type", "unicast") not in ("unicast", "local", "broadcast") for route in selected): + outcomes[table] = "terminal" + elif table == "main" and longest == 0: + outcomes[table] = "main-default" + else: + outcomes[table] = "other-route" + return outcomes + + +def collect_consistent(run, profiles): + first = collect_observations(run, profiles) + if first != collect_observations(run, profiles): + raise ValueError("routing or profile observations changed during collection") + return first + + +def interpret_rules(raw): + names = {"local": 255, "main": 254, "default": 253} + result = [] + for rule in raw: + if set(rule) - {"priority", "src", "table", "fwmark", "fwmask", "action", "not"} or rule.get("src", "all") != "all": + raise ValueError("unsupported rule selector") + action = rule.get("action", "lookup") + table = rule.get("table", 0) + if isinstance(table, str): + table = names[table] if table in names else int(table) + result.append({"priority": rule["priority"], "mark": int(str(rule.get("fwmark", "0")), 0), "mask": int(str(rule.get("fwmask", "0xffffffff" if "fwmark" in rule else "0")), 0), "invert": rule.get("not", False), "action": action, "table": table}) + return result + + +def evaluate_observations(observations, destination, mark): + profiles = observations["profiles"] + if len(profiles) != 2: + raise ValueError("two active profiles are required") + defaults = [] + for profile in profiles: + routes = [r for r in observations["routes"] if r.get("dst") == "default" and str(r.get("table", "main")) in ("main", "254") and r.get("dev") == profile["device"]] + if len(routes) != 1 or not routes[0].get("gateway") or "linkdown" in routes[0].get("flags", []): + raise ValueError("each device requires one usable main-table default route") + defaults.append(routes[0]) + if defaults[0]["gateway"] != defaults[1]["gateway"]: + raise ValueError("default routes must share a gateway") + outcomes = destination_tables(observations["routes"], destination) + tables = {255: outcomes.get("local", "miss"), 254: outcomes.get("main", "miss"), 253: outcomes.get("default", "miss")} + tables.update({int(k): v for k, v in outcomes.items() if k.isdigit()}) + for rule in interpret_rules(observations["rules"]): + if rule["action"] == "lookup": + tables.setdefault(rule["table"], "miss") + return controls_destination(interpret_rules(observations["rules"]), mark, tables) + + +def controls_destination(rules, mark, table_results): + """Evaluate explicitly modelled matching rules and table lookup results for one flow.""" + if not rules or len({rule["priority"] for rule in rules}) != len(rules): + raise ValueError("complete rules with distinct priorities are required") + for rule in sorted(rules, key=lambda item: item["priority"]): + if set(rule) != {"priority", "mark", "mask", "invert", "action", "table"}: + raise ValueError("unsupported or incomplete rule selector") + matches = (mark & rule["mask"]) == (rule["mark"] & rule["mask"]) + if rule["invert"]: + matches = not matches + if not matches: + continue + if rule["action"] in ("unreachable", "prohibit", "blackhole"): + return False + if rule["action"] != "lookup" or rule["table"] not in table_results: + raise ValueError("unknown rule action or table lookup result") + result = table_results[rule["table"]] + if result == "miss": + continue + if result == "main-default" and rule["table"] == 254: + return True + if result in ("other-route", "terminal"): + return False + raise ValueError("ambiguous table lookup result") + raise ValueError("no matching terminal routing outcome") + + +def plan(profiles, rules, mark, table_results): + """Return candidate metrics only for two comparable default routes.""" + if len(profiles) != 2: + raise ValueError("exactly one Ethernet and one Wi-Fi profile are required") + by_type = {} + for profile in profiles: + kind = profile["kind"] + if kind not in ("ethernet", "wifi") or kind in by_type: + raise ValueError("profiles must have distinct Ethernet and Wi-Fi kinds") + if not profile["active"] or not profile["default_route"] or profile["explicit_route_metric"]: + raise ValueError("active comparable default routes without explicit metrics are required") + if profile["address_family"] != "ipv4" or profile["table"] != 254 or not profile["gateway"] or not profile["uuid"]: + raise ValueError("IPv4 main-table default routes with known gateways are required") + by_type[kind] = profile + ethernet = by_type["ethernet"] + wifi = by_type["wifi"] + if ethernet["gateway"] != wifi["gateway"] or ethernet["uuid"] == wifi["uuid"]: + raise ValueError("distinct profiles must use the same gateway") + if not controls_destination(rules, mark, table_results): + raise ValueError("main-table default metrics do not control the specified flow") + assert ETHERNET_METRIC + CONNECTIVITY_PENALTY < WIFI_METRIC + return [{"uuid": ethernet["uuid"], "metric": ETHERNET_METRIC}, {"uuid": wifi["uuid"], "metric": WIFI_METRIC}] + + +def main(): + from ansible.module_utils.basic import AnsibleModule + + observation_module = AnsibleModule(argument_spec={"profiles": {"type": "list", "elements": "str", "required": True}, "destination": {"type": "str", "required": True}, "mark": {"type": "int", "required": True}}, supports_check_mode=True) + + def run(argv): + rc, stdout, stderr = observation_module.run_command(argv) + if rc: + raise ValueError("read-only observation command failed: " + argv[0]) + return stdout + + try: + observations = collect_consistent(run, observation_module.params["profiles"]) + outcomes = destination_tables(observations["routes"], observation_module.params["destination"]) + controls = evaluate_observations(observations, observation_module.params["destination"], observation_module.params["mark"]) + except (ValueError, KeyError) as exc: + observation_module.fail_json(msg=str(exc)) + observation_module.exit_json(changed=False, observations=observations, destination_table_outcomes=outcomes, main_default_controls=controls, candidate=None, applied=False, msg="Observed data requires validated flow and route interpretation before planning") + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/networkmanager_metric_transaction.py b/plugins/modules/networkmanager_metric_transaction.py new file mode 100644 index 00000000..8bf4df36 --- /dev/null +++ b/plugins/modules/networkmanager_metric_transaction.py @@ -0,0 +1,95 @@ +#!/usr/bin/python +"""Guarded checkpoint transaction protocol for future NetworkManager integration. + +The adapter must call NetworkManager's device-scoped checkpoint API with flags zero and a non-zero timeout. This protocol is not connected to a host adapter or Ansible role. +""" + + +class NetworkManagerAdapter: + """Use a python-dbus system bus; callers must supply validated device object paths. + + One adapter instance is bound to one NetworkManager process: every call is addressed to the unique bus name the daemon owned when the adapter was created, never to the well-known name. A restarted daemon has a new unique name, so a read made before the restart cannot be followed by a write after it: the call fails with an unknown-name error. This matters because a restart resets each profile's version-id, so a stale version can match a changed profile (verified on NetworkManager 1.54.3). Profiles are always found by UUID, because a restart also renumbers their object paths. + """ + + def __init__(self, bus, dbus_module): + self.bus = bus + self.dbus = dbus_module + self.service = "org.freedesktop.NetworkManager" + self.owner = bus.get_name_owner(self.service) + if not isinstance(self.owner, str) or not self.owner.startswith(":"): + raise ValueError("NetworkManager must own a unique bus name") + + def _interface(self, path, name): + return self.dbus.Interface(self.bus.get_object(self.owner, path), name) + + def _manager(self): + return self._interface("/org/freedesktop/NetworkManager", self.service) + + def create_checkpoint(self, devices, timeout, flags): + if not devices or timeout <= 0 or flags != 0: + raise ValueError("a non-zero timeout, devices and flags zero are required") + return self._manager().CheckpointCreate(self.dbus.Array(devices, signature="o"), self.dbus.UInt32(timeout), self.dbus.UInt32(flags)) + + def inspect_device(self, device, uuid): + interface = self._interface(device, self.service + ".Device") + applied, version = interface.GetAppliedConnection(self.dbus.UInt32(0)) + if applied.get("connection", {}).get("uuid") != uuid or not version: + raise ValueError("checkpoint device does not have the expected applied profile and version") + return applied, version + + def set_metric(self, uuid, metric): + settings = self._interface("/org/freedesktop/NetworkManager/Settings", self.service + ".Settings") + path = settings.GetConnectionByUuid(uuid) + connection = self._interface(path, self.service + ".Settings.Connection") + properties = self._interface(path, "org.freedesktop.DBus.Properties") + version = properties.Get(self.service + ".Settings.Connection", "VersionId") + if not version: + raise ValueError("profile version must be non-zero") + profile = connection.GetSettings() + if "ipv4" not in profile: + raise ValueError("profile has no IPv4 settings") + profile["ipv4"]["route-metric"] = self.dbus.Int64(metric, variant_level=1) + args = self.dbus.Dictionary({"version-id": self.dbus.UInt64(version, variant_level=1)}, signature="sv") + connection.Update2(profile, self.dbus.UInt32(1), args) + + def reapply(self, device, uuid, metric, applied, version): + if applied.get("connection", {}).get("uuid") != uuid or not version or "ipv4" not in applied: + raise ValueError("applied profile identity, IPv4 settings and version are required") + applied["ipv4"]["route-metric"] = self.dbus.Int64(metric, variant_level=1) + interface = self._interface(device, self.service + ".Device") + interface.Reapply(applied, self.dbus.UInt64(version), self.dbus.UInt32(1)) + + def rollback_checkpoint(self, checkpoint, devices): + results = self._manager().CheckpointRollback(checkpoint) + if set(results) != set(devices) or any(int(result) != 0 for result in results.values()): + raise RuntimeError("checkpoint rollback incomplete or failed: " + repr(dict(results))) + return results + + def destroy_checkpoint(self, checkpoint): + self._manager().CheckpointDestroy(checkpoint) + + +def execute(adapter, profiles, timeout, verify): + """Apply candidate metrics behind a checkpoint, or fail before touching the adapter.""" + raise RuntimeError("metric transaction disabled until isolated NetworkManager crash and rollback validation") + + +def _execute_for_test(adapter, profiles, timeout, verify): + """Exercise the protocol with an offline adapter; never use against a host.""" + if len(profiles) != 2 or len({item["device"] for item in profiles}) != 2 or timeout <= 0: + raise ValueError("two distinct devices and a positive timeout are required") + checkpoint = adapter.create_checkpoint([item["device"] for item in profiles], timeout, 0) + try: + snapshots = [adapter.inspect_device(profile["device"], profile["uuid"]) for profile in profiles] + for profile, (applied, version) in zip(profiles, snapshots): + adapter.set_metric(profile["uuid"], profile["metric"]) + adapter.reapply(profile["device"], profile["uuid"], profile["metric"], applied, version) + if not verify(): + raise RuntimeError("independent route verification failed") + adapter.destroy_checkpoint(checkpoint) + except Exception as original: + try: + adapter.rollback_checkpoint(checkpoint, [profile["device"] for profile in profiles]) + except Exception as rollback_error: + raise ExceptionGroup("metric transaction and rollback both failed", [original, rollback_error]) from None + raise diff --git a/roles/github_runner_linux_host/README.md b/roles/github_runner_linux_host/README.md index 7876ddaf..5fd052c9 100644 --- a/roles/github_runner_linux_host/README.md +++ b/roles/github_runner_linux_host/README.md @@ -8,6 +8,10 @@ Its tasks that change the host use `become` on their own, so the connecting user `github_runner_linux_host_keep_awake: true` masks `sleep.target`, `suspend.target`, `hibernate.target` and `hybrid-sleep.target`, and writes a systemd-logind drop-in (`/etc/systemd/logind.conf.d/90-github-runner-keep-awake.conf`) that sets `HandleLidSwitch`, `HandleLidSwitchExternalPower` and `HandleLidSwitchDocked` to `ignore`, then has logind read it with `SIGHUP`, which reloads its configuration without ending the login sessions it tracks. A host that suspends drops off the network, so its node leaves the cluster and its runner pods stop; a laptop suspends whenever its lid closes (logind's default for `HandleLidSwitch` is `suspend`), and its desktop or power button can ask logind to suspend it as well. Masking is what stops a request that does not come from the lid: the target cannot be started, so `systemctl suspend` fails. Both parts are idempotent, and the role checks what is masked before it masks anything. +## NetworkManager connectivity checks + +The NetworkManager connectivity setting is not ready for use. Setting `github_runner_linux_host_networkmanager_connectivity_check_off` to either `true` or `false` fails without changing the host; leaving it unset leaves the configuration unmanaged. The watchdog is not installed by the role. Safe integration requires verified service readiness, exclusive control of drop-in writers, and preventing an in-flight NetworkManager reload from overlapping recovery. Scheduled deadlines use a suspend-aware boot clock and expire on a different boot. + ## Wi-Fi power save `github_runner_linux_host_wifi_powersave_off: true` keeps the host's Wi-Fi from power saving. A radio in power-save mode sleeps between beacons and delays or drops packets, which on a node of the cluster shows as the node flapping `NotReady`. It matters only for a host on Wi-Fi, and does nothing to a host without a Wi-Fi device. The mechanism depends on what manages the Wi-Fi, which the role reads from the host: NetworkManager when `nmcli` lists a Wi-Fi device that is not `unmanaged`, else systemd-networkd when it is running and the kernel has a Wi-Fi network device (one with a `phy80211` link under `/sys/class/net`). A host where neither manages a Wi-Fi device fails, since the setting is explicitly asked for; a host with no Wi-Fi device at all is left alone as long as one of the two answers. @@ -91,6 +95,8 @@ A host that is not Debian or Ubuntu, a host without the package, and a package n ## Variables - `github_runner_linux_host_keep_awake` (default `false`): mask the sleep targets and have logind ignore the lid switch. +- `github_runner_linux_host_networkmanager_connectivity_check_off` (default `null`): `null` leaves connectivity configuration unmanaged; either boolean fails because this feature is not ready. +- `github_runner_linux_host_networkmanager_connectivity_dropin`: where the connectivity drop-in goes, a variable for tests, not a setting. - `github_runner_linux_host_wifi_powersave_off` (default `false`): turn Wi-Fi power save off, in NetworkManager or, on a host without it that runs systemd-networkd, with a udev rule and a unit that run `iw`, and on the radio. - `github_runner_linux_host_k3s_prerequisites` (default `false`): load the modules and set the sysctls k3s needs, now and at boot. - `github_runner_linux_host_containerd_apparmor_stacking_fix` (default `false`): install and load the AppArmor profile for containerd's containers that allows signals and ptrace between peers of a stacked label, on a host with AppArmor, and refuse a k3s whose containerd it was not taken from. diff --git a/roles/github_runner_linux_host/defaults/main.yml b/roles/github_runner_linux_host/defaults/main.yml index 97661670..8d3b3ce2 100644 --- a/roles/github_runner_linux_host/defaults/main.yml +++ b/roles/github_runner_linux_host/defaults/main.yml @@ -19,6 +19,10 @@ github_runner_linux_host_logind_dropin: /etc/systemd/logind.conf.d/90-github-run # Whether the host's Wi-Fi is kept from power saving. A Wi-Fi radio in power-save mode sleeps between beacons and drops or delays packets, which on a node of the cluster shows as the node flapping NotReady; it matters only for a host that is on Wi-Fi, and does nothing to a host without a Wi-Fi device. On a host whose Wi-Fi NetworkManager manages, NetworkManager is told not to (wifi.powersave = 2, disable) for every connection that does not set its own; on a host without NetworkManager that runs systemd-networkd, a udev rule and a systemd unit run `iw dev set power_save off` for each Wi-Fi interface when it appears, and iw is installed. Both apply to the radio now. A host where neither stack manages a Wi-Fi device fails. github_runner_linux_host_wifi_powersave_off: false +# Disable NetworkManager's probe-driven route metric penalty without changing connection metrics or carrier handling. +github_runner_linux_host_networkmanager_connectivity_check_off: null +# Where the connectivity drop-in goes; a variable so tests can use a stand-in. +github_runner_linux_host_networkmanager_connectivity_dropin: /etc/NetworkManager/conf.d/zz-github-runner-connectivity.conf # Not settings: where the NetworkManager drop-in goes. The name sorts after Ubuntu's default-wifi-powersave-on.conf (wifi.powersave = 3), because NetworkManager reads its configuration files in alphabetical order and the last one to set a value wins. Variables only so the tests can point them at a stand-in. github_runner_linux_host_networkmanager_dropin: /etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf # Not settings: where the sysfs network class is read for the host's Wi-Fi devices, and where the udev rule and the systemd unit template of the systemd-networkd path go. The unit's name must end in @.service, since the rule instantiates it for each interface. Variables only so the tests can point them at a stand-in. diff --git a/roles/github_runner_linux_host/meta/argument_specs.yml b/roles/github_runner_linux_host/meta/argument_specs.yml index db523a0f..72567941 100644 --- a/roles/github_runner_linux_host/meta/argument_specs.yml +++ b/roles/github_runner_linux_host/meta/argument_specs.yml @@ -35,6 +35,14 @@ argument_specs: type: bool default: false description: Whether the host's Wi-Fi is kept from power saving, through NetworkManager (wifi.powersave = 2, as the default for every connection) when it manages a Wi-Fi device, else through a udev rule and a systemd unit that run iw for each Wi-Fi interface when systemd-networkd is running, and on the radio now. Fails when neither manages a Wi-Fi device. + github_runner_linux_host_networkmanager_connectivity_check_off: + type: bool + default: null + description: Set true to disable connectivity checks, false to remove the role's configuration, or leave unset to leave it unmanaged. + github_runner_linux_host_networkmanager_connectivity_dropin: + type: path + default: /etc/NetworkManager/conf.d/zz-github-runner-connectivity.conf + description: Where the connectivity drop-in goes. Not a setting; a variable so tests can point it at a stand-in. github_runner_linux_host_networkmanager_dropin: type: path default: /etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf diff --git a/roles/github_runner_linux_host/tasks/main.yml b/roles/github_runner_linux_host/tasks/main.yml index 0e03d8af..eba31717 100644 --- a/roles/github_runner_linux_host/tasks/main.yml +++ b/roles/github_runner_linux_host/tasks/main.yml @@ -23,6 +23,12 @@ - ansible_facts['system'] == 'Linux' - github_runner_linux_host_wifi_powersave_off | bool +- name: Configure NetworkManager connectivity checks + ansible.builtin.include_tasks: networkmanager_connectivity.yml + when: + - ansible_facts['system'] == 'Linux' + - github_runner_linux_host_networkmanager_connectivity_check_off is not none + - name: Make the host ready for k3s ansible.builtin.include_tasks: k3s_prerequisites.yml when: diff --git a/roles/github_runner_linux_host/tasks/networkmanager_connectivity.yml b/roles/github_runner_linux_host/tasks/networkmanager_connectivity.yml new file mode 100644 index 00000000..f8962514 --- /dev/null +++ b/roles/github_runner_linux_host/tasks/networkmanager_connectivity.yml @@ -0,0 +1,4 @@ +--- +- name: Refuse connectivity changes until watchdog reloads are serialised + ansible.builtin.fail: + msg: "NetworkManager connectivity changes are not ready: an in-flight reload can overlap watchdog recovery." diff --git a/roles/github_runner_linux_host/templates/connectivity-watchdog.service.j2 b/roles/github_runner_linux_host/templates/connectivity-watchdog.service.j2 new file mode 100644 index 00000000..38ab6cab --- /dev/null +++ b/roles/github_runner_linux_host/templates/connectivity-watchdog.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Recover expired NetworkManager connectivity transactions +After=local-fs.target NetworkManager.service +Requires=NetworkManager.service +StartLimitIntervalSec=0 + +[Service] +Type=simple +StateDirectory=github-runner-connectivity +StateDirectoryMode=0700 +ExecStart=/usr/bin/python3 /usr/local/lib/github-runner-connectivity/connectivity_watchdog.py --state-dir /var/lib/github-runner-connectivity +Restart=always +RestartSec=1s + +[Install] +WantedBy=multi-user.target diff --git a/tests/linux_host/connectivity_module_real.yml b/tests/linux_host/connectivity_module_real.yml new file mode 100644 index 00000000..dc87c084 --- /dev/null +++ b/tests/linux_host/connectivity_module_real.yml @@ -0,0 +1,11 @@ +--- +# Calls the shipped connectivity module by its collection name, with no test copy on the library path, to show that it refuses. +- name: Run the shipped connectivity module + hosts: all + connection: local + gather_facts: false + tasks: + - name: Apply or remove the connectivity drop-in + exadev.github_runner.networkmanager_connectivity: + dropin: "{{ github_runner_linux_host_networkmanager_connectivity_dropin }}" + disabled: "{{ github_runner_linux_host_networkmanager_connectivity_check_off }}" diff --git a/tests/nm-isolated/README.md b/tests/nm-isolated/README.md new file mode 100644 index 00000000..4128a790 --- /dev/null +++ b/tests/nm-isolated/README.md @@ -0,0 +1,17 @@ +# Isolated NetworkManager fixture + +This source-only fixture is incomplete. Its preflight, private bus launcher, PolicyKit stub and non-root Update2 client are components for a disposable VM harness, not evidence of isolation or a passing integration test. Nothing orchestrates a real NetworkManager daemon yet. + +Use only a disposable Linux VM whose system bus, network devices, filesystem and credentials are disposable. The VM must be independently verified before creating `/run/nm-isolated-test/disposable-vm`; this sentinel is an assertion by the operator, not proof of isolation. The fixture refuses to proceed without the sentinel and `systemd-detect-virt --vm`. Neither check makes an existing VM safe. Do not run on a host carrying real workloads or credentials. + +Dependencies for the intended harness are NetworkManager 1.54.3, systemd-detect-virt, a private D-Bus system bus, Python 3 with PyGObject and a non-root test account. The test bus must be isolated from the host system bus and the VM must contain only disposable NetworkManager state and profiles. NetworkManager's keyfile storage and runtime directories require VM-level isolation; changing only `--state-file` is not sufficient. + +The PolicyKit stub accepts an explicit bus address under `/run/nm-isolated-test/`, holds non-root authorisation requests and exposes test control methods to release them. The path check does not prove the bus is private: the disposable VM operator must ensure it is a dedicated test bus and not a link to another bus. NetworkManager authorises root directly, so Update2 must come from a non-root client. No-fence must prove a delayed update can complete after that client disconnects; fence must perform a confirmed identical Update2 from a separate client before releasing the delayed request and prove the old version is rejected. Restart must check the NetworkManager unique bus owner and profile version before and after daemon replacement. `scenarios.py` expresses those assertions against an injected adapter, and `scenario_bus.py` supplies bounded D-Bus profile and owner reads with a stable-version check. `ScenarioAdapter` composes injected held-client, separate-writer, authority, outcome-observer and daemon-restart ports under one deadline. `lifecycle_ports.py` connects child-process and PolicyKit control interfaces, `client_launcher.py` supplies a non-root child launcher using the fail-closed isolation guard and fixed private bus address, but it has not been exercised with real processes and there is no end-to-end integration. Restart explicitly reports inconclusive. `FixtureOwner` claims a control object within one Python process and tears down the injected authority and bus children on retirement; it does not establish exclusive ownership across different D-Bus proxy objects or processes. A disconnected client's reply cannot establish the delayed request's outcome. `OutcomeObserver` compares snapshots only after an injected request-specific daemon-completion barrier, reporting applied, not-applied or inconclusive. `AuditObserver` correlates structured audit records by daemon instance, client PID, profile UUID, operation and a post-submission cursor, distinguishing success and the source's exact version-mismatch reason from missing or ambiguous events in the observed batch. It returns as soon as one matching batch arrives, so it relies on the dedicated client submitting exactly one update; it cannot detect a later duplicate. `audit_log.py` parses NetworkManager 1.54.3 log-format fields from a caller-supplied binary capture and tracks complete lines with byte cursors; its input must be an exclusive, append-only capture for the named daemon. `AuditScenarioObserver` marks the capture before submission and binds its cursor to the held request. The daemon lifecycle now requests INFO logging and drains its merged output into a bounded capture with an audit-reader adapter; overflow makes observation fail rather than discarding evidence. No real-daemon output or live format validation exists; a PolicyKit release alone is not a completion barrier, and an unchanged profile does not prove a version-mismatch rejection. It also requires exclusive writers. Restart records profile versions on either side without assuming versions survive daemon replacement. + +The client reads a profile's settings and version, then sends an in-memory Update2 with a `version-id` fence; `--unfenced` explicitly omits the version for the control case. After a request is held, the PolicyKit control method `AllowLater(true)` allows subsequent non-root requests without releasing the held request, so a separate fence writer can finish first. This mode allows all subsequent valid non-root requests and therefore requires exclusive fixture use. `--metric` changes its IPv4 route metric; omitting it sends identical settings. By default the client waits for the daemon's reply. With `--disconnect-after-held`, it submits asynchronously and uses a separate bus connection to wait for the stub to report one new held request before disconnecting, printing its cancellation id for the controller. This mode requires an empty authority queue and an exclusive fixture, so another client's authorisation cannot be mistaken for its own; it fails on a timeout or ambiguous queue. The client enforces the non-root, VM and sentinel checks before connecting to an explicitly addressed test bus. As with the stub, the address check does not establish that the bus is private. Run its offline request-construction tests with `python3 -m unittest discover -s tests/nm-isolated -p 'test_*.py'` from the repository root. + +`guest_transport.py` supplies a bounded JSON-lines request/response codec and fixed-handler dispatch for an injected binary stream. Its `DeadlineStream` bounds socket I/O by a single monotonic deadline, and its `ready` handler reports that dispatch is responsive, not that the guest is isolated or NetworkManager is ready. The QEMU command defines a virtio serial port and private Unix socket, and `guest_agent.py` can serve one readiness request on the named guest port with a deadline. `host_transport.connect_ready` retries the workspace socket within a single deadline and requires a matching readiness reply while the QEMU child remains alive. An injected guest runner can handle a validated scenario request, and `vm_factory.request_scenario` sends one bounded request over the ready socket. The agent entrypoint wires `guest_runner` to the scenario functions. `guest_owner` composes injected bus, authority, daemon, profile and adapter contexts with reverse-order cleanup, with default private-bus, PolicyKit process and NetworkManager factories. The authority factory probes its private-bus control method, and the profile factory creates and deletes an unsaved test profile. The scenario-adapter factory composes the existing ports, but the production isolation guard deliberately refuses, so this is not a runnable real-daemon scenario path. `guest_manifest.json` specifies the prebuilt Debian guest's packages and fixture files; `guest_provision.py` validates their presence and the NetworkManager version without installing or starting anything. An installation function checks fixture bytes against caller-supplied trusted digests and validates installed packages before copying, but production provisioning has no authorised entry point: the provenance guard refuses and no trusted digest distribution exists. It does not configure a bus or PolicyKit, build an image, verify package contents or prove guest isolation. There is no real QEMU test. + +`VMScenarioOwner` composes injected VM and connection contexts with fresh-identity tracking and bounded retry attempts; `vm_factory.make_owner` connects it to the QEMU context and readiness socket; its deadline depends on the underlying operations honouring their timeouts, and it returns a socket rather than a complete scenario adapter. `qemu_vm.disposable_vm()` is source-only host-side lifecycle scaffolding: it creates a named qcow2 overlay and constructs a QEMU command without network devices or host shares, verifies the child executable and argument vector through Linux procfs, and reaps it. It requires a trusted qcow2 base image and private workspace; it checks qemu-img JSON for a standalone qcow2 with no backing or external data file, requires an owned read-only base and an owned private workspace, but does not provide guest provisioning, readiness or a test transport. These checks assume the image and its parent directories cannot be replaced concurrently; mode bits do not make an owned image immutable. It has not been run with QEMU. A guest cannot prove its host is disposable, and the guest-side isolation verifier remains fail-closed. + +`private_bus.private_bus()` starts a foreground `dbus-daemon` with an explicit private socket under `/run/nm-isolated-test/`, after the VM and sentinel checks, and terminates and reaps the child on exit. `networkmanager.networkmanager()` composes that bus with the daemon's lifecycle and sets its system bus address explicitly, but its production filesystem isolation verifier always refuses to start: it checks the VM guard, Linux mount namespace and mount table, but cannot establish mount provenance or exclude namespace escape from that evidence alone. Its injectable verifier is exclusively for offline tests, not authorisation to run a daemon. The bus launcher uses `bus.conf`, which names the non-root `nm-isolated` account, and waits for an explicit-address D-Bus Ping before yielding. The account must exist in the guest. Neither the policy nor readiness has been tested against a real bus. The preflight still exits non-zero and must not be interpreted as a successful integration run. diff --git a/tests/nm-isolated/adapter_factory.py b/tests/nm-isolated/adapter_factory.py new file mode 100644 index 00000000..5a69a2de --- /dev/null +++ b/tests/nm-isolated/adapter_factory.py @@ -0,0 +1,37 @@ +"""Compose scenario adapter ports for an isolated guest fixture.""" + +from contextlib import contextmanager + +import client_launcher +import daemon_capture +from fixture_lifetime import FixtureLifetime +import lifecycle_ports +import scenario_bus + + +@contextmanager +def adapter_factory(*, bus, authority, daemon, profile, timeout, dbus_module=None, launcher_factory=client_launcher.Launcher): + """Bind private bus, profile and child lifetimes; caller must enforce isolation.""" + if dbus_module is None: + import dbus as dbus_module + import dbus.bus + address, bus_process = bus + daemon_address, daemon_process, capture = daemon + if address != daemon_address: + raise RuntimeError("daemon and fixture buses differ") + path, profile_uuid = profile + connection = dbus_module.bus.BusConnection(address) + try: + owner_name = str(dbus_module.Interface(connection.get_object("org.freedesktop.DBus", "/org/freedesktop/DBus", introspect=False), "org.freedesktop.DBus").GetNameOwner("org.freedesktop.NetworkManager", timeout=timeout)) + control = dbus_module.Interface(connection.get_object("org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority", introspect=False), "test.nm.IsolatedControl") + lifetime = FixtureLifetime(lambda *, timeout: None, timeout) + reader = scenario_bus.ScenarioBus(connection, dbus_module, path, timeout) + clients = lifecycle_ports.Clients(launcher_factory(path)) + observer = scenario_bus.AuditScenarioObserver(daemon_capture.CaptureReader(capture, owner_name), owner_name, None, profile_uuid) + adapter = scenario_bus.ScenarioAdapter(reader, clients, lifecycle_ports.Authority(control, lifetime), observer, lifecycle_ports.Daemon(), timeout) + try: + yield adapter + finally: + clients.close() + finally: + connection.close() diff --git a/tests/nm-isolated/audit_log.py b/tests/nm-isolated/audit_log.py new file mode 100644 index 00000000..09281b0d --- /dev/null +++ b/tests/nm-isolated/audit_log.py @@ -0,0 +1,59 @@ +"""Parse NetworkManager 1.54.3 audit log text from an injected capture stream.""" + +import re + +FIELD = re.compile(r'([a-z][a-z0-9_-]*)=(?:"([^"\n]*)"|([0-9]+))(?: |$)') + + +def parse_line(line, daemon_instance): + """Parse the audit suffix; reject malformed or duplicate fields.""" + marker = "audit: " + if marker not in line: + return None + text = line.split(marker, 1)[1].rstrip("\n") + values = {} + position = 0 + while position < len(text): + match = FIELD.match(text, position) + if match is None or match.group(1) in values: + raise ValueError("malformed NetworkManager audit event") + values[match.group(1)] = match.group(2) if match.group(2) is not None else int(match.group(3)) + position = match.end() + values["daemon_instance"] = daemon_instance + return values + + +class CaptureReader: + """Read complete lines after byte cursors from one daemon's captured output. + + The stream must be exclusive to the identified daemon process. It must not be truncated or replaced; those conditions raise instead of silently skipping. + """ + + def __init__(self, stream, daemon_instance): + self.stream = stream + self.daemon_instance = daemon_instance + + def mark(self): + self.stream.seek(0, 2) + return self.stream.tell() + + def read_after(self, cursor, *, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.stream.seek(0, 2) + if self.stream.tell() < cursor: + raise RuntimeError("audit capture was truncated") + self.stream.seek(cursor) + records = [] + while True: + position = self.stream.tell() + line = self.stream.readline() + if not line or not line.endswith(b"\n"): + return records, position + try: + text = line.decode("utf-8") + except UnicodeDecodeError: + raise ValueError("invalid audit capture encoding") from None + record = parse_line(text, self.daemon_instance) + if record is not None: + records.append(record) diff --git a/tests/nm-isolated/audit_observer.py b/tests/nm-isolated/audit_observer.py new file mode 100644 index 00000000..05e8021d --- /dev/null +++ b/tests/nm-isolated/audit_observer.py @@ -0,0 +1,57 @@ +"""Correlate synthetic or captured NetworkManager audit events for a test run. + +The reader supplies structured records and opaque cursors; no journal or host logging service is opened by this module. +""" + +import time + + +class AuditObserver: + def __init__(self, reader, daemon_instance, pid, profile_uuid, cursor, *, clock=time.monotonic, pause=time.sleep): + self.reader = reader + self.daemon_instance = daemon_instance + self.pid = pid + self.profile_uuid = profile_uuid + self.cursor = cursor + self.clock = clock + self.pause = pause + + def wait(self, request, *, timeout): + """Return on one matching batch; multiple matches in that batch are inconclusive. + + The fixture must guarantee that the dedicated client sends exactly one update. Without that invariant, a later duplicate cannot be ruled out. + + Reader.read_after(cursor, timeout) returns (records, new_cursor). Records are parsed by the capture adapter, which must preserve ordering and must not fabricate a daemon identity, cursor or audit field. + """ + if timeout <= 0: + raise ValueError("timeout must be positive") + deadline = self.clock() + timeout + matches = [] + cursor = self.cursor + while True: + remaining = deadline - self.clock() + if remaining <= 0: + break + records, cursor = self.reader.read_after(cursor, timeout=remaining) + for record in records: + if (record.get("daemon_instance") == self.daemon_instance + and record.get("pid") == self.pid + and record.get("uuid") == self.profile_uuid + and record.get("op") == "connection-update"): + matches.append(record) + if len(matches) > 1: + return "inconclusive" + if matches: + break + remaining = deadline - self.clock() + if remaining <= 0: + break + self.pause(min(0.05, remaining)) + if len(matches) != 1: + return "inconclusive" + record = matches[0] + if record.get("result") == "success": + return "applied" + if record.get("result") == "fail" and record.get("reason") == "Update failed because profile changed in the meantime and the version-id mismatches": + return "version-mismatch" + return "inconclusive" diff --git a/tests/nm-isolated/bus.conf b/tests/nm-isolated/bus.conf new file mode 100644 index 00000000..df1e6214 --- /dev/null +++ b/tests/nm-isolated/bus.conf @@ -0,0 +1,21 @@ + + + session + EXTERNAL + unix:path=/run/nm-isolated-test/bus + + + + + + + + + + + + + + + + diff --git a/tests/nm-isolated/client_launcher.py b/tests/nm-isolated/client_launcher.py new file mode 100644 index 00000000..64bdefc1 --- /dev/null +++ b/tests/nm-isolated/client_launcher.py @@ -0,0 +1,84 @@ +"""Guarded non-root fixture client process launcher. + +The production isolation check deliberately refuses until the VM boundary is verified. Process and identity dependencies can be injected for offline tests. +""" + +import os +import pwd +import select +import subprocess +import time +from pathlib import Path + +import networkmanager +import private_bus + + +class Child: + def __init__(self, process): + self.process = process + + @property + def pid(self): + return self.process.pid + + def read_request_id(self, *, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + deadline = time.monotonic() + timeout + line = bytearray() + while len(line) <= 256: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("held client did not report a request") + readable, _, _ = select.select([self.process.stdout], [], [], remaining) + if not readable: + raise TimeoutError("held client did not report a request") + part = os.read(self.process.stdout.fileno(), 1) + if not part: + raise RuntimeError("held client exited before request id") + if part == b"\n": + return line.decode("utf-8") + line.extend(part) + raise RuntimeError("invalid held client response") + + def wait(self, *, timeout): + return self.process.wait(timeout=timeout) + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.process.stdout.close() + + +class Launcher: + """Start the fixed client as the named non-root guest account.""" + + def __init__(self, profile_path, *, guard=networkmanager.verified_isolation, lookup=pwd.getpwnam, popen=subprocess.Popen): + self.profile_path = profile_path + self.guard = guard + self.lookup = lookup + self.popen = popen + + def start(self, *, metric, unfenced, disconnect_after_held, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.guard() + account = self.lookup("nm-isolated") + if account.pw_uid == 0 or account.pw_gid == 0: + raise RuntimeError("fixture account must be non-root") + client = Path(__file__).with_name("update2_client.py") + command = ["/usr/bin/python3", str(client), "--private-bus-address", private_bus.ADDRESS, "--profile-path", self.profile_path, "--timeout", str(timeout)] + if metric is not None: + command.extend(["--metric", str(metric)]) + if unfenced: + command.append("--unfenced") + if disconnect_after_held: + command.append("--disconnect-after-held") + process = self.popen(command, user=account.pw_uid, group=account.pw_gid, extra_groups=(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, close_fds=True) + return Child(process) diff --git a/tests/nm-isolated/daemon_capture.py b/tests/nm-isolated/daemon_capture.py new file mode 100644 index 00000000..d611a035 --- /dev/null +++ b/tests/nm-isolated/daemon_capture.py @@ -0,0 +1,62 @@ +"""Bounded, append-only capture of a fixture daemon's output pipe.""" + +import io +import threading + + +class Capture: + """Drain the daemon pipe independently and fail closed on overflow.""" + + def __init__(self, pipe, limit): + if limit <= 0: + raise ValueError("capture limit must be positive") + self.pipe = pipe + self.limit = limit + self.data = bytearray() + self.failure = None + self.lock = threading.Lock() + self.thread = threading.Thread(target=self._drain, daemon=True) + self.thread.start() + + def _drain(self): + try: + while True: + chunk = self.pipe.read1(4096) + if not chunk: + return + with self.lock: + if len(self.data) + len(chunk) > self.limit: + self.failure = RuntimeError("daemon capture limit exceeded") + elif self.failure is None: + self.data.extend(chunk) + except Exception as error: + with self.lock: + self.failure = error + + def snapshot(self): + with self.lock: + if self.failure: + raise RuntimeError("daemon capture failed") from self.failure + return bytes(self.data) + + def join(self, timeout): + self.thread.join(timeout) + if self.thread.is_alive(): + raise TimeoutError("daemon capture did not finish") + self.snapshot() + + +class CaptureReader: + """Adapt a capture snapshot to the audit parser's byte cursor contract.""" + + def __init__(self, capture, daemon_instance): + self.capture = capture + self.daemon_instance = daemon_instance + + def mark(self): + return len(self.capture.snapshot()) + + def read_after(self, cursor, *, timeout): + from audit_log import CaptureReader as TextReader + + return TextReader(io.BytesIO(self.capture.snapshot()), self.daemon_instance).read_after(cursor, timeout=timeout) diff --git a/tests/nm-isolated/fixture_lifetime.py b/tests/nm-isolated/fixture_lifetime.py new file mode 100644 index 00000000..7c14578e --- /dev/null +++ b/tests/nm-isolated/fixture_lifetime.py @@ -0,0 +1,30 @@ +"""Shared fail-closed lifetime for one fixture authority and bus.""" + + +class FixtureLifetime: + """Retire all aliases and invoke the fixture owner's bounded teardown once. + + Teardown must stop both authority and bus and report failure; this object cannot independently prove a supplied teardown actually did so. + """ + + def __init__(self, teardown, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.teardown = teardown + self.timeout = timeout + self.retired = False + self.teardown_error = None + + def check(self): + if self.retired: + raise RuntimeError("fixture lifetime is retired") + + def retire(self): + if self.retired: + return + self.retired = True + try: + self.teardown(timeout=self.timeout) + except BaseException as error: + self.teardown_error = error + raise diff --git a/tests/nm-isolated/fixture_owner.py b/tests/nm-isolated/fixture_owner.py new file mode 100644 index 00000000..caafe1be --- /dev/null +++ b/tests/nm-isolated/fixture_owner.py @@ -0,0 +1,66 @@ +"""Own fixture child processes and the authority wrapper lifetime.""" + +import subprocess +import threading +import time + +from fixture_lifetime import FixtureLifetime +from lifecycle_ports import Authority + + +class FixtureOwner: + """Claim a control object and tear down authority and bus on retirement. + + Claims are process-local and retained after retirement. The production isolation guard must run before constructing an owner around real children. + """ + + _claims = {} + _lock = threading.Lock() + + def __init__(self, control, authority_process, bus_process, *, timeout, guard): + if timeout <= 0: + raise ValueError("timeout must be positive") + guard() + if authority_process is bus_process: + raise ValueError("authority and bus must be distinct children") + with self._lock: + key = id(control) + if key in self._claims: + raise RuntimeError("control already owned") + self._claims[key] = control + self.control = control + self.authority_process = authority_process + self.bus_process = bus_process + self.lifetime = FixtureLifetime(self._teardown, timeout) + + def authority(self): + return Authority(self.control, self.lifetime) + + def retire(self): + self.lifetime.retire() + + def _teardown(self, *, timeout): + deadline = time.monotonic() + timeout + errors = [] + for process in (self.authority_process, self.bus_process): + try: + if process.poll() is None: + process.terminate() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("fixture child", timeout) + process.wait(timeout=remaining) + except BaseException as error: + errors.append(error) + try: + if process.poll() is None: + process.kill() + remaining = deadline - time.monotonic() + if remaining > 0: + process.wait(timeout=remaining) + else: + errors.append(TimeoutError("no time left to reap fixture child")) + except BaseException as cleanup_error: + errors.append(cleanup_error) + if errors: + raise RuntimeError("fixture teardown failed") from errors[0] diff --git a/tests/nm-isolated/guest_agent.py b/tests/nm-isolated/guest_agent.py new file mode 100644 index 00000000..a8d2eb97 --- /dev/null +++ b/tests/nm-isolated/guest_agent.py @@ -0,0 +1,91 @@ +"""Guest virtio-serial readiness agent; no commands or services are started.""" + +import argparse +import os +import select +import math +import time + +import guest_transport +import guest_runner + +DEVICE = "/dev/virtio-ports/test.nm.fixture" + + +class DeviceStream: + """Deadline-bound, unbuffered access to a nonblocking virtio serial fd.""" + + def __init__(self, fd, timeout, *, clock=time.monotonic, wait=select.select, read=os.read, write=os.write): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.fd, self.clock, self.wait, self.read, self.write_fd = fd, clock, wait, read, write + self.deadline = clock() + timeout + + def _wait(self, writing): + remaining = self.deadline - self.clock() + if remaining <= 0: + raise TimeoutError("guest transport deadline expired") + readable, writable, _ = self.wait([] if writing else [self.fd], [self.fd] if writing else [], [], remaining) + if not (writable if writing else readable): + raise TimeoutError("guest transport deadline expired") + + def readline(self, limit): + result = bytearray() + while len(result) < limit: + self._wait(False) + try: + part = self.read(self.fd, 1) + except BlockingIOError: + continue + if not part: + break + result.extend(part) + if part == b"\n": + break + return bytes(result) + + def write(self, data): + remaining = memoryview(data) + while remaining: + self._wait(True) + try: + count = self.write_fd(self.fd, remaining) + except BlockingIOError: + continue + if count == 0: + raise ConnectionError("guest transport closed during write") + remaining = remaining[count:] + + def flush(self): + pass + + +def scenario_handler(run_scenario): + """Build a restricted scenario handler around an injected runner.""" + def handle(payload): + if set(payload) != {"scenario", "metric", "timeout"} or payload["scenario"] not in ("no-fence", "fenced") or type(payload["metric"]) is not int or type(payload["timeout"]) not in (int, float) or not math.isfinite(payload["timeout"]) or not 0 < payload["timeout"] <= 300: + raise ValueError("invalid scenario request") + return run_scenario(payload["scenario"], payload["metric"], payload["timeout"]) + return handle + + +def serve(fd, timeout, *, run_scenario=None): + """Serve readiness and, when supplied, one scenario request.""" + guest_transport.serve_one(DeviceStream(fd, timeout), {"ready": guest_transport.ready}) + if run_scenario is not None: + guest_transport.serve_one(DeviceStream(fd, timeout), {"scenario": scenario_handler(run_scenario)}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--timeout", type=float, required=True) + args = parser.parse_args() + fd = os.open(DEVICE, os.O_RDWR | os.O_NONBLOCK) + try: + serve(fd, args.timeout, run_scenario=guest_runner.make_runner(guest_runner.guarded_adapter_factory)) + finally: + os.close(fd) + + +if __name__ == "__main__": + main() diff --git a/tests/nm-isolated/guest_factories.py b/tests/nm-isolated/guest_factories.py new file mode 100644 index 00000000..cf0d9e17 --- /dev/null +++ b/tests/nm-isolated/guest_factories.py @@ -0,0 +1,135 @@ +"""Private bus, PolicyKit stub and daemon factories for the guarded guest owner.""" + +import subprocess +import time +import uuid +from contextlib import contextmanager +from pathlib import Path + +import networkmanager +import private_bus + + +@contextmanager +def bus_factory(*, timeout): + with private_bus.private_bus(readiness_timeout=timeout, shutdown_timeout=timeout) as bus: + yield bus + + +def authority_ready(process, address, timeout, *, run=subprocess.run, clock=time.monotonic, pause=time.sleep): + deadline = clock() + timeout + while True: + if process.poll() is not None: + raise RuntimeError("authority exited before readiness") + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("authority readiness deadline expired") + result = run(["dbus-send", f"--bus={address}", "--print-reply", f"--reply-timeout={max(1, int(remaining * 1000))}", "--dest=org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority", "test.nm.IsolatedControl.Pending"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=remaining, check=False) + if result.returncode == 0: + if process.poll() is not None: + raise RuntimeError("authority exited during readiness") + return + remaining = deadline - clock() + if remaining > 0: + pause(min(0.05, remaining)) + + +@contextmanager +def authority_factory(*, bus, timeout, popen=subprocess.Popen, ready=authority_ready): + address, bus_process = bus + if address != private_bus.ADDRESS or bus_process.poll() is not None: + raise RuntimeError("private bus is not running") + script = Path(__file__).with_name("polkit_stub.py") + process = popen(["/usr/bin/python3", str(script), "--private-bus-address", address], stdin=subprocess.DEVNULL) + try: + ready(process, address, timeout) + yield process + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=timeout) + + +@contextmanager +def profile_factory(*, bus, daemon, timeout, dbus_module=None): + """Create and delete one unsaved, inactive Ethernet test profile.""" + address, bus_process = bus + if address != private_bus.ADDRESS or bus_process.poll() is not None: + raise RuntimeError("private bus is not running") + if dbus_module is None: + import dbus as dbus_module + import dbus.bus + connection = dbus_module.bus.BusConnection(address) + try: + settings = dbus_module.Interface(connection.get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings", introspect=False), "org.freedesktop.NetworkManager.Settings") + identifier = str(uuid.uuid4()) + data = dbus_module.Dictionary({ + "connection": dbus_module.Dictionary({"id": "nm-isolated-" + identifier, "uuid": identifier, "type": "802-3-ethernet", "autoconnect": dbus_module.Boolean(False)}, signature="sv"), + "802-3-ethernet": dbus_module.Dictionary({}, signature="sv"), + "ipv4": dbus_module.Dictionary({"method": "disabled", "route-metric": dbus_module.Int64(600)}, signature="sv"), + "ipv6": dbus_module.Dictionary({"method": "disabled"}, signature="sv"), + }, signature="sa{sv}") + path = settings.AddConnectionUnsaved(data, timeout=timeout) + try: + yield str(path), identifier + finally: + profile = dbus_module.Interface(connection.get_object("org.freedesktop.NetworkManager", path, introspect=False), "org.freedesktop.NetworkManager.Settings.Connection") + profile.Delete(timeout=timeout) + finally: + connection.close() + + +def daemon_ready(process, address, timeout, *, connect=None, clock=time.monotonic, pause=time.sleep): + if address != private_bus.ADDRESS or timeout <= 0: + raise ValueError("private bus and positive timeout required") + if connect is None: + import dbus + import dbus.bus + def connect(address): + connection = dbus.bus.BusConnection(address) + def interfaces(): + bus = dbus.Interface(connection.get_object("org.freedesktop.DBus", "/org/freedesktop/DBus", introspect=False), "org.freedesktop.DBus") + settings = dbus.Interface(connection.get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings", introspect=False), "org.freedesktop.NetworkManager.Settings") + return connection, bus, settings + return interfaces() + deadline = clock() + timeout + connection, bus, settings = connect(address) + try: + def remaining(): + value = deadline - clock() + if value <= 0: + raise TimeoutError("daemon readiness deadline expired") + return value + while True: + if process.poll() is not None: + raise RuntimeError("daemon exited before readiness") + try: + owner = str(bus.GetNameOwner("org.freedesktop.NetworkManager", timeout=remaining())) + except Exception as error: + if getattr(error, "get_dbus_name", lambda: None)() != "org.freedesktop.DBus.Error.NameHasNoOwner": + raise + pause(min(0.05, remaining())) + continue + if not owner.startswith(":") or int(bus.GetConnectionUnixProcessID(owner, timeout=remaining())) != process.pid: + raise RuntimeError("NetworkManager name owned by unexpected process") + settings.ListConnections(timeout=remaining()) + if process.poll() is not None or str(bus.GetNameOwner("org.freedesktop.NetworkManager", timeout=remaining())) != owner: + raise RuntimeError("daemon owner changed during readiness") + return + finally: + connection.close() + + +@contextmanager +def daemon_factory(*, bus, timeout, daemon=networkmanager.networkmanager, ready=daemon_ready): + @contextmanager + def existing_bus(): + yield bus + with daemon(bus_factory=existing_bus, shutdown_timeout=timeout) as result: + address, process, _ = result + ready(process, address, timeout) + yield result diff --git a/tests/nm-isolated/guest_manifest.json b/tests/nm-isolated/guest_manifest.json new file mode 100644 index 00000000..ae5ca1be --- /dev/null +++ b/tests/nm-isolated/guest_manifest.json @@ -0,0 +1,9 @@ +{ + "schema": 1, + "platform": "debian", + "networkmanager_version": "1.54.3", + "packages": ["dbus", "network-manager", "python3", "python3-dbus", "python3-gi", "systemd"] , + "files": ["adapter_factory.py", "audit_log.py", "audit_observer.py", "bus.conf", "client_launcher.py", "daemon_capture.py", "fixture_lifetime.py", "fixture_owner.py", "guest_agent.py", "guest_factories.py", "guest_owner.py", "guest_runner.py", "guest_transport.py", "lifecycle_ports.py", "networkmanager.py", "polkit_stub.py", "preflight.sh", "private_bus.py", "scenario_bus.py", "scenarios.py", "update2_client.py"], + "destination": "/opt/nm-isolated-test", + "virtio_port": "test.nm.fixture" +} diff --git a/tests/nm-isolated/guest_owner.py b/tests/nm-isolated/guest_owner.py new file mode 100644 index 00000000..3262d312 --- /dev/null +++ b/tests/nm-isolated/guest_owner.py @@ -0,0 +1,25 @@ +"""Guarded composition of a guest's private fixture lifecycle.""" + +from contextlib import ExitStack, contextmanager + +import networkmanager +import guest_factories +import adapter_factory as scenario_factory + + +@contextmanager +def guest_owner(*, timeout, adapter_factory=scenario_factory.adapter_factory, profile_factory=guest_factories.profile_factory, bus_factory=guest_factories.bus_factory, authority_factory=guest_factories.authority_factory, daemon_factory=guest_factories.daemon_factory, guard=networkmanager.verified_isolation): + """Own bus, authority, daemon, test profile and adapter in teardown order. + + Factories are injected context managers. Production isolation verification currently always refuses, and no concrete profile factory is supplied. + """ + if timeout <= 0: + raise ValueError("timeout must be positive") + guard() + with ExitStack() as stack: + bus = stack.enter_context(bus_factory(timeout=timeout)) + authority = stack.enter_context(authority_factory(bus=bus, timeout=timeout)) + daemon = stack.enter_context(daemon_factory(bus=bus, timeout=timeout)) + profile = stack.enter_context(profile_factory(bus=bus, daemon=daemon, timeout=timeout)) + adapter = stack.enter_context(adapter_factory(bus=bus, authority=authority, daemon=daemon, profile=profile, timeout=timeout)) + yield adapter diff --git a/tests/nm-isolated/guest_provision.py b/tests/nm-isolated/guest_provision.py new file mode 100644 index 00000000..e50a40b2 --- /dev/null +++ b/tests/nm-isolated/guest_provision.py @@ -0,0 +1,90 @@ +"""Validate a prebuilt guest's provisioning manifest and installed prerequisites. + +Validation is read-only. Provisioning remains disabled until host-side disposable-VM provenance can be established; it does not start services. +""" + +import hashlib +import json +import os +import subprocess +from pathlib import Path + +DESTINATION = "/opt/nm-isolated-test" +PACKAGES = ("dbus", "network-manager", "python3", "python3-dbus", "python3-gi", "systemd") +FILES = ("adapter_factory.py", "audit_log.py", "audit_observer.py", "bus.conf", "client_launcher.py", "daemon_capture.py", "fixture_lifetime.py", "fixture_owner.py", "guest_agent.py", "guest_factories.py", "guest_owner.py", "guest_runner.py", "guest_transport.py", "lifecycle_ports.py", "networkmanager.py", "polkit_stub.py", "preflight.sh", "private_bus.py", "scenario_bus.py", "scenarios.py", "update2_client.py") + + +def validate_manifest(value): + """Reject any manifest differing from the intended fixed provisioning set.""" + expected = {"schema": 1, "platform": "debian", "networkmanager_version": "1.54.3", "packages": list(PACKAGES), "files": list(FILES), "destination": DESTINATION, "virtio_port": "test.nm.fixture"} + if value != expected: + raise ValueError("unexpected guest provisioning manifest") + + +def validate_guest(manifest, *, run=subprocess.run, is_file=Path.is_file): + """Check installed package status, daemon version and fixture files.""" + validate_manifest(manifest) + for package in PACKAGES: + result = run(["dpkg-query", "-W", "-f=${Status}", package], check=True, capture_output=True, text=True) + if result.stdout != "install ok installed": + raise RuntimeError(f"package not installed: {package}") + result = run(["NetworkManager", "--version"], check=True, capture_output=True, text=True) + if result.stdout.strip() != manifest["networkmanager_version"]: + raise RuntimeError("NetworkManager version mismatch") + for name in FILES: + if not is_file(Path(DESTINATION) / name): + raise RuntimeError(f"fixture file missing: {name}") + + +def provision(source, manifest, expected_hashes, *, guard, run=subprocess.run, install=None): + """Validate trusted inputs then install files via an injected installation port. + + The production guard is deliberately unavailable until VM provenance can be established by the host. The caller-supplied hashes must come from an independently trusted source. + """ + guard() + validate_manifest(manifest) + if set(expected_hashes) != set(FILES): + raise ValueError("a trusted digest is required for every fixture file") + contents = {} + for name in FILES: + item = Path(source) / name + if item.is_symlink() or not item.is_file(): + raise ValueError(f"invalid fixture source: {name}") + data = item.read_bytes() + if hashlib.sha256(data).hexdigest() != expected_hashes[name]: + raise ValueError(f"fixture digest mismatch: {name}") + contents[name] = data + for package in PACKAGES: + result = run(["dpkg-query", "-W", "-f=${Status}", package], check=True, capture_output=True, text=True) + if result.stdout != "install ok installed": + raise RuntimeError(f"package not installed: {package}") + result = run(["NetworkManager", "--version"], check=True, capture_output=True, text=True) + if result.stdout.strip() != manifest["networkmanager_version"]: + raise RuntimeError("NetworkManager version mismatch") + if install is None: + install = install_files + install(contents) + + +def install_files(contents): + """Create a new fixture directory without overwriting existing files.""" + destination = Path(DESTINATION) + destination.mkdir(mode=0o755) + for name, data in contents.items(): + fd = os.open(destination / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + + +def refuse_unverified_guest(): + """Refuse writes until host-side disposable-VM provenance is enforced.""" + raise RuntimeError("refusing: disposable VM provenance is not verified") + + +def main(): + manifest_path = Path(__file__).with_name("guest_manifest.json") + validate_guest(json.loads(manifest_path.read_text(encoding="utf-8"))) + + +if __name__ == "__main__": + main() diff --git a/tests/nm-isolated/guest_runner.py b/tests/nm-isolated/guest_runner.py new file mode 100644 index 00000000..0789bf4d --- /dev/null +++ b/tests/nm-isolated/guest_runner.py @@ -0,0 +1,25 @@ +"""Dispatch fixture scenarios through a supplied guarded adapter context.""" + +from dataclasses import asdict + +import scenarios +import guest_owner + + +def make_runner(adapter_factory): + """Create a runner; the factory owns setup, isolation and teardown.""" + def run(name, metric, timeout): + with adapter_factory(timeout=timeout) as adapter: + if name == "no-fence": + before, after = scenarios.no_fence(adapter, metric) + return {"before": asdict(before), "after": asdict(after)} + if name == "fenced": + before, fence, after = scenarios.fenced(adapter, metric) + return {"before": asdict(before), "fence": asdict(fence), "after": asdict(after)} + raise ValueError("unknown scenario") + return run + + +def guarded_adapter_factory(*, timeout): + """Compose the guest owner, whose production guard still refuses startup.""" + return guest_owner.guest_owner(timeout=timeout) diff --git a/tests/nm-isolated/guest_transport.py b/tests/nm-isolated/guest_transport.py new file mode 100644 index 00000000..e42ff90e --- /dev/null +++ b/tests/nm-isolated/guest_transport.py @@ -0,0 +1,101 @@ +"""Bounded JSON-lines transport for a future isolated guest serial channel. + +This module does not provision or start a guest. Callers supply a connected binary stream and guest-side handlers; neither endpoint opens a host device. +""" + +import json +import time + +MAX_FRAME = 65536 + + +class DeadlineStream: + """Unbuffered socket stream with one monotonic deadline for all operations.""" + + def __init__(self, sock, timeout, clock=time.monotonic): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.sock = sock + self.clock = clock + self.deadline = clock() + timeout + + def _remaining(self): + remaining = self.deadline - self.clock() + if remaining <= 0: + raise TimeoutError("transport deadline expired") + self.sock.settimeout(remaining) + + def write(self, data): + view = memoryview(data) + while view: + self._remaining() + count = self.sock.send(view) + if count == 0: + raise ConnectionError("transport closed during write") + view = view[count:] + + def flush(self): + pass + + def readline(self, limit): + data = bytearray() + while len(data) < limit: + self._remaining() + chunk = self.sock.recv(1) + if not chunk: + break + data.extend(chunk) + if chunk == b"\n": + break + return bytes(data) + + +def ready(payload): + """Report that this handler is responsive, not that isolation is proven.""" + if payload: + raise ValueError("readiness payload must be empty") + return {"ready": True} + + +def send(stream, value): + """Send one bounded JSON object to the other endpoint.""" + encoded = json.dumps(value, separators=(",", ":")).encode("utf-8") + b"\n" + if len(encoded) > MAX_FRAME: + raise ValueError("frame exceeds limit") + stream.write(encoded) + stream.flush() + + +def receive(stream): + """Read one complete bounded JSON object, rejecting malformed frames.""" + line = stream.readline(MAX_FRAME + 1) + if len(line) > MAX_FRAME or not line.endswith(b"\n"): + raise ValueError("incomplete or oversized frame") + try: + value = json.loads(line) + except (ValueError, UnicodeDecodeError): + raise ValueError("invalid JSON frame") from None + if not isinstance(value, dict): + raise ValueError("frame must be an object") + return value + + +def serve_one(stream, handlers): + """Dispatch a named fixed handler; never interpret guest input as a command.""" + request = receive(stream) + if set(request) != {"id", "operation", "payload"} or not isinstance(request["id"], str) or not isinstance(request["operation"], str) or not isinstance(request["payload"], dict): + raise ValueError("invalid request") + operation = request["operation"] + if operation not in handlers: + raise ValueError("unknown operation") + result = handlers[operation](request["payload"]) + send(stream, {"id": request["id"], "result": result}) + + +def request(stream, request_id, operation, payload): + """Send a request and verify that the response corresponds to it.""" + send(stream, {"id": request_id, "operation": operation, "payload": payload}) + response = receive(stream) + if set(response) != {"id", "result"} or response["id"] != request_id: + raise ValueError("response does not match request") + return response["result"] diff --git a/tests/nm-isolated/host_transport.py b/tests/nm-isolated/host_transport.py new file mode 100644 index 00000000..8f67c724 --- /dev/null +++ b/tests/nm-isolated/host_transport.py @@ -0,0 +1,52 @@ +"""Connect to a fixture VM's private virtio-serial socket and check readiness.""" + +import errno +import socket +import time +from pathlib import Path + +import guest_transport + + +def connect_ready(workspace, process, timeout, *, socket_factory=socket.socket, clock=time.monotonic, pause=time.sleep): + """Connect and perform one readiness exchange within one deadline. + + The returned socket belongs to the caller. A ready reply confirms only that the guest agent responded, not that the VM or guest is safely isolated. + """ + if timeout <= 0: + raise ValueError("timeout must be positive") + path = str(Path(workspace) / "guest.sock") + deadline = clock() + timeout + while True: + if process.poll() is not None: + raise RuntimeError("QEMU exited before guest readiness") + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("guest readiness deadline expired") + sock = socket_factory(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(remaining) + sock.connect(path) + except OSError as error: + sock.close() + if error.errno not in (errno.ENOENT, errno.ECONNREFUSED): + raise + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("guest readiness deadline expired") from error + pause(min(0.05, remaining)) + continue + try: + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("guest readiness deadline expired") + stream = guest_transport.DeadlineStream(sock, remaining, clock=clock) + result = guest_transport.request(stream, "readiness", "ready", {}) + if result != {"ready": True}: + raise RuntimeError("guest did not report readiness") + if process.poll() is not None: + raise RuntimeError("QEMU exited during guest readiness") + return sock + except BaseException: + sock.close() + raise diff --git a/tests/nm-isolated/lifecycle_ports.py b/tests/nm-isolated/lifecycle_ports.py new file mode 100644 index 00000000..6e782cdf --- /dev/null +++ b/tests/nm-isolated/lifecycle_ports.py @@ -0,0 +1,84 @@ +"""Fixture scenario lifecycle ports using injected child-process and bus interfaces. + +The child launcher must enforce the disposable VM guard and non-root identity. No default launcher is provided; these ports do not establish isolation. +""" + + +class Clients: + def __init__(self, launcher): + self.launcher = launcher + self.requests = {} + + def submit_held(self, *, metric, fenced, timeout): + child = self.launcher.start(metric=metric, unfenced=not fenced, disconnect_after_held=True, timeout=timeout) + try: + request = child.read_request_id(timeout=timeout) + if not request or request in self.requests: + raise RuntimeError("invalid or duplicate held request id") + self.requests[request] = child + return request + except BaseException: + child.close() + raise + + def close(self): + errors = [] + for child in self.requests.values(): + try: + child.close() + except Exception as error: + errors.append(error) + self.requests.clear() + if errors: + raise RuntimeError("client cleanup failed") from errors[0] + + def pid(self, request): + return self.requests[request].pid + + def disconnect(self, request, *, timeout): + """Confirm the child has disconnected and exited successfully.""" + child = self.requests[request] + if child.wait(timeout=timeout) != 0: + raise RuntimeError("held client did not disconnect successfully") + + def update_separately(self, *, metric, fenced, timeout): + child = self.launcher.start(metric=metric, unfenced=not fenced, disconnect_after_held=False, timeout=timeout) + try: + if child.wait(timeout=timeout) != 0: + raise RuntimeError("separate writer failed") + finally: + child.close() + + +class Authority: + def __init__(self, control, lifetime): + lifetime.check() + self.control = control + self.lifetime = lifetime + + def retire(self): + self.lifetime.retire() + + def check(self): + self.lifetime.check() + + def allow_later(self, *, timeout): + self.check() + self.control.AllowLater(True, timeout=timeout) + + def hold_later(self, *, timeout): + self.check() + try: + self.control.AllowLater(False, timeout=timeout) + except BaseException: + self.retire() + raise + + def release(self, request, *, timeout): + self.check() + self.control.Release(request, True, timeout=timeout) + + +class Daemon: + def restart(self, *, timeout): + raise RuntimeError("daemon restart is inconclusive: no verified lifecycle") diff --git a/tests/nm-isolated/networkmanager.py b/tests/nm-isolated/networkmanager.py new file mode 100644 index 00000000..93c98c77 --- /dev/null +++ b/tests/nm-isolated/networkmanager.py @@ -0,0 +1,69 @@ +"""Fail-closed lifecycle composition for an isolated NetworkManager fixture.""" + +import os +import subprocess +from contextlib import contextmanager + +import private_bus +from daemon_capture import Capture + + +def verified_isolation(*, vm_check=private_bus.check_environment, readlink=os.readlink, read_text=None): + """Check isolation evidence and refuse without a trusted filesystem provenance proof.""" + vm_check() + if read_text is None: + def read_text(name): + with open(name, encoding="utf-8") as stream: + return stream.read() + if readlink("/proc/self/ns/mnt") == readlink("/proc/1/ns/mnt"): + raise RuntimeError("refusing: mount namespace is shared with VM init") + mounts = read_text("/proc/self/mountinfo").splitlines() + if not mounts: + raise RuntimeError("refusing: mount table is empty") + root_seen = False + for line in mounts: + fields = line.split() + try: + separator = fields.index("-") + mount_root, mountpoint = fields[3:5] + filesystem = fields[separator + 1] + except (ValueError, IndexError): + raise RuntimeError("refusing: malformed mount table") from None + if separator < 6 or mount_root != "/" or filesystem != "tmpfs": + raise RuntimeError("refusing: host or non-isolated filesystem mount") + if mountpoint == "/": + root_seen = True + if not root_seen: + raise RuntimeError("refusing: isolated root mount missing") + raise RuntimeError("refusing: tmpfs mount provenance and namespace escape cannot be verified") + + +@contextmanager +def networkmanager(*, isolation_check=verified_isolation, bus_factory=private_bus.private_bus, popen=subprocess.Popen, capture_factory=Capture, capture_limit=1048576, shutdown_timeout=5): + """Compose the private bus and daemon lifecycle after isolation verification. + + The production verifier inspects isolation evidence but still refuses without mount provenance proof. Injected checks are for offline tests and must not be used to authorise a real daemon. + """ + if shutdown_timeout <= 0 or capture_limit <= 0: + raise ValueError("shutdown timeout and capture limit must be positive") + isolation_check() + with bus_factory() as (address, bus_process): + if address != private_bus.ADDRESS or bus_process.poll() is not None: + raise RuntimeError("refusing: private bus is not the expected running child") + environment = os.environ.copy() + environment["DBUS_SYSTEM_BUS_ADDRESS"] = address + process = popen(["NetworkManager", "--no-daemon", "--log-level=INFO", "--log-domains=ALL"], env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + capture = None + try: + capture = capture_factory(process.stdout, capture_limit) + yield address, process, capture + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=shutdown_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + if capture is not None: + capture.join(shutdown_timeout) diff --git a/tests/nm-isolated/outcome_observer.py b/tests/nm-isolated/outcome_observer.py new file mode 100644 index 00000000..297fed7e --- /dev/null +++ b/tests/nm-isolated/outcome_observer.py @@ -0,0 +1,38 @@ +"""Infer a disconnected request's outcome from controlled, observed state.""" + +import time + + +class OutcomeObserver: + """Compare profile snapshots after an independently confirmed completion barrier. + + The barrier must establish that the daemon finished processing the specific authorisation, not merely that the authority sent a reply. The caller must exclude other writers. An unchanged profile cannot identify a rejection reason. + """ + + def __init__(self, reader, barrier, *, clock=time.monotonic): + self.reader = reader + self.barrier = barrier + self.clock = clock + self.requests = {} + + def register(self, request, before, expected_metric): + if request in self.requests or before.metric == expected_metric: + raise ValueError("request must be unique and change the metric") + self.requests[request] = (before, expected_metric) + + def wait(self, request, *, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + before, expected_metric = self.requests[request] + deadline = self.clock() + timeout + if not self.barrier.finished(request, timeout=timeout): + return "inconclusive" + remaining = deadline - self.clock() + if remaining <= 0: + raise TimeoutError("outcome observation deadline expired") + after = self.reader.profile(timeout=remaining) + if after.metric == expected_metric and after.version > before.version: + return "applied" + if after == before: + return "not-applied" + return "inconclusive" diff --git a/tests/nm-isolated/polkit_stub.py b/tests/nm-isolated/polkit_stub.py new file mode 100644 index 00000000..4273fd54 --- /dev/null +++ b/tests/nm-isolated/polkit_stub.py @@ -0,0 +1,88 @@ +#!/usr/bin/python3 +"""Private-bus-only PolicyKit fixture. Never point it at a system bus.""" + +import argparse +import os +import subprocess +from pathlib import Path + + +class HeldAuthorisation: + """Hold non-root authorisation callbacks until explicitly released.""" + + def __init__(self): + self.pending = {} + self.allow_later = False + + def hold(self, subject, cancellation_id, success, failure): + kind, details = subject + if kind != "unix-process" or "uid" not in details or int(details["uid"]) == 0: + failure("a non-root unix-process subject is required") + return + if not cancellation_id or cancellation_id in self.pending: + failure("invalid or duplicate cancellation id") + return + if self.allow_later: + success((True, False, {})) + else: + self.pending[cancellation_id] = (success, failure) + + def release(self, cancellation_id, allowed): + success, _ = self.pending.pop(cancellation_id) + success((bool(allowed), False, {})) + + def cancel(self, cancellation_id): + _, failure = self.pending.pop(cancellation_id) + failure("cancelled") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--private-bus-address", required=True) + args = parser.parse_args() + sentinel = Path("/run/nm-isolated-test/disposable-vm") + if os.geteuid() != 0 or sentinel.is_symlink() or not sentinel.is_file(): + raise SystemExit("refusing: root and disposable VM sentinel required") + if subprocess.run(["systemd-detect-virt", "--vm", "--quiet"], check=False).returncode: + raise SystemExit("refusing: disposable VM required") + expected_prefix = "unix:path=/run/nm-isolated-test/" + if not args.private_bus_address.startswith(expected_prefix) or "," in args.private_bus_address or ";" in args.private_bus_address or ".." in args.private_bus_address: + raise SystemExit("refusing: dedicated test bus path required") + import dbus + import dbus.service + from dbus.mainloop.glib import DBusGMainLoop + from gi.repository import GLib + + DBusGMainLoop(set_as_default=True) + bus = dbus.bus.BusConnection(args.private_bus_address) + name = dbus.service.BusName("org.freedesktop.PolicyKit1", bus, do_not_queue=True) + held = HeldAuthorisation() + + class Authority(dbus.service.Object): + @dbus.service.method("org.freedesktop.PolicyKit1.Authority", in_signature="(sa{sv})sa{ss}us", out_signature="(bba{ss})", async_callbacks=("reply", "error")) + def CheckAuthorization(self, subject, action, details, flags, cancellation_id, reply, error): + held.hold(subject, str(cancellation_id), reply, lambda message: error(dbus.exceptions.DBusException(message))) + + @dbus.service.method("org.freedesktop.PolicyKit1.Authority", in_signature="s", out_signature="") + def CancelCheckAuthorization(self, cancellation_id): + held.cancel(str(cancellation_id)) + + @dbus.service.method("test.nm.IsolatedControl", in_signature="b", out_signature="") + def AllowLater(self, allowed): + held.allow_later = bool(allowed) + + @dbus.service.method("test.nm.IsolatedControl", in_signature="sb", out_signature="") + def Release(self, cancellation_id, allowed): + held.release(str(cancellation_id), allowed) + + @dbus.service.method("test.nm.IsolatedControl", in_signature="", out_signature="as") + def Pending(self): + return list(held.pending) + + authority = Authority(name, "/org/freedesktop/PolicyKit1/Authority") + GLib.MainLoop().run() + return authority + + +if __name__ == "__main__": + main() diff --git a/tests/nm-isolated/preflight.sh b/tests/nm-isolated/preflight.sh new file mode 100644 index 00000000..8637c4a0 --- /dev/null +++ b/tests/nm-isolated/preflight.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + printf '%s\n' 'This fixture requires root inside a disposable VM.' >&2 + exit 1 +fi +if ! systemd-detect-virt --vm --quiet; then + printf '%s\n' 'Refusing: systemd does not identify a VM.' >&2 + exit 1 +fi +if [ ! -f /run/nm-isolated-test/disposable-vm ] || [ -L /run/nm-isolated-test/disposable-vm ]; then + printf '%s\n' 'Refusing: disposable VM sentinel is missing.' >&2 + exit 1 +fi +printf '%s\n' 'Fixture incomplete: no private bus, daemon or Update2 client is launched.' >&2 +exit 1 diff --git a/tests/nm-isolated/private_bus.py b/tests/nm-isolated/private_bus.py new file mode 100644 index 00000000..8fce87fa --- /dev/null +++ b/tests/nm-isolated/private_bus.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 +"""Lifecycle for a disposable fixture's explicitly addressed private bus.""" + +import os +import subprocess +import time +from contextlib import contextmanager +from pathlib import Path + +ROOT = Path("/run/nm-isolated-test") +SOCKET = ROOT / "bus" +ADDRESS = f"unix:path={SOCKET}" + + +def check_environment(): + """Refuse to launch outside the disposable VM asserted by the operator.""" + sentinel = ROOT / "disposable-vm" + if os.geteuid() != 0 or sentinel.is_symlink() or not sentinel.is_file(): + raise RuntimeError("refusing: root and disposable VM sentinel required") + if subprocess.run(["systemd-detect-virt", "--vm", "--quiet"], check=False).returncode: + raise RuntimeError("refusing: disposable VM required") + if ROOT.is_symlink(): + raise RuntimeError("refusing: test directory is symlinked") + + +def wait_ready(process, timeout, *, run=subprocess.run, clock=time.monotonic, pause=time.sleep): + """Wait for a successful D-Bus Ping on the explicit private address.""" + deadline = clock() + timeout + while True: + if process.poll() is not None: + raise RuntimeError("private bus exited before readiness") + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("private bus readiness deadline expired") + try: + result = run(["dbus-send", f"--bus={ADDRESS}", "--print-reply", f"--reply-timeout={max(1, int(remaining * 1000))}", "--dest=org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.Peer.Ping"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=remaining, check=False) + except subprocess.TimeoutExpired as error: + raise TimeoutError("private bus readiness deadline expired") from error + if result.returncode == 0: + if process.poll() is not None: + raise RuntimeError("private bus exited during readiness") + return + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("private bus readiness deadline expired") + pause(min(0.05, remaining)) + + +@contextmanager +def private_bus(*, check=check_environment, popen=subprocess.Popen, ready=wait_ready, readiness_timeout=5, shutdown_timeout=5): + """Launch a private bus and yield only after its explicit address replies.""" + if shutdown_timeout <= 0 or readiness_timeout <= 0: + raise ValueError("timeouts must be positive") + check() + if SOCKET.is_symlink() or SOCKET.exists(): + raise RuntimeError("refusing: test bus path is occupied or symlinked") + config = Path(__file__).with_name("bus.conf") + process = popen(["dbus-daemon", f"--config-file={config}", "--nofork"], stdin=subprocess.DEVNULL) + try: + ready(process, readiness_timeout) + yield ADDRESS, process + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=shutdown_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/tests/nm-isolated/qemu_vm.py b/tests/nm-isolated/qemu_vm.py new file mode 100644 index 00000000..a5714315 --- /dev/null +++ b/tests/nm-isolated/qemu_vm.py @@ -0,0 +1,104 @@ +"""Source-only lifecycle for a disposable QEMU fixture VM. + +The caller supplies an independently trusted base image and a private, empty working directory. A guest cannot establish whether its host is disposable. +""" + +import json +import os +import stat +import subprocess +import time +import uuid +from contextlib import contextmanager +from pathlib import Path + +# An exec publishes the child's argument area within microseconds of switching its executable; the budget is a hundred times that, so a healthy launch never fails and a stuck child still does. +EMPTY_CMDLINE_INTERVAL = 0.001 +EMPTY_CMDLINE_READS = 100 + + +def verify_process(pid, executable, command, *, readlink=os.readlink, read_bytes=None, sleep=time.sleep, empty_reads=EMPTY_CMDLINE_READS, empty_interval=EMPTY_CMDLINE_INTERVAL): + """Check Linux procfs executable and complete argv against the launched child. + + During exec the kernel switches the executable before it publishes the new argument area, so a healthy child can briefly read as empty. Only an empty read is retried, a bounded number of times; any non-empty difference fails at once. + """ + if read_bytes is None: + read_bytes = lambda name: Path(name).read_bytes() + if readlink(f"/proc/{pid}/exe") != os.path.realpath(executable): + raise RuntimeError("QEMU executable identity mismatch") + raw = read_bytes(f"/proc/{pid}/cmdline") + for _ in range(empty_reads): + if raw: + break + sleep(empty_interval) + raw = read_bytes(f"/proc/{pid}/cmdline") + actual = raw.split(b"\0") + expected = [os.fsencode(arg) for arg in command] + [b""] + if actual != expected: + raise RuntimeError("QEMU command identity mismatch") + + +def validate_base(base, qemu_img, *, run=subprocess.run): + """Accept only an owned, read-only standalone local qcow2 image.""" + base = Path(base) + if not base.is_absolute() or base != base.resolve() or ":" in str(base): + raise ValueError("base must be a canonical local path") + info = base.stat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o222: + raise ValueError("base image must be owned and have no write permissions") + result = run([qemu_img, "info", "--output=json", "--backing-chain", "--", str(base)], check=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + chain = json.loads(result.stdout) + except (ValueError, TypeError): + raise ValueError("invalid qemu-img JSON") from None + if not isinstance(chain, list) or len(chain) != 1 or not isinstance(chain[0], dict): + raise ValueError("base must have no backing chain") + image = chain[0] + if image.get("format") != "qcow2" or image.get("filename") != str(base): + raise ValueError("base image format or filename mismatch") + if any(key in image for key in ("backing-filename", "full-backing-filename", "backing-filename-format", "data-file")): + raise ValueError("external backing or data file is forbidden") + specific = image.get("format-specific") + if not isinstance(specific, dict) or specific.get("type") != "qcow2" or not isinstance(specific.get("data"), dict): + raise ValueError("missing qcow2 format details") + if any(key in specific["data"] for key in ("data-file", "data-file-raw")): + raise ValueError("external data file is forbidden") + + +@contextmanager +def disposable_vm(base_image, directory, *, qemu, qemu_img, run=subprocess.run, popen=subprocess.Popen, verify=verify_process, shutdown_timeout=5): + """Create an overlay and launch a hardware-accelerated VM with no host shares or network devices. + + KVM is required and QEMU refuses to start without it: software emulation boots a guest too slowly for the fixture's bounded waits, so there is no fallback to it. + + The caller owns and retains the workspace and overlay. Paths must not contain commas because QEMU's drive option treats them as delimiters. + """ + base, work = Path(base_image), Path(directory) + if shutdown_timeout <= 0: + raise ValueError("shutdown timeout must be positive") + if not base.is_absolute() or not work.is_absolute() or not base.is_file() or base.is_symlink() or not work.is_dir() or work.is_symlink(): + raise ValueError("absolute base image and private workspace required") + if any("," in str(item) for item in (base, work)) or any(work.iterdir()): + raise ValueError("workspace must be empty and paths must not contain commas") + if work != work.resolve() or work.stat().st_uid != os.geteuid() or work.stat().st_mode & 0o077: + raise ValueError("workspace must be canonical, owned and private") + if not os.path.isabs(qemu) or not os.path.isabs(qemu_img): + raise ValueError("absolute executable paths required") + validate_base(base, qemu_img, run=run) + name = "nm-isolated-" + uuid.uuid4().hex + overlay = work / (name + ".qcow2") + run([qemu_img, "create", "-f", "qcow2", "-F", "qcow2", "-b", str(base), str(overlay)], check=True, stdin=subprocess.DEVNULL) + serial_socket = work / "guest.sock" + command = [qemu, "-name", name, "-accel", "kvm", "-cpu", "host", "-m", "2048", "-smp", "2", "-nodefaults", "-no-user-config", "-display", "none", "-monitor", "none", "-serial", "none", "-nic", "none", "-drive", f"file={overlay},if=virtio,format=qcow2", "-chardev", f"socket,id=fixture,path={serial_socket},server=on,wait=off", "-device", "virtio-serial-pci", "-device", "virtserialport,chardev=fixture,name=test.nm.fixture"] + process = popen(command, stdin=subprocess.DEVNULL) + try: + verify(process.pid, qemu, command) + yield name, overlay, process + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=shutdown_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/tests/nm-isolated/scenario_bus.py b/tests/nm-isolated/scenario_bus.py new file mode 100644 index 00000000..47b29a48 --- /dev/null +++ b/tests/nm-isolated/scenario_bus.py @@ -0,0 +1,161 @@ +"""Read fixture profile state and daemon identity over an injected private bus. + +The lifecycle adapter composes injected client, authority, observer and daemon ports. Real implementations of those ports are not supplied. +""" + +import time +from dataclasses import dataclass + +import update2_client +from audit_observer import AuditObserver + +BUS_SERVICE = "org.freedesktop.DBus" +BUS_PATH = "/org/freedesktop/DBus" +BUS_INTERFACE = "org.freedesktop.DBus" + + +@dataclass(frozen=True) +class Profile: + metric: int + version: int + + +class AuditScenarioObserver: + """Bind a pre-submission capture cursor and unique client identity to one request.""" + + def __init__(self, capture, daemon_instance, pid, profile_uuid, *, clock=time.monotonic, pause=time.sleep): + if not daemon_instance or (pid is not None and pid <= 0) or not profile_uuid: + raise ValueError("daemon instance, client PID and profile UUID required") + self.capture = capture + self.daemon_instance = daemon_instance + self.pid = pid + self.profile_uuid = profile_uuid + self.clock = clock + self.pause = pause + self.pending = {} + + def mark(self): + return self.capture.mark() + + def register(self, request, cursor, pid): + if request in self.pending or pid <= 0 or (self.pid is not None and pid != self.pid): + raise ValueError("duplicate request or unexpected client PID") + self.pending[request] = AuditObserver(self.capture, self.daemon_instance, pid, self.profile_uuid, cursor, clock=self.clock, pause=self.pause) + + def wait(self, request, *, timeout): + return self.pending[request].wait(request, timeout=timeout) + + +class ScenarioAdapter: + """Compose scenario ports with a single deadline for the full scenario run. + + The observer independently records the daemon's outcome after client disconnect. A disconnected client's D-Bus reply cannot establish success or rejection. + """ + + def __init__(self, reader, clients, authority, observer, daemon, timeout, clock=time.monotonic): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.reader, self.clients, self.authority, self.observer, self.daemon = reader, clients, authority, observer, daemon + self.clock = clock + self.deadline = clock() + timeout + self.cleanup_timeout = timeout + self.retired = False + + def remaining(self): + if self.retired: + raise RuntimeError("scenario authority is retired") + value = self.deadline - self.clock() + if value <= 0: + raise TimeoutError("scenario deadline expired") + return value + + def profile(self): + return self.reader.profile(timeout=self.remaining()) + + def owner(self): + return self.reader.owner(timeout=self.remaining()) + + def submit_held(self, *, metric, fenced): + cursor = self.observer.mark() + try: + request = self.clients.submit_held(metric=metric, fenced=fenced, timeout=self.remaining()) + self.observer.register(request, cursor, self.clients.pid(request)) + return request + except BaseException: + self.retire() + raise + + def disconnect(self, request): + self.clients.disconnect(request, timeout=self.remaining()) + + def allow_later(self): + self.authority.allow_later(timeout=self.remaining()) + + def hold_later(self): + try: + self.authority.hold_later(timeout=self.cleanup_timeout) + except BaseException: + self.retire() + raise + + def retire(self): + self.retired = True + self.authority.retire() + + def update_separately(self, *, metric, fenced): + self.clients.update_separately(metric=metric, fenced=fenced, timeout=self.remaining()) + + def release(self, request): + self.authority.release(request, timeout=self.remaining()) + + def await_completion(self, request): + outcome = self.observer.wait(request, timeout=self.remaining()) + if outcome != "applied": + raise AssertionError(f"expected applied outcome, got {outcome}") + + def await_rejection(self, request): + outcome = self.observer.wait(request, timeout=self.remaining()) + if outcome != "version-mismatch": + raise AssertionError(f"expected version mismatch, got {outcome}") + + def restart(self): + self.daemon.restart(timeout=self.remaining()) + + +class ScenarioBus: + """Bounded D-Bus profile and owner reads on a caller-supplied connection.""" + + def __init__(self, bus, dbus, profile_path, timeout): + if timeout <= 0: + raise ValueError("timeout must be positive") + self.bus = bus + self.dbus = dbus + self.profile_path = profile_path + self.timeout = timeout + + def profile(self, *, timeout=None): + timeout = self.timeout if timeout is None else min(timeout, self.timeout) + obj = self.bus.get_object(update2_client.SERVICE, self.profile_path, introspect=False) + properties = self.dbus.Interface(obj, update2_client.PROPERTIES) + connection = self.dbus.Interface(obj, update2_client.CONNECTION) + deadline = time.monotonic() + timeout + def remaining(): + value = deadline - time.monotonic() + if value <= 0: + raise TimeoutError("profile read deadline expired") + return value + before = int(properties.Get(update2_client.CONNECTION, "VersionId", timeout=remaining())) + settings = connection.GetSettings(timeout=remaining()) + after = int(properties.Get(update2_client.CONNECTION, "VersionId", timeout=remaining())) + if before <= 0 or before != after: + raise RuntimeError("profile changed during state read") + return Profile(int(settings["ipv4"]["route-metric"]), after) + + def owner(self, *, timeout=None): + timeout = self.timeout if timeout is None else min(timeout, self.timeout) + obj = self.bus.get_object(BUS_SERVICE, BUS_PATH, introspect=False) + bus = self.dbus.Interface(obj, BUS_INTERFACE) + owner = str(bus.GetNameOwner(update2_client.SERVICE, timeout=timeout)) + if not owner.startswith(":"): + raise RuntimeError("daemon owner is not a unique bus name") + return owner diff --git a/tests/nm-isolated/scenarios.py b/tests/nm-isolated/scenarios.py new file mode 100644 index 00000000..35fe89da --- /dev/null +++ b/tests/nm-isolated/scenarios.py @@ -0,0 +1,64 @@ +"""Assertions for delayed Update2 scenarios over an injected fixture adapter. + +Adapter operations must return only after the described action is confirmed. This module does not open a bus, start services or establish isolation. +""" + + +def no_fence(adapter, metric): + """Confirm an unfenced held update takes effect after its client disconnects.""" + before = adapter.profile() + if before.metric == metric: + raise ValueError("test metric must differ from initial metric") + request = adapter.submit_held(metric=metric, fenced=False) + try: + adapter.disconnect(request) + adapter.release(request) + adapter.await_completion(request) + after = adapter.profile() + if after.metric != metric or after.version <= before.version: + raise AssertionError("unfenced delayed update did not change metric and version") + return before, after + except BaseException: + adapter.retire() + raise + + +def fenced(adapter, metric): + """Confirm an identical separately authorised update fences a held update.""" + before = adapter.profile() + if before.metric == metric: + raise ValueError("test metric must differ from initial metric") + request = adapter.submit_held(metric=metric, fenced=True) + try: + adapter.disconnect(request) + try: + adapter.allow_later() + adapter.update_separately(metric=before.metric, fenced=True) + finally: + adapter.hold_later() + fence = adapter.profile() + if fence.metric != before.metric or fence.version <= before.version: + raise AssertionError("separate identical update did not advance version") + adapter.release(request) + adapter.await_rejection(request) + after = adapter.profile() + if after.metric != before.metric or after.version != fence.version: + raise AssertionError("delayed fenced update changed profile") + return before, fence, after + except BaseException: + adapter.retire() + raise + + +def restart(adapter): + """Confirm daemon identity changes and inspect the resulting profile version.""" + owner_before = adapter.owner() + profile_before = adapter.profile() + adapter.restart() + owner_after = adapter.owner() + profile_after = adapter.profile() + if not owner_before or not owner_after or owner_before == owner_after: + raise AssertionError("daemon unique owner did not change") + if profile_before.version <= 0 or profile_after.version <= 0: + raise AssertionError("profile version must be non-zero") + return owner_before, profile_before, owner_after, profile_after diff --git a/tests/nm-isolated/test_adapter_factory.py b/tests/nm-isolated/test_adapter_factory.py new file mode 100644 index 00000000..cdddfb43 --- /dev/null +++ b/tests/nm-isolated/test_adapter_factory.py @@ -0,0 +1,37 @@ +"""Injected scenario adapter composition tests.""" + +import types +import unittest +from contextlib import contextmanager + +import adapter_factory + + +class Tests(unittest.TestCase): + def test_adapter_composition_and_connection_cleanup(self): + events = [] + class Connection: + def get_object(self, service, path, **kwargs): + return self + def GetNameOwner(self, name, *, timeout): + return ":1.3" + def close(self): + events.append("close") + connection = Connection() + class DBus: + bus = types.SimpleNamespace(BusConnection=lambda address: connection) + @staticmethod + def Interface(obj, interface): + return obj + with adapter_factory.adapter_factory(bus=("private", object()), authority=object(), daemon=("private", object(), object()), profile=("/profile", "uuid"), timeout=2, dbus_module=DBus, launcher_factory=lambda path: "launcher") as adapter: + self.assertIs(adapter.authority.control, connection) + self.assertEqual(events, ["close"]) + + def test_bus_mismatch_refuses_before_connection(self): + with self.assertRaisesRegex(RuntimeError, "buses differ"): + with adapter_factory.adapter_factory(bus=("one", object()), authority=object(), daemon=("two", object(), object()), profile=("/p", "u"), timeout=2, dbus_module=object()): + self.fail("must not enter") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_audit_log.py b/tests/nm-isolated/test_audit_log.py new file mode 100644 index 00000000..149e1fa1 --- /dev/null +++ b/tests/nm-isolated/test_audit_log.py @@ -0,0 +1,57 @@ +"""Synthetic tests for NetworkManager audit text and capture cursors.""" + +import io +import unittest + +from audit_log import CaptureReader, parse_line +from audit_observer import AuditObserver + + +SUCCESS = b' audit: op="connection-update" uuid="id" name="fixture" pid=123 uid=1000 result="success"\n' +FAILURE = b' audit: op="connection-update" uuid="id" name="fixture" pid=123 uid=1000 result="fail" reason="Update failed because profile changed in the meantime and the version-id mismatches"\n' + + +class AuditLogTests(unittest.TestCase): + def test_source_shaped_success_and_failure(self): + self.assertEqual(parse_line(SUCCESS.decode(), ":1.4")["pid"], 123) + self.assertEqual(parse_line(FAILURE.decode(), ":1.4")["reason"], "Update failed because profile changed in the meantime and the version-id mismatches") + self.assertIsNone(parse_line("unrelated\n", ":1.4")) + + def test_duplicate_and_malformed_rejected(self): + for line in ('audit: pid=1 pid=2\n', 'audit: op="unfinished\n', 'audit: pid=abc\n'): + with self.subTest(line=line), self.assertRaises(ValueError): + parse_line(line, ":1.4") + + def test_cursor_skips_prior_lines_and_retains_partial_line(self): + stream = io.BytesIO(SUCCESS) + reader = CaptureReader(stream, ":1.4") + cursor = reader.mark() + stream.write(FAILURE[:-1]) + records, unchanged = reader.read_after(cursor, timeout=1) + self.assertEqual(records, []) + self.assertEqual(unchanged, cursor) + stream.seek(0, 2) + stream.write(b"\n") + records, end = reader.read_after(cursor, timeout=1) + self.assertEqual(len(records), 1) + self.assertEqual(records[0]["result"], "fail") + self.assertGreater(end, cursor) + stream.truncate(cursor - 1) + with self.assertRaisesRegex(RuntimeError, "truncated"): + reader.read_after(cursor, timeout=1) + + def test_observer_detects_ambiguity_and_unrelated(self): + stream = io.BytesIO(SUCCESS + SUCCESS) + now = [0.0] + def pause(value): + now[0] += value + observer = AuditObserver(CaptureReader(stream, ":1.4"), ":1.4", 123, "id", 0, clock=lambda: now[0], pause=pause) + self.assertEqual(observer.wait("request", timeout=0.1), "inconclusive") + stream = io.BytesIO(SUCCESS) + now[0] = 0 + observer = AuditObserver(CaptureReader(stream, ":1.4"), ":1.4", 124, "id", 0, clock=lambda: now[0], pause=pause) + self.assertEqual(observer.wait("request", timeout=0.1), "inconclusive") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_audit_observer.py b/tests/nm-isolated/test_audit_observer.py new file mode 100644 index 00000000..225c12e1 --- /dev/null +++ b/tests/nm-isolated/test_audit_observer.py @@ -0,0 +1,64 @@ +"""Offline structured synthetic NetworkManager audit event tests.""" + +import unittest + +from audit_observer import AuditObserver + + +class Reader: + def __init__(self, events): + self.events = events + self.cursors = [] + + def read_after(self, cursor, *, timeout): + self.cursors.append((cursor, timeout)) + return self.events[cursor:], len(self.events) + + +def event(**changes): + value = {"daemon_instance": ":1.4", "pid": 123, "uuid": "profile-uuid", "op": "connection-update", "result": "success"} + value.update(changes) + return value + + +class AuditTests(unittest.TestCase): + def observe(self, events, cursor=0): + now = [0.0] + reader = Reader(events) + def pause(value): + now[0] += value + observer = AuditObserver(reader, ":1.4", 123, "profile-uuid", cursor, clock=lambda: now[0], pause=pause) + return observer.wait("held", timeout=0.1), reader + + def test_success_after_cursor(self): + result, reader = self.observe([event(result="fail"), event()], cursor=1) + self.assertEqual(result, "applied") + self.assertEqual(reader.cursors[0][0], 1) + + def test_match_returns_without_spending_remaining_deadline(self): + now = [0.0] + observer = AuditObserver(Reader([event()]), ":1.4", 123, "profile-uuid", 0, clock=lambda: now[0], pause=lambda delay: now.__setitem__(0, now[0] + delay)) + self.assertEqual(observer.wait("held", timeout=1), "applied") + self.assertEqual(now[0], 0) + + def test_version_mismatch_exact_reason(self): + reason = "Update failed because profile changed in the meantime and the version-id mismatches" + result, _ = self.observe([event(result="fail", reason=reason)]) + self.assertEqual(result, "version-mismatch") + result, _ = self.observe([event(result="fail", reason="another failure")]) + self.assertEqual(result, "inconclusive") + + def test_unrelated_missing_and_ambiguous_are_inconclusive(self): + cases = [[], [event(pid=124)], [event(uuid="other")], [event(daemon_instance=":1.9")], [event(op="connection-delete")], [event(), event()]] + for records in cases: + with self.subTest(records=records): + self.assertEqual(self.observe(records)[0], "inconclusive") + + def test_rejects_non_positive_deadline(self): + observer = AuditObserver(Reader([]), ":1.4", 123, "id", 0) + with self.assertRaises(ValueError): + observer.wait("held", timeout=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_client_launcher.py b/tests/nm-isolated/test_client_launcher.py new file mode 100644 index 00000000..8f6f080c --- /dev/null +++ b/tests/nm-isolated/test_client_launcher.py @@ -0,0 +1,69 @@ +"""Injected non-root launcher tests without starting processes.""" + +import io +import os +import threading +import types +import unittest + +import client_launcher +import private_bus + + +class Process: + def __init__(self): + self.stdout = io.BytesIO() + self.events = [] + def poll(self): + return None + def terminate(self): + self.events.append("terminate") + def wait(self, *, timeout=None): + self.events.append(("wait", timeout)) + return 0 + + +class LauncherTests(unittest.TestCase): + def test_nonroot_identity_private_bus_and_flags(self): + calls = [] + process = Process() + launcher = client_launcher.Launcher("/profile", guard=lambda: None, lookup=lambda name: types.SimpleNamespace(pw_uid=1001, pw_gid=1002), popen=lambda cmd, **kwargs: calls.append((cmd, kwargs)) or process) + child = launcher.start(metric=4, unfenced=True, disconnect_after_held=True, timeout=2) + command, options = calls[0] + self.assertIn(private_bus.ADDRESS, command) + self.assertIn("--unfenced", command) + self.assertIn("--disconnect-after-held", command) + self.assertEqual((options["user"], options["group"], options["extra_groups"]), (1001, 1002, ())) + child.close() + self.assertEqual(process.events, ["terminate", ("wait", 5)]) + self.assertTrue(process.stdout.closed) + + def test_fragmented_request_line(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(read_fd, "rb") + process = types.SimpleNamespace(stdout=stream) + os.write(write_fd, b"req") + timer = threading.Timer(0.02, lambda: os.write(write_fd, b"uest\n")) + timer.start() + try: + self.assertEqual(client_launcher.Child(process).read_request_id(timeout=1), "request") + finally: + timer.join() + os.close(write_fd) + stream.close() + + def test_guard_or_root_refuses_before_launch(self): + calls = [] + def refuse(): + raise RuntimeError("unsafe") + launcher = client_launcher.Launcher("/profile", guard=refuse, popen=lambda *args, **kwargs: calls.append(args)) + with self.assertRaisesRegex(RuntimeError, "unsafe"): + launcher.start(metric=1, unfenced=False, disconnect_after_held=False, timeout=1) + launcher = client_launcher.Launcher("/profile", guard=lambda: None, lookup=lambda name: types.SimpleNamespace(pw_uid=0, pw_gid=1), popen=lambda *args, **kwargs: calls.append(args)) + with self.assertRaisesRegex(RuntimeError, "non-root"): + launcher.start(metric=1, unfenced=False, disconnect_after_held=False, timeout=1) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_daemon_capture.py b/tests/nm-isolated/test_daemon_capture.py new file mode 100644 index 00000000..c398b471 --- /dev/null +++ b/tests/nm-isolated/test_daemon_capture.py @@ -0,0 +1,48 @@ +"""Offline daemon output capture tests with in-memory pipes.""" + +import io +import os +import unittest + +from daemon_capture import Capture, CaptureReader + + +class CaptureTests(unittest.TestCase): + def test_capture_feeds_audit_cursor_reader(self): + data = b'audit: op="connection-update" uuid="id" pid=123 result="success"\n' + capture = Capture(io.BufferedReader(io.BytesIO(data)), len(data)) + capture.join(1) + reader = CaptureReader(capture, ":1.1") + records, cursor = reader.read_after(0, timeout=1) + self.assertEqual(records[0]["pid"], 123) + self.assertEqual(cursor, len(data)) + self.assertEqual(reader.mark(), len(data)) + + def test_overflow_is_reported_and_pipe_is_drained(self): + pipe = io.BytesIO(b"x" * 10000) + capture = Capture(io.BufferedReader(pipe), 20) + with self.assertRaisesRegex(RuntimeError, "capture failed"): + capture.join(1) + self.assertEqual(pipe.tell(), 10000) + + def test_short_pipe_write_is_visible_before_eof(self): + read_fd, write_fd = os.pipe() + pipe = os.fdopen(read_fd, "rb") + capture = Capture(pipe, 100) + try: + os.write(write_fd, b"short\n") + capture.thread.join(0.05) + self.assertTrue(capture.thread.is_alive()) + self.assertEqual(capture.snapshot(), b"short\n") + finally: + os.close(write_fd) + capture.join(1) + pipe.close() + + def test_invalid_limit_fails(self): + with self.assertRaises(ValueError): + Capture(io.BytesIO(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_fixture_owner.py b/tests/nm-isolated/test_fixture_owner.py new file mode 100644 index 00000000..d49ec797 --- /dev/null +++ b/tests/nm-isolated/test_fixture_owner.py @@ -0,0 +1,68 @@ +"""Injected fixture ownership and child teardown tests.""" + +import subprocess +import unittest + +from fixture_owner import FixtureOwner + + +class Child: + def __init__(self, name, events, fail=False): + self.name, self.events, self.fail = name, events, fail + self.running = True + + def poll(self): + return None if self.running else 0 + + def terminate(self): + self.events.append((self.name, "terminate")) + if not self.fail: + self.running = False + + def kill(self): + self.events.append((self.name, "kill")) + self.running = False + + def wait(self, *, timeout): + self.events.append((self.name, "wait")) + if self.running: + raise subprocess.TimeoutExpired(self.name, timeout) + return 0 + + +class OwnerTests(unittest.TestCase): + def test_aliases_share_one_owner_and_teardown(self): + events, control = [], object() + owner = FixtureOwner(control, Child("authority", events), Child("bus", events), timeout=2, guard=lambda: None) + alias = owner.authority() + with self.assertRaisesRegex(RuntimeError, "already owned"): + FixtureOwner(control, Child("a", events), Child("b", events), timeout=2, guard=lambda: None) + owner.retire() + self.assertEqual(events, [("authority", "terminate"), ("authority", "wait"), ("bus", "terminate"), ("bus", "wait")]) + with self.assertRaisesRegex(RuntimeError, "retired"): + alias.allow_later(timeout=1) + with self.assertRaisesRegex(RuntimeError, "retired"): + owner.authority() + + def test_teardown_failure_still_stops_bus_and_blocks_reuse(self): + events, control = [], object() + owner = FixtureOwner(control, Child("authority", events, fail=True), Child("bus", events), timeout=2, guard=lambda: None) + with self.assertRaisesRegex(RuntimeError, "teardown failed"): + owner.retire() + self.assertIn(("authority", "kill"), events) + self.assertIn(("bus", "terminate"), events) + with self.assertRaisesRegex(RuntimeError, "already owned"): + FixtureOwner(control, Child("a", events), Child("b", events), timeout=2, guard=lambda: None) + + def test_guard_failure_claims_nothing(self): + control = object() + def refuse(): + raise RuntimeError("guard") + with self.assertRaisesRegex(RuntimeError, "guard"): + FixtureOwner(control, Child("a", []), Child("b", []), timeout=2, guard=refuse) + owner = FixtureOwner(control, Child("a", []), Child("b", []), timeout=2, guard=lambda: None) + owner.retire() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_agent.py b/tests/nm-isolated/test_guest_agent.py new file mode 100644 index 00000000..42c42f4c --- /dev/null +++ b/tests/nm-isolated/test_guest_agent.py @@ -0,0 +1,48 @@ +"""Offline device stream and guest agent tests.""" + +import socket +import unittest + +import guest_agent +import guest_transport + + +class GuestAgentTests(unittest.TestCase): + def test_one_readiness_roundtrip_over_socket_pair(self): + left, right = socket.socketpair() + self.addCleanup(left.close) + self.addCleanup(right.close) + left.setblocking(False) + right.sendall(b'{"id":"1","operation":"ready","payload":{}}\n') + guest_agent.serve(left.fileno(), 1) + self.assertEqual(guest_transport.receive(right.makefile("rb")), {"id": "1", "result": {"ready": True}}) + + def test_scenario_handler_validates_before_dispatch(self): + calls = [] + handler = guest_agent.scenario_handler(lambda *args: calls.append(args) or {"result": "ok"}) + self.assertEqual(handler({"scenario": "fenced", "metric": 12, "timeout": 5}), {"result": "ok"}) + self.assertEqual(calls, [("fenced", 12, 5)]) + for payload in ({"scenario": "exec", "metric": 1, "timeout": 1}, {"scenario": "fenced", "metric": True, "timeout": 1}, {"scenario": "fenced", "metric": 1, "timeout": float("nan")}): + with self.assertRaises(ValueError): + handler(payload) + self.assertEqual(len(calls), 1) + + def test_device_timeout_fails_without_read(self): + called = [] + stream = guest_agent.DeviceStream(1, 2, clock=lambda: 0, wait=lambda *args: ([], [], []), read=lambda *args: called.append(args)) + with self.assertRaises(TimeoutError): + stream.readline(10) + self.assertEqual(called, []) + + def test_partial_write_obeys_single_deadline(self): + now = [0] + def wait(readable, writable, errors, timeout): + now[0] += 1 + return readable, writable, [] + stream = guest_agent.DeviceStream(1, 2, clock=lambda: now[0], wait=wait, write=lambda fd, data: 1) + with self.assertRaises(TimeoutError): + stream.write(b"abc") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_factories.py b/tests/nm-isolated/test_guest_factories.py new file mode 100644 index 00000000..a6e48fa5 --- /dev/null +++ b/tests/nm-isolated/test_guest_factories.py @@ -0,0 +1,126 @@ +"""Injected lifecycle tests; never start guest services.""" + +import unittest +import types +from contextlib import contextmanager + +import guest_factories +import private_bus + + +class Process: + def __init__(self): + self.events = [] + def poll(self): + return None + def terminate(self): + self.events.append("terminate") + def wait(self, *, timeout): + self.events.append(("wait", timeout)) + + +class FactoryTests(unittest.TestCase): + def test_authority_uses_private_bus_and_reaps_child(self): + bus, child, calls = Process(), Process(), [] + def popen(command, **kwargs): + calls.append((command, kwargs)) + return child + with guest_factories.authority_factory(bus=(private_bus.ADDRESS, bus), timeout=2, popen=popen, ready=lambda *args: None) as result: + self.assertIs(result, child) + self.assertIn(private_bus.ADDRESS, calls[0][0]) + self.assertEqual(child.events, ["terminate", ("wait", 2)]) + + def test_readiness_uses_explicit_bus(self): + calls = [] + def run(command, **kwargs): + calls.append((command, kwargs)) + return types.SimpleNamespace(returncode=0) + guest_factories.authority_ready(Process(), private_bus.ADDRESS, 2, run=run) + self.assertIn("--bus=" + private_bus.ADDRESS, calls[0][0]) + self.assertIn("test.nm.IsolatedControl.Pending", calls[0][0]) + + def test_profile_creation_and_cleanup(self): + calls = [] + class Connection: + def get_object(self, service, path, **kwargs): + calls.append(("object", service, str(path))) + return self + def AddConnectionUnsaved(self, data, *, timeout): + calls.append(("add", data, timeout)) + return "/profile/1" + def Delete(self, *, timeout): + calls.append(("delete", timeout)) + def close(self): + calls.append(("close",)) + connection = Connection() + class DBus: + bus = types.SimpleNamespace(BusConnection=lambda address: connection) + Boolean = bool + Int64 = int + @staticmethod + def Dictionary(value, signature): + return value + @staticmethod + def Interface(obj, interface): + return obj + with guest_factories.profile_factory(bus=(private_bus.ADDRESS, Process()), daemon=None, timeout=2, dbus_module=DBus) as (path, identifier): + self.assertEqual(path, "/profile/1") + self.assertEqual(calls[1][1]["connection"]["uuid"], identifier) + self.assertFalse(calls[1][1]["connection"]["autoconnect"]) + self.assertEqual(calls[-2:], [("delete", 2), ("close",)]) + + def test_daemon_readiness_checks_pid_and_settings(self): + calls = [] + class Conn: + def close(self): + calls.append("close") + class Bus: + def GetNameOwner(self, name, *, timeout): + calls.append("owner") + return ":1.4" + def GetConnectionUnixProcessID(self, owner, *, timeout): + calls.append("pid") + return 42 + class Settings: + def ListConnections(self, *, timeout): + calls.append("settings") + return [] + process = Process() + process.pid = 42 + guest_factories.daemon_ready(process, private_bus.ADDRESS, 2, connect=lambda address: (Conn(), Bus(), Settings())) + self.assertEqual(calls, ["owner", "pid", "settings", "owner", "close"]) + process.pid = 43 + calls.clear() + with self.assertRaisesRegex(RuntimeError, "unexpected process"): + guest_factories.daemon_ready(process, private_bus.ADDRESS, 2, connect=lambda address: (Conn(), Bus(), Settings())) + self.assertNotIn("settings", calls) + process.pid = 42 + class Unready(Settings): + def ListConnections(self, *, timeout): + raise TimeoutError("interface unavailable") + with self.assertRaises(TimeoutError): + guest_factories.daemon_ready(process, private_bus.ADDRESS, 2, connect=lambda address: (Conn(), Bus(), Unready())) + + def test_daemon_receives_existing_bus(self): + bus = (private_bus.ADDRESS, Process()) + @contextmanager + def daemon(*, bus_factory, shutdown_timeout): + with bus_factory() as received: + self.assertIs(received, bus) + self.assertEqual(shutdown_timeout, 2) + yield (private_bus.ADDRESS, received[1], "capture") + calls = [] + with guest_factories.daemon_factory(bus=bus, timeout=2, daemon=daemon, ready=lambda *args: calls.append(args)) as result: + self.assertEqual(result[2], "capture") + self.assertEqual(calls, [(bus[1], private_bus.ADDRESS, 2)]) + + def test_wrong_bus_refused_before_spawn(self): + calls = [] + with self.assertRaisesRegex(RuntimeError, "private bus"): + with guest_factories.authority_factory(bus=("wrong", Process()), timeout=2, popen=lambda *args, **kwargs: calls.append(args)): + self.fail("must not start") + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_owner.py b/tests/nm-isolated/test_guest_owner.py new file mode 100644 index 00000000..5d435a89 --- /dev/null +++ b/tests/nm-isolated/test_guest_owner.py @@ -0,0 +1,58 @@ +"""Injected guest fixture ownership tests; no service is started.""" + +import unittest +from contextlib import contextmanager + +import guest_owner + + +class GuestOwnerTests(unittest.TestCase): + def test_lifecycle_order_and_exception_cleanup(self): + events = [] + def factory(name): + @contextmanager + def start(**kwargs): + events.append("start " + name) + try: + yield name + finally: + events.append("stop " + name) + return start + with self.assertRaisesRegex(ValueError, "scenario"): + with guest_owner.guest_owner(timeout=2, bus_factory=factory("bus"), authority_factory=factory("authority"), daemon_factory=factory("daemon"), profile_factory=factory("profile"), adapter_factory=factory("adapter"), guard=lambda: None) as adapter: + self.assertEqual(adapter, "adapter") + raise ValueError("scenario") + self.assertEqual(events, ["start bus", "start authority", "start daemon", "start profile", "start adapter", "stop adapter", "stop profile", "stop daemon", "stop authority", "stop bus"]) + + def test_partial_startup_cleans_prior_resources(self): + events = [] + @contextmanager + def bus(**kwargs): + events.append("start") + try: + yield "bus" + finally: + events.append("stop") + @contextmanager + def fail(**kwargs): + raise RuntimeError("authority failed") + yield + with self.assertRaisesRegex(RuntimeError, "authority failed"): + with guest_owner.guest_owner(timeout=2, bus_factory=bus, authority_factory=fail, daemon_factory=fail, profile_factory=fail, adapter_factory=fail, guard=lambda: None): + self.fail("must not enter") + self.assertEqual(events, ["start", "stop"]) + + def test_guard_refuses_before_factories(self): + calls = [] + def guard(): + raise RuntimeError("unsafe") + def factory(**kwargs): + calls.append(kwargs) + with self.assertRaisesRegex(RuntimeError, "unsafe"): + with guest_owner.guest_owner(timeout=2, bus_factory=factory, authority_factory=factory, daemon_factory=factory, profile_factory=factory, adapter_factory=factory, guard=guard): + self.fail("must not enter") + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_provision.py b/tests/nm-isolated/test_guest_provision.py new file mode 100644 index 00000000..3e57a4cc --- /dev/null +++ b/tests/nm-isolated/test_guest_provision.py @@ -0,0 +1,103 @@ +"""Injected validation tests, without package queries or guest setup.""" + +import ast +import hashlib +import json +import tempfile +import types +import unittest +from pathlib import Path + +import guest_provision + + +class ProvisionTests(unittest.TestCase): + def setUp(self): + self.manifest = json.loads(Path(__file__).with_name("guest_manifest.json").read_text(encoding="utf-8")) + + def test_manifest_covers_transitive_local_imports(self): + root = Path(__file__).parent + included = set(self.manifest["files"]) + modules = {path.stem for path in root.glob("*.py")} + queue = [name for name in included if name.endswith(".py")] + checked = set() + while queue: + name = queue.pop() + if name in checked: + continue + checked.add(name) + tree = ast.parse((root / name).read_text(encoding="utf-8"), filename=name) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name.split(".")[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module.split(".")[0]] + else: + continue + for module in names: + if module in modules: + dependency = module + ".py" + self.assertIn(dependency, included, f"{name} imports omitted {dependency}") + queue.append(dependency) + + def test_prebuilt_guest_prerequisites(self): + calls, files = [], [] + def run(command, **kwargs): + calls.append(command) + output = "1.54.3\n" if command[0] == "NetworkManager" else "install ok installed" + return types.SimpleNamespace(stdout=output) + def is_file(path): + files.append(path) + return True + guest_provision.validate_guest(self.manifest, run=run, is_file=is_file) + self.assertEqual(len(calls), len(guest_provision.PACKAGES) + 1) + self.assertEqual([p.name for p in files], list(guest_provision.FILES)) + self.assertTrue(all(p.parent == Path(guest_provision.DESTINATION) for p in files)) + + def test_manifest_drift_fails_before_commands(self): + self.manifest["files"].append("extra.py") + calls = [] + with self.assertRaisesRegex(ValueError, "manifest"): + guest_provision.validate_guest(self.manifest, run=lambda *args, **kwargs: calls.append(args)) + self.assertEqual(calls, []) + + def test_provision_validates_every_file_before_install(self): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) + hashes = {} + for name in guest_provision.FILES: + data = name.encode() + (source / name).write_bytes(data) + hashes[name] = hashlib.sha256(data).hexdigest() + installed = [] + def run(command, **kwargs): + return types.SimpleNamespace(stdout="1.54.3" if command[0] == "NetworkManager" else "install ok installed") + guest_provision.provision(source, self.manifest, hashes, guard=lambda: None, run=run, install=installed.append) + self.assertEqual(installed, [{name: name.encode() for name in guest_provision.FILES}]) + (source / guest_provision.FILES[-1]).write_bytes(b"changed") + installed.clear() + with self.assertRaisesRegex(ValueError, "digest mismatch"): + guest_provision.provision(source, self.manifest, hashes, guard=lambda: None, run=run, install=installed.append) + self.assertEqual(installed, []) + + def test_production_guard_refuses_before_any_work(self): + with self.assertRaisesRegex(RuntimeError, "provenance"): + guest_provision.provision("/not-read", self.manifest, {}, guard=guest_provision.refuse_unverified_guest) + + def test_missing_package_version_and_file_fail(self): + def absent(*args, **kwargs): + return types.SimpleNamespace(stdout="deinstall ok config-files") + with self.assertRaisesRegex(RuntimeError, "package not installed"): + guest_provision.validate_guest(self.manifest, run=absent) + def wrong_version(command, **kwargs): + return types.SimpleNamespace(stdout="1.0" if command[0] == "NetworkManager" else "install ok installed") + with self.assertRaisesRegex(RuntimeError, "version mismatch"): + guest_provision.validate_guest(self.manifest, run=wrong_version) + def installed(command, **kwargs): + return types.SimpleNamespace(stdout="1.54.3" if command[0] == "NetworkManager" else "install ok installed") + with self.assertRaisesRegex(RuntimeError, "fixture file missing"): + guest_provision.validate_guest(self.manifest, run=installed, is_file=lambda path: False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_runner.py b/tests/nm-isolated/test_guest_runner.py new file mode 100644 index 00000000..d22d2700 --- /dev/null +++ b/tests/nm-isolated/test_guest_runner.py @@ -0,0 +1,51 @@ +"""Offline guest scenario runner integration tests.""" + +import socket +import threading +import unittest +from contextlib import contextmanager + +import guest_agent +import guest_runner +import guest_transport +from test_scenarios import Adapter + + +class GuestRunnerTests(unittest.TestCase): + def test_no_fence_over_bounded_socket_transport(self): + events = [] + @contextmanager + def factory(*, timeout): + events.append(("start", timeout)) + try: + yield Adapter() + finally: + events.append("stop") + left, right = socket.socketpair() + self.addCleanup(left.close) + self.addCleanup(right.close) + left.setblocking(False) + errors = [] + def worker(): + try: + guest_agent.serve(left.fileno(), 2, run_scenario=guest_runner.make_runner(factory)) + except Exception as error: + errors.append(error) + thread = threading.Thread(target=worker) + thread.start() + ready = guest_transport.request(guest_transport.DeadlineStream(right, 2), "ready", "ready", {}) + result = guest_transport.request(guest_transport.DeadlineStream(right, 2), "scenario", "scenario", {"scenario": "no-fence", "metric": 20101, "timeout": 1}) + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(ready, {"ready": True}) + self.assertEqual(result["after"], {"metric": 20101, "version": 3}) + self.assertEqual(events, [("start", 1), "stop"]) + + def test_production_factory_refuses(self): + with self.assertRaises(RuntimeError): + guest_runner.make_runner(guest_runner.guarded_adapter_factory)("fenced", 1, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_guest_transport.py b/tests/nm-isolated/test_guest_transport.py new file mode 100644 index 00000000..bc87a272 --- /dev/null +++ b/tests/nm-isolated/test_guest_transport.py @@ -0,0 +1,98 @@ +"""Offline tests for the stream transport without devices or VM processes.""" + +import io +import json +import socket +import unittest + +import guest_transport as transport + + +class Duplex: + def __init__(self, incoming): + self.reader = io.BytesIO(incoming) + self.writer = io.BytesIO() + + def readline(self, limit): + return self.reader.readline(limit) + + def write(self, data): + return self.writer.write(data) + + def flush(self): + pass + + +class TransportTests(unittest.TestCase): + def test_dispatches_only_named_handler(self): + stream = Duplex(b'{"id":"one","operation":"ready","payload":{}}\n') + transport.serve_one(stream, {"ready": lambda payload: {"ready": True}}) + self.assertEqual(json.loads(stream.writer.getvalue()), {"id": "one", "result": {"ready": True}}) + + def test_readiness_handler_reports_only_responsiveness(self): + stream = Duplex(b'{"id":"one","operation":"ready","payload":{}}\n') + transport.serve_one(stream, {"ready": transport.ready}) + self.assertEqual(json.loads(stream.writer.getvalue())["result"], {"ready": True}) + with self.assertRaisesRegex(ValueError, "empty"): + transport.ready({"assert-isolated": True}) + + def test_deadline_applies_across_partial_reads_and_writes(self): + class FakeSocket: + def __init__(self): + self.now = 0 + self.timeouts = [] + self.sent = bytearray() + def settimeout(self, timeout): + self.timeouts.append(timeout) + def recv(self, size): + self.now += 1 + return b"x" + def send(self, data): + self.now += 1 + self.sent.extend(data[:1]) + return 1 + sock = FakeSocket() + stream = transport.DeadlineStream(sock, 2, clock=lambda: sock.now) + with self.assertRaises(TimeoutError): + stream.readline(10) + self.assertEqual(sock.timeouts, [2, 1]) + sock = FakeSocket() + stream = transport.DeadlineStream(sock, 2, clock=lambda: sock.now) + with self.assertRaises(TimeoutError): + stream.write(b"abc") + self.assertEqual(sock.sent, b"ab") + + def test_real_socket_timeout_on_read_and_full_write_buffer(self): + left, right = socket.socketpair() + self.addCleanup(left.close) + self.addCleanup(right.close) + with self.assertRaises(TimeoutError): + transport.DeadlineStream(left, 0.02).readline(10) + left.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096) + with self.assertRaises(TimeoutError): + transport.DeadlineStream(left, 0.02).write(b"x" * (1024 * 1024)) + + def test_unknown_operation_is_rejected(self): + stream = Duplex(b'{"id":"one","operation":"exec","payload":{}}\n') + with self.assertRaisesRegex(ValueError, "unknown operation"): + transport.serve_one(stream, {}) + self.assertEqual(stream.writer.getvalue(), b"") + + def test_request_matches_response_id(self): + stream = Duplex(b'{"id":"one","result":{"ready":true}}\n') + self.assertEqual(transport.request(stream, "one", "ready", {}), {"ready": True}) + self.assertEqual(json.loads(stream.writer.getvalue()), {"id": "one", "operation": "ready", "payload": {}}) + with self.assertRaisesRegex(ValueError, "does not match"): + transport.request(Duplex(b'{"id":"other","result":{}}\n'), "one", "ready", {}) + + def test_invalid_and_oversized_frames_fail(self): + for data in (b"[]\n", b"{", b"x" * (transport.MAX_FRAME + 1)): + with self.subTest(length=len(data)): + with self.assertRaises(ValueError): + transport.receive(io.BytesIO(data)) + with self.assertRaisesRegex(ValueError, "exceeds"): + transport.send(io.BytesIO(), {"data": "x" * transport.MAX_FRAME}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_host_transport.py b/tests/nm-isolated/test_host_transport.py new file mode 100644 index 00000000..e7fc1a98 --- /dev/null +++ b/tests/nm-isolated/test_host_transport.py @@ -0,0 +1,92 @@ +"""Injected tests for host readiness connection orchestration.""" + +import errno +import unittest + +import host_transport + + +class Process: + def __init__(self): + self.exited = False + + def poll(self): + return 1 if self.exited else None + + +class Socket: + def __init__(self, connect_error=None, response=b'{"id":"readiness","result":{"ready":true}}\n'): + self.connect_error = connect_error + self.response = bytearray(response) + self.sent = bytearray() + self.closed = False + self.address = None + self.timeouts = [] + + def settimeout(self, value): + self.timeouts.append(value) + + def connect(self, address): + self.address = address + if self.connect_error: + raise OSError(self.connect_error, "not ready") + + def send(self, data): + self.sent.extend(data) + return len(data) + + def recv(self, size): + result = bytes(self.response[:size]) + del self.response[:size] + return result + + def close(self): + self.closed = True + + +class HostTransportTests(unittest.TestCase): + def test_retry_then_readiness_and_explicit_path(self): + first, second = Socket(errno.ENOENT), Socket() + sockets = iter([first, second]) + now = [0.0] + def pause(duration): + now[0] += duration + result = host_transport.connect_ready("/private/work", Process(), 1, socket_factory=lambda *args: next(sockets), clock=lambda: now[0], pause=pause) + self.assertIs(result, second) + self.assertTrue(first.closed) + self.assertFalse(second.closed) + self.assertEqual(second.address, "/private/work/guest.sock") + self.assertIn(b'"operation":"ready"', second.sent) + self.assertLess(second.timeouts[0], 1) + + def test_retry_is_bounded_and_closes_each_socket(self): + now = [0.0] + sockets = [] + def factory(*args): + sock = Socket(errno.ECONNREFUSED) + sockets.append(sock) + return sock + def pause(duration): + now[0] += duration + with self.assertRaises(TimeoutError): + host_transport.connect_ready("/private/work", Process(), 0.1, socket_factory=factory, clock=lambda: now[0], pause=pause) + self.assertTrue(sockets) + self.assertTrue(all(sock.closed for sock in sockets)) + + def test_failed_readiness_closes_socket(self): + sock = Socket(response=b'{"id":"readiness","result":{"ready":false}}\n') + with self.assertRaisesRegex(RuntimeError, "did not report"): + host_transport.connect_ready("/private/work", Process(), 1, socket_factory=lambda *args: sock) + self.assertTrue(sock.closed) + + def test_dead_process_never_connects(self): + process = Process() + process.exited = True + calls = [] + with self.assertRaisesRegex(RuntimeError, "exited"): + host_transport.connect_ready("/private/work", process, 1, socket_factory=lambda *args: calls.append(args)) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_lifecycle_ports.py b/tests/nm-isolated/test_lifecycle_ports.py new file mode 100644 index 00000000..84075254 --- /dev/null +++ b/tests/nm-isolated/test_lifecycle_ports.py @@ -0,0 +1,135 @@ +"""Offline lifecycle port tests with injected child and authority controls.""" + +import unittest + +import lifecycle_ports +from fixture_lifetime import FixtureLifetime + + +class Child: + def __init__(self, request="held", code=0): + self.request = request + self.code = code + self.calls = [] + + def read_request_id(self, *, timeout): + self.calls.append(("read", timeout)) + return self.request + + def wait(self, *, timeout): + self.calls.append(("wait", timeout)) + return self.code + + def close(self): + self.calls.append(("close",)) + + +class Launcher: + def __init__(self, *children): + self.children = iter(children) + self.calls = [] + + def start(self, **kwargs): + self.calls.append(kwargs) + return next(self.children) + + +class Tests(unittest.TestCase): + def test_held_and_separate_use_distinct_children(self): + first, second = Child(), Child() + launcher = Launcher(first, second) + ports = lifecycle_ports.Clients(launcher) + request = ports.submit_held(metric=12, fenced=False, timeout=2) + ports.disconnect(request, timeout=1) + ports.update_separately(metric=600, fenced=True, timeout=1) + self.assertEqual(launcher.calls, [{"metric": 12, "unfenced": True, "disconnect_after_held": True, "timeout": 2}, {"metric": 600, "unfenced": False, "disconnect_after_held": False, "timeout": 1}]) + self.assertEqual(first.calls, [("read", 2), ("wait", 1)]) + self.assertEqual(second.calls, [("wait", 1), ("close",)]) + + def test_cleanup_closes_held_child_on_success_and_failure(self): + for code in (0, 1): + child = Child(code=code) + ports = lifecycle_ports.Clients(Launcher(child)) + request = ports.submit_held(metric=1, fenced=True, timeout=2) + try: + ports.disconnect(request, timeout=1) + except RuntimeError: + pass + finally: + ports.close() + self.assertIn(("close",), child.calls) + self.assertEqual(ports.requests, {}) + + def test_failed_child_and_duplicate_are_not_accepted(self): + first, duplicate = Child(), Child() + ports = lifecycle_ports.Clients(Launcher(first, duplicate)) + ports.submit_held(metric=1, fenced=True, timeout=2) + with self.assertRaisesRegex(RuntimeError, "duplicate"): + ports.submit_held(metric=1, fenced=True, timeout=2) + self.assertIn(("close",), duplicate.calls) + bad = Child(code=1) + with self.assertRaisesRegex(RuntimeError, "writer failed"): + lifecycle_ports.Clients(Launcher(bad)).update_separately(metric=1, fenced=True, timeout=1) + self.assertIn(("close",), bad.calls) + + def test_reset_timeout_retires_authority(self): + class Control: + def AllowLater(self, value, *, timeout): + raise TimeoutError("reset") + authority = lifecycle_ports.Authority(Control(), FixtureLifetime(lambda **kwargs: None, 1)) + with self.assertRaises(TimeoutError): + authority.hold_later(timeout=1) + with self.assertRaisesRegex(RuntimeError, "retired"): + authority.allow_later(timeout=1) + + def test_reset_failure_tears_down_pending_and_blocks_aliases(self): + events = [] + pending = {"held"} + def teardown(*, timeout): + events.append(("authority-stop", timeout)) + pending.clear() + events.append(("bus-stop", timeout)) + class Control: + def AllowLater(self, value, *, timeout): + raise TimeoutError("reset") + lifetime = FixtureLifetime(teardown, 2) + first = lifecycle_ports.Authority(Control(), lifetime) + alias = lifecycle_ports.Authority(Control(), lifetime) + with self.assertRaises(TimeoutError): + first.hold_later(timeout=1) + self.assertEqual(pending, set()) + self.assertEqual(events, [("authority-stop", 2), ("bus-stop", 2)]) + with self.assertRaisesRegex(RuntimeError, "retired"): + alias.allow_later(timeout=1) + with self.assertRaisesRegex(RuntimeError, "retired"): + lifecycle_ports.Authority(Control(), lifetime) + + def test_teardown_failure_still_blocks_reuse(self): + def teardown(*, timeout): + raise TimeoutError("teardown") + lifetime = FixtureLifetime(teardown, 2) + authority = lifecycle_ports.Authority(object(), lifetime) + with self.assertRaisesRegex(TimeoutError, "teardown"): + authority.retire() + self.assertIsInstance(lifetime.teardown_error, TimeoutError) + with self.assertRaisesRegex(RuntimeError, "retired"): + lifecycle_ports.Authority(object(), lifetime) + + def test_authority_controls_and_restart_refusal(self): + calls = [] + class Control: + def AllowLater(self, value, *, timeout): + calls.append(("allow", value, timeout)) + def Release(self, request, value, *, timeout): + calls.append(("release", request, value, timeout)) + authority = lifecycle_ports.Authority(Control(), FixtureLifetime(lambda **kwargs: None, 1)) + authority.allow_later(timeout=2) + authority.hold_later(timeout=2) + authority.release("held", timeout=1) + self.assertEqual(calls, [("allow", True, 2), ("allow", False, 2), ("release", "held", True, 1)]) + with self.assertRaisesRegex(RuntimeError, "inconclusive"): + lifecycle_ports.Daemon().restart(timeout=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_networkmanager.py b/tests/nm-isolated/test_networkmanager.py new file mode 100644 index 00000000..96fa7561 --- /dev/null +++ b/tests/nm-isolated/test_networkmanager.py @@ -0,0 +1,119 @@ +"""Offline tests of the fail-closed NetworkManager lifecycle.""" + +import subprocess +import unittest +from contextlib import contextmanager + +import networkmanager +import private_bus + + +class Process: + def __init__(self, events, name, exited=False, timeout=False): + self.events = events + self.name = name + self.exited = exited + self.timeout = timeout + self.stdout = None + + def poll(self): + return 1 if self.exited else None + + def terminate(self): + self.events.append((self.name, "terminate")) + + def kill(self): + self.events.append((self.name, "kill")) + + def wait(self, timeout=None): + self.events.append((self.name, "wait", timeout)) + if self.timeout and timeout is not None: + raise subprocess.TimeoutExpired(self.name, timeout) + return 0 + + +class FakeCapture: + def __init__(self, pipe, limit): + self.limit = limit + + def join(self, timeout): + pass + + +class NetworkManagerTests(unittest.TestCase): + def test_isolation_evidence_rejects_shared_namespace_and_host_mounts(self): + def run(mountinfo, same_namespace=False): + def link(name): + return "mnt:[1]" if same_namespace or name == "/proc/1/ns/mnt" else "mnt:[2]" + networkmanager.verified_isolation(vm_check=lambda: None, readlink=link, read_text=lambda name: mountinfo) + root = "1 0 0:1 / / rw - tmpfs tmpfs rw" + with self.assertRaisesRegex(RuntimeError, "namespace is shared"): + run(root, same_namespace=True) + with self.assertRaisesRegex(RuntimeError, "non-isolated"): + run(root + "\n2 1 8:1 / /etc/NetworkManager rw - ext4 /dev/sda rw") + with self.assertRaisesRegex(RuntimeError, "non-isolated"): + run(root + "\n2 1 0:1 /host /etc/NetworkManager rw - tmpfs tmpfs rw") + with self.assertRaisesRegex(RuntimeError, "namespace escape"): + run(root) + + def test_default_verifier_refuses_before_launch(self): + calls = [] + with self.assertRaises(RuntimeError): + with networkmanager.networkmanager(popen=lambda *args, **kwargs: calls.append(args)): + self.fail("must not enter") + self.assertEqual(calls, []) + + def test_child_uses_explicit_bus_and_is_reaped_before_bus(self): + events = [] + bus = Process(events, "bus") + daemon = Process(events, "nm") + + @contextmanager + def bus_factory(): + events.append("bus-start") + try: + yield private_bus.ADDRESS, bus + finally: + events.append("bus-stop") + + def popen(command, **kwargs): + events.append("nm-start") + self.assertEqual(command, ["NetworkManager", "--no-daemon", "--log-level=INFO", "--log-domains=ALL"]) + self.assertEqual(kwargs["stdout"], subprocess.PIPE) + self.assertEqual(kwargs["stderr"], subprocess.STDOUT) + self.assertEqual(kwargs["env"]["DBUS_SYSTEM_BUS_ADDRESS"], private_bus.ADDRESS) + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) + return daemon + + with self.assertRaisesRegex(ValueError, "fixture failure"): + with networkmanager.networkmanager(isolation_check=lambda: None, bus_factory=bus_factory, popen=popen, capture_factory=FakeCapture, shutdown_timeout=2) as (address, child, capture): + self.assertEqual(address, private_bus.ADDRESS) + self.assertIs(child, daemon) + raise ValueError("fixture failure") + self.assertEqual(events, ["bus-start", "nm-start", ("nm", "terminate"), ("nm", "wait", 2), "bus-stop"]) + + def test_wrong_or_dead_bus_refused_without_daemon(self): + for address, exited in [("unix:path=/run/dbus/system_bus_socket", False), (private_bus.ADDRESS, True)]: + with self.subTest(address=address, exited=exited): + calls = [] + @contextmanager + def bus_factory(): + yield address, Process([], "bus", exited=exited) + with self.assertRaisesRegex(RuntimeError, "private bus"): + with networkmanager.networkmanager(isolation_check=lambda: None, bus_factory=bus_factory, popen=lambda *args, **kwargs: calls.append(args)): + self.fail("must not enter") + self.assertEqual(calls, []) + + def test_unresponsive_daemon_is_killed_and_reaped(self): + events = [] + @contextmanager + def bus_factory(): + yield private_bus.ADDRESS, Process(events, "bus") + daemon = Process(events, "nm", timeout=True) + with networkmanager.networkmanager(isolation_check=lambda: None, bus_factory=bus_factory, popen=lambda *args, **kwargs: daemon, capture_factory=FakeCapture, shutdown_timeout=2): + pass + self.assertEqual(events, [("nm", "terminate"), ("nm", "wait", 2), ("nm", "kill"), ("nm", "wait", None)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_outcome_observer.py b/tests/nm-isolated/test_outcome_observer.py new file mode 100644 index 00000000..8c42bb08 --- /dev/null +++ b/tests/nm-isolated/test_outcome_observer.py @@ -0,0 +1,70 @@ +"""Outcome inference tests; no daemon or bus is started.""" + +import unittest + +from outcome_observer import OutcomeObserver +from scenario_bus import Profile + + +class Reader: + def __init__(self, profile): + self.value = profile + self.calls = [] + + def profile(self, *, timeout): + self.calls.append(timeout) + return self.value + + +class Barrier: + def __init__(self, finished): + self.value = finished + self.calls = [] + + def finished(self, request, *, timeout): + self.calls.append((request, timeout)) + return self.value + + +class ObserverTests(unittest.TestCase): + def observe(self, after, finished=True): + reader = Reader(after) + barrier = Barrier(finished) + observer = OutcomeObserver(reader, barrier, clock=lambda: 0) + observer.register("request", Profile(600, 3), 20101) + return observer, reader, barrier + + def test_applied_requires_completion_and_changed_metric_and_version(self): + observer, reader, barrier = self.observe(Profile(20101, 4)) + self.assertEqual(observer.wait("request", timeout=2), "applied") + self.assertEqual(barrier.calls, [("request", 2)]) + self.assertEqual(reader.calls, [2]) + + def test_unchanged_is_not_applied_not_a_specific_rejection(self): + observer, _, _ = self.observe(Profile(600, 3)) + self.assertEqual(observer.wait("request", timeout=2), "not-applied") + + def test_missing_barrier_or_ambiguous_state_is_inconclusive(self): + observer, reader, _ = self.observe(Profile(20101, 4), finished=False) + self.assertEqual(observer.wait("request", timeout=2), "inconclusive") + self.assertEqual(reader.calls, []) + for state in (Profile(600, 4), Profile(20101, 3), Profile(123, 4)): + observer, _, _ = self.observe(state) + self.assertEqual(observer.wait("request", timeout=2), "inconclusive") + + def test_deadline_after_barrier_fails_without_profile_read(self): + now = [0] + class SlowBarrier: + def finished(self, request, *, timeout): + now[0] = 2 + return True + reader = Reader(Profile(600, 3)) + observer = OutcomeObserver(reader, SlowBarrier(), clock=lambda: now[0]) + observer.register("request", Profile(600, 3), 20101) + with self.assertRaises(TimeoutError): + observer.wait("request", timeout=2) + self.assertEqual(reader.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_private_bus.py b/tests/nm-isolated/test_private_bus.py new file mode 100644 index 00000000..ef38f48a --- /dev/null +++ b/tests/nm-isolated/test_private_bus.py @@ -0,0 +1,125 @@ +"""Private bus lifecycle tests using an injected child, never a daemon.""" + +import subprocess +import types +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import patch +from pathlib import Path + +import private_bus + + +class Child: + def __init__(self, running=True, timeout=False): + self.running = running + self.timeout = timeout + self.events = [] + + def poll(self): + self.events.append("poll") + return None if self.running else 1 + + def terminate(self): + self.events.append("terminate") + + def kill(self): + self.events.append("kill") + + def wait(self, timeout=None): + self.events.append(("wait", timeout)) + if self.timeout and timeout is not None: + raise subprocess.TimeoutExpired("dbus-daemon", timeout) + return 0 + + +class PrivateBusTests(unittest.TestCase): + def start(self, child, checks, calls): + def check(): + checks.append("checked") + def popen(command, **kwargs): + calls.append((command, kwargs)) + return child + return private_bus.private_bus(check=check, popen=popen, ready=lambda process, timeout: None, shutdown_timeout=2) + + def test_explicit_private_address_and_cleanup(self): + child, checks, calls = Child(), [], [] + with self.start(child, checks, calls) as (address, process): + self.assertEqual(checks, ["checked"]) + self.assertEqual(address, "unix:path=/run/nm-isolated-test/bus") + self.assertIs(process, child) + self.assertEqual(calls[0][0], ["dbus-daemon", "--config-file=" + str(Path(private_bus.__file__).with_name("bus.conf")), "--nofork"]) + self.assertEqual(calls[0][1], {"stdin": subprocess.DEVNULL}) + self.assertEqual(child.events, ["poll", "terminate", ("wait", 2)]) + + def test_config_allows_named_nonroot_client(self): + config = ET.parse(Path(private_bus.__file__).with_name("bus.conf")).getroot() + self.assertEqual(config.findtext("listen"), private_bus.ADDRESS) + policy = config.find("policy[@user='nm-isolated']") + self.assertIsNotNone(policy) + self.assertIsNotNone(policy.find("allow[@send_destination='org.freedesktop.NetworkManager']")) + # A connection permission is bus-global, so dbus-daemon accepts it only in the default policy; it names exactly the two accounts and never a wildcard. + self.assertIsNone(policy.find("allow[@user]")) + connect = sorted(rule.get("user") for rule in config.findall("policy[@context='default']/allow[@user]")) + self.assertEqual(connect, ["nm-isolated", "root"]) + self.assertEqual(config.findall(".//allow[@user='*']"), []) + + def test_readiness_retries_explicit_bus_and_is_bounded(self): + calls, now = [], [0.0] + def run(command, **kwargs): + calls.append((command, kwargs)) + return types.SimpleNamespace(returncode=0 if len(calls) == 2 else 1) + def pause(duration): + now[0] += duration + private_bus.wait_ready(Child(), 1, run=run, clock=lambda: now[0], pause=pause) + self.assertEqual(len(calls), 2) + self.assertIn("--bus=" + private_bus.ADDRESS, calls[0][0]) + self.assertLess(calls[1][1]["timeout"], calls[0][1]["timeout"]) + + def test_readiness_failure_reaps_child(self): + child = Child() + def fail(process, timeout): + raise TimeoutError("not ready") + with self.assertRaises(TimeoutError): + with private_bus.private_bus(check=lambda: None, popen=lambda *args, **kwargs: child, ready=fail, shutdown_timeout=2): + self.fail("must not enter") + self.assertEqual(child.events, ["poll", "terminate", ("wait", 2)]) + + def test_cleanup_on_body_error(self): + child = Child() + with self.assertRaisesRegex(ValueError, "body"): + with self.start(child, [], []): + raise ValueError("body") + self.assertEqual(child.events, ["poll", "terminate", ("wait", 2)]) + + def test_kills_child_that_does_not_stop(self): + child = Child(timeout=True) + with self.start(child, [], []): + pass + self.assertEqual(child.events, ["poll", "terminate", ("wait", 2), "kill", ("wait", None)]) + + def test_reaps_exited_child_without_signalling(self): + child = Child(running=False) + with self.start(child, [], []): + pass + self.assertEqual(child.events, ["poll", ("wait", 2)]) + + def test_environment_guard_accepts_existing_bus_but_launcher_refuses_it(self): + with patch.object(private_bus.os, "geteuid", return_value=0), patch.object(Path, "is_symlink", return_value=False), patch.object(Path, "is_file", return_value=True), patch.object(Path, "exists", return_value=True), patch.object(private_bus.subprocess, "run", return_value=types.SimpleNamespace(returncode=0)): + private_bus.check_environment() + with self.assertRaisesRegex(RuntimeError, "occupied"): + with private_bus.private_bus(check=lambda: None): + self.fail("must not start") + + def test_guard_failure_never_spawns(self): + calls = [] + def refuse(): + raise RuntimeError("unsafe") + with self.assertRaisesRegex(RuntimeError, "unsafe"): + with private_bus.private_bus(check=refuse, popen=lambda *args, **kwargs: calls.append(args)): + self.fail("must not enter") + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_qemu_vm.py b/tests/nm-isolated/test_qemu_vm.py new file mode 100644 index 00000000..c38185ac --- /dev/null +++ b/tests/nm-isolated/test_qemu_vm.py @@ -0,0 +1,138 @@ +"""Injected QEMU lifecycle tests; no image utility or VM is launched.""" + +import json +import os +import types +import tempfile +import unittest +from pathlib import Path + +import qemu_vm + + +class Process: + pid = 123 + + def __init__(self): + self.events = [] + + def poll(self): + return None + + def terminate(self): + self.events.append("terminate") + + def wait(self, timeout=None): + self.events.append(("wait", timeout)) + + +class QemuTests(unittest.TestCase): + def test_launch_command_overlay_and_cleanup(self): + with tempfile.TemporaryDirectory() as temp_root: + root = str(Path(temp_root).resolve()) + base = Path(root) / "base.qcow2" + base.touch() + base.chmod(0o400) + work = Path(root) / "work" + work.mkdir(mode=0o700) + calls, checks, process = [], [], Process() + def run(*args, **kwargs): + calls.append((args[0], kwargs)) + return types.SimpleNamespace(stdout=json.dumps([{"filename": str(base), "format": "qcow2", "format-specific": {"type": "qcow2", "data": {}}}])) + def popen(command, **kwargs): + calls.append((command, kwargs)) + return process + def verify(pid, executable, command): + checks.append((pid, executable, command)) + with qemu_vm.disposable_vm(base, work, qemu="/usr/bin/qemu", qemu_img="/usr/bin/qemu-img", run=run, popen=popen, verify=verify) as (name, overlay, child): + self.assertTrue(name.startswith("nm-isolated-")) + self.assertEqual(overlay.parent, work) + self.assertIs(child, process) + self.assertEqual(calls[0][0], ["/usr/bin/qemu-img", "info", "--output=json", "--backing-chain", "--", str(base)]) + self.assertEqual(calls[1][0], ["/usr/bin/qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", str(base), str(overlay)]) + command = calls[2][0] + self.assertIn(f"socket,id=fixture,path={work / 'guest.sock'},server=on,wait=off", command) + self.assertIn("virtserialport,chardev=fixture,name=test.nm.fixture", command) + self.assertIn("-nodefaults", command) + self.assertEqual(command[command.index("-accel") + 1], "kvm") + self.assertIn("-no-user-config", command) + self.assertEqual(command[command.index("-nic") + 1], "none") + self.assertFalse(any(option in command for option in ("-virtfs", "-fsdev", "-netdev"))) + self.assertEqual(checks, [(123, "/usr/bin/qemu", command)]) + self.assertEqual(process.events, ["terminate", ("wait", 5)]) + + def test_rejects_writable_remote_and_backed_images(self): + with tempfile.TemporaryDirectory() as temp_root: + root = str(Path(temp_root).resolve()) + base = Path(root) / "base" + base.touch() + calls = [] + def run(*args, **kwargs): + calls.append(args) + return types.SimpleNamespace(stdout=json.dumps(payload)) + with self.assertRaisesRegex(ValueError, "write permissions"): + qemu_vm.validate_base(base, "/img", run=run) + self.assertEqual(calls, []) + base.chmod(0o400) + with self.assertRaisesRegex(ValueError, "local path"): + qemu_vm.validate_base("/remote:http", "/img", run=run) + payload = [{"filename": str(base), "format": "qcow2", "format-specific": {"type": "qcow2", "data": {}}}, {"filename": "/other"}] + with self.assertRaisesRegex(ValueError, "backing chain"): + qemu_vm.validate_base(base, "/img", run=run) + payload = [{"filename": str(base), "format": "qcow2", "backing-filename": "https://example.com/disk", "format-specific": {"type": "qcow2", "data": {}}}] + with self.assertRaisesRegex(ValueError, "backing"): + qemu_vm.validate_base(base, "/img", run=run) + payload = [{"filename": str(base), "format": "qcow2", "format-specific": {"type": "qcow2", "data": {"data-file": "/other"}}}] + with self.assertRaisesRegex(ValueError, "data file"): + qemu_vm.validate_base(base, "/img", run=run) + + def test_process_identity_matches_complete_arguments(self): + args = ["/usr/bin/qemu", "-nic", "none"] + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: os.path.realpath(args[0]), read_bytes=lambda name: b"/usr/bin/qemu\0-nic\0none\0") + with self.assertRaisesRegex(RuntimeError, "command identity"): + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: os.path.realpath(args[0]), read_bytes=lambda name: b"/usr/bin/qemu\0-nic\0user\0") + with self.assertRaisesRegex(RuntimeError, "executable identity"): + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: "/other", read_bytes=lambda name: b"") + + def test_an_empty_argument_area_during_exec_is_retried_then_matches(self): + args = ["/usr/bin/qemu", "-nic", "none"] + reads = iter([b"", b"", b"/usr/bin/qemu\0-nic\0none\0"]) + sleeps = [] + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: os.path.realpath(args[0]), read_bytes=lambda name: next(reads), sleep=sleeps.append) + self.assertEqual(len(sleeps), 2) + + def test_an_argument_area_that_stays_empty_fails_after_the_bound(self): + args = ["/usr/bin/qemu", "-nic", "none"] + sleeps = [] + with self.assertRaisesRegex(RuntimeError, "command identity"): + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: os.path.realpath(args[0]), read_bytes=lambda name: b"", sleep=sleeps.append, empty_reads=5) + self.assertEqual(len(sleeps), 5) + + def test_a_non_empty_wrong_command_is_never_retried(self): + args = ["/usr/bin/qemu", "-nic", "none"] + sleeps = [] + with self.assertRaisesRegex(RuntimeError, "command identity"): + qemu_vm.verify_process(4, args[0], args, readlink=lambda name: os.path.realpath(args[0]), read_bytes=lambda name: b"/usr/bin/qemu\0-nic\0user\0", sleep=sleeps.append) + self.assertEqual(sleeps, []) + + def test_identity_failure_reaps_child(self): + with tempfile.TemporaryDirectory() as temp_root: + root = str(Path(temp_root).resolve()) + base = Path(root) / "base" + base.touch() + base.chmod(0o400) + work = Path(root) / "work" + work.mkdir(mode=0o700) + process = Process() + def refuse(*args): + raise RuntimeError("identity") + def run(*args, **kwargs): + return types.SimpleNamespace(stdout=json.dumps([{"filename": str(base), "format": "qcow2", "format-specific": {"type": "qcow2", "data": {}}}])) + with self.assertRaisesRegex(RuntimeError, "identity"): + with qemu_vm.disposable_vm(base, work, qemu="/qemu", qemu_img="/img", run=run, popen=lambda *args, **kwargs: process, verify=refuse): + self.fail("must not enter") + self.assertEqual(process.events, ["terminate", ("wait", 5)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_scenario_bus.py b/tests/nm-isolated/test_scenario_bus.py new file mode 100644 index 00000000..9ae65193 --- /dev/null +++ b/tests/nm-isolated/test_scenario_bus.py @@ -0,0 +1,155 @@ +"""Offline injected D-Bus profile and identity tests.""" + +import io +import unittest + +from audit_log import CaptureReader + +import scenario_bus +import update2_client + + +class FakeInterface: + def __init__(self): + self.versions = iter([3, 3]) + self.calls = [] + self.name = ":1.7" + + def Get(self, interface, name, *, timeout): + self.calls.append(("Get", interface, name, timeout)) + return next(self.versions) + + def GetSettings(self, *, timeout): + self.calls.append(("GetSettings", timeout)) + return {"ipv4": {"route-metric": 600}} + + def GetNameOwner(self, name, *, timeout): + self.calls.append(("GetNameOwner", name, timeout)) + return self.name + + +class FakeBus: + def __init__(self): + self.interface = FakeInterface() + self.calls = [] + + def get_object(self, service, path, *, introspect): + self.calls.append((service, path, introspect)) + return self.interface + + +class FakeDBus: + @staticmethod + def Interface(obj, interface): + return obj + + +class ScenarioBusTests(unittest.TestCase): + def test_profile_reads_stable_version_and_metric(self): + bus = FakeBus() + adapter = scenario_bus.ScenarioBus(bus, FakeDBus, "/profile", 2) + self.assertEqual(adapter.profile(), scenario_bus.Profile(600, 3)) + self.assertEqual(bus.calls, [(update2_client.SERVICE, "/profile", False)]) + self.assertEqual([call[0] for call in bus.interface.calls], ["Get", "GetSettings", "Get"]) + self.assertTrue(all(0 < call[-1] <= 2 for call in bus.interface.calls)) + + def test_profile_change_is_rejected(self): + bus = FakeBus() + bus.interface.versions = iter([3, 4]) + with self.assertRaisesRegex(RuntimeError, "changed"): + scenario_bus.ScenarioBus(bus, FakeDBus, "/profile", 2).profile() + + def test_lifecycle_ports_share_deadline_and_distinguish_outcomes(self): + calls = [] + now = [0] + class Ports: + def mark(self): + return 0 + def register(self, request, cursor, pid): + pass + def pid(self, request): + return 123 + def submit_held(self, **kwargs): + calls.append(("held", kwargs)) + now[0] += 1 + return "request" + def disconnect(self, request, **kwargs): + calls.append(("disconnect", request, kwargs)) + def allow_later(self, **kwargs): + calls.append(("allow", kwargs)) + def update_separately(self, **kwargs): + calls.append(("separate", kwargs)) + def release(self, request, **kwargs): + calls.append(("release", request, kwargs)) + def wait(self, request, **kwargs): + calls.append(("wait", request, kwargs)) + return "version-mismatch" + def restart(self, **kwargs): + calls.append(("restart", kwargs)) + ports = Ports() + adapter = scenario_bus.ScenarioAdapter(None, ports, ports, ports, ports, 5, clock=lambda: now[0]) + request = adapter.submit_held(metric=42, fenced=True) + adapter.disconnect(request) + adapter.allow_later() + adapter.update_separately(metric=600, fenced=True) + adapter.release(request) + adapter.await_rejection(request) + adapter.restart() + self.assertEqual([c[0] for c in calls], ["held", "disconnect", "allow", "separate", "release", "wait", "restart"]) + self.assertEqual(calls[0][1]["timeout"], 5) + self.assertEqual(calls[-1][1]["timeout"], 4) + with self.assertRaisesRegex(AssertionError, "expected applied"): + adapter.await_completion(request) + now[0] = 5 + with self.assertRaises(TimeoutError): + adapter.restart() + + def test_audit_capture_is_marked_before_submission(self): + line = b'audit: op="connection-update" uuid="id" pid=123 result="success"\n' + stream = io.BytesIO(line) + capture = CaptureReader(stream, ":1.4") + now = [0.0] + def pause(duration): + now[0] += duration + observer = scenario_bus.AuditScenarioObserver(capture, ":1.4", 123, "id", clock=lambda: now[0], pause=pause) + class Clients: + def pid(self, request): + return 123 + def submit_held(self, **kwargs): + stream.seek(0, 2) + stream.write(line) + return "held" + adapter = scenario_bus.ScenarioAdapter(None, Clients(), None, observer, None, 1, clock=lambda: now[0]) + request = adapter.submit_held(metric=1, fenced=False) + adapter.await_completion(request) + self.assertEqual(now[0], 0) + self.assertGreater(adapter.remaining(), 0) + + def test_audit_rejects_mismatched_launched_pid(self): + observer = scenario_bus.AuditScenarioObserver(CaptureReader(io.BytesIO(), ":1.4"), ":1.4", 123, "id") + with self.assertRaisesRegex(ValueError, "PID"): + observer.register("held", 0, 124) + + def test_audit_missing_or_duplicate_is_inconclusive(self): + line = b'audit: op="connection-update" uuid="id" pid=123 result="success"\n' + for data in (b"", line + line): + with self.subTest(data=data): + now = [0.0] + def pause(duration): + now[0] += duration + observer = scenario_bus.AuditScenarioObserver(CaptureReader(io.BytesIO(data), ":1.4"), ":1.4", 123, "id", clock=lambda: now[0], pause=pause) + observer.register("held", 0, 123) + self.assertEqual(observer.wait("held", timeout=0.1), "inconclusive") + + def test_owner_reads_unique_name(self): + bus = FakeBus() + adapter = scenario_bus.ScenarioBus(bus, FakeDBus, "/profile", 2) + self.assertEqual(adapter.owner(), ":1.7") + self.assertEqual(bus.interface.calls, [("GetNameOwner", update2_client.SERVICE, 2)]) + bus.interface.name = "not-unique" + with self.assertRaisesRegex(RuntimeError, "unique"): + adapter.owner() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_scenarios.py b/tests/nm-isolated/test_scenarios.py new file mode 100644 index 00000000..5928ce27 --- /dev/null +++ b/tests/nm-isolated/test_scenarios.py @@ -0,0 +1,146 @@ +"""Offline scenario orchestration tests using a deterministic fake adapter.""" + +import unittest +from dataclasses import dataclass + +import scenarios + + +@dataclass(frozen=True) +class Profile: + metric: int + version: int + + +class Adapter: + def __init__(self): + self.current = Profile(600, 2) + self.events = [] + self.pending = None + self.bus_owner = ":1.1" + self.advance_fence = True + self.ignore_fence = False + self.allowing = False + self.retired = False + + def profile(self): + if self.retired: + raise RuntimeError("retired") + return self.current + + def retire(self): + self.retired = True + + def submit_held(self, *, metric, fenced): + if self.allowing: + raise RuntimeError("request was not held") + self.events.append(("submit", metric, fenced)) + self.pending = (metric, self.current.version if fenced else None) + return "held" + + def disconnect(self, request): + self.events.append(("disconnect", request)) + + def allow_later(self): + self.allowing = True + self.events.append("allow-later") + + def hold_later(self): + self.allowing = False + self.events.append("hold-later") + + def update_separately(self, *, metric, fenced): + self.events.append(("separate", metric, fenced)) + if self.advance_fence: + self.current = Profile(metric, self.current.version + 1) + + def release(self, request): + self.events.append(("release", request)) + metric, version = self.pending + self.rejected = version is not None and version != self.current.version and not self.ignore_fence + if not self.rejected: + self.current = Profile(metric, self.current.version + 1) + + def await_completion(self, request): + if self.rejected: + raise AssertionError("unexpected rejection") + + def await_rejection(self, request): + if not self.rejected: + raise AssertionError("expected rejection") + + def owner(self): + return self.bus_owner + + def restart(self): + self.bus_owner = ":1.2" + self.current = Profile(self.current.metric, 1) + + +class ScenarioTests(unittest.TestCase): + def test_unfenced_delayed_write_changes_profile(self): + adapter = Adapter() + before, after = scenarios.no_fence(adapter, 20101) + self.assertEqual((before, after), (Profile(600, 2), Profile(20101, 3))) + self.assertEqual(adapter.events, [("submit", 20101, False), ("disconnect", "held"), ("release", "held")]) + + def test_separate_fence_changes_version_before_release(self): + adapter = Adapter() + before, fence, after = scenarios.fenced(adapter, 20101) + self.assertEqual((before, fence, after), (Profile(600, 2), Profile(600, 3), Profile(600, 3))) + self.assertEqual(adapter.events, [("submit", 20101, True), ("disconnect", "held"), "allow-later", ("separate", 600, True), "hold-later", ("release", "held")]) + + def test_no_version_advance_or_ignored_fence_fails(self): + adapter = Adapter() + adapter.advance_fence = False + with self.assertRaisesRegex(AssertionError, "advance version"): + scenarios.fenced(adapter, 20101) + adapter = Adapter() + adapter.ignore_fence = True + with self.assertRaisesRegex(AssertionError, "expected rejection"): + scenarios.fenced(adapter, 20101) + + def test_next_scenario_holds_after_success_and_writer_failure(self): + adapter = Adapter() + scenarios.fenced(adapter, 20101) + scenarios.no_fence(adapter, 20102) + adapter = Adapter() + def fail(**kwargs): + raise RuntimeError("writer failed") + adapter.update_separately = fail + with self.assertRaisesRegex(RuntimeError, "writer failed"): + scenarios.fenced(adapter, 20101) + self.assertFalse(adapter.allowing) + self.assertTrue(adapter.retired) + with self.assertRaisesRegex(RuntimeError, "retired"): + scenarios.no_fence(adapter, 20102) + + def test_each_post_hold_failure_retires_authority(self): + for stage in ("disconnect", "allow_later", "update_separately", "hold_later", "release", "await_rejection"): + with self.subTest(stage=stage): + adapter = Adapter() + def fail(*args, **kwargs): + raise TimeoutError("injected") + setattr(adapter, stage, fail) + with self.assertRaises(TimeoutError): + scenarios.fenced(adapter, 20101) + self.assertTrue(adapter.retired) + with self.assertRaisesRegex(RuntimeError, "retired"): + scenarios.no_fence(adapter, 20102) + + def test_restart_requires_changed_unique_owner(self): + adapter = Adapter() + result = scenarios.restart(adapter) + self.assertEqual(result, (":1.1", Profile(600, 2), ":1.2", Profile(600, 1))) + adapter.restart = lambda: None + with self.assertRaisesRegex(AssertionError, "owner did not change"): + scenarios.restart(adapter) + + def test_identical_test_metric_is_refused(self): + for scenario in (scenarios.no_fence, scenarios.fenced): + with self.assertRaises(ValueError): + scenario(Adapter(), 600) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_update2_client.py b/tests/nm-isolated/test_update2_client.py new file mode 100644 index 00000000..99b8ce91 --- /dev/null +++ b/tests/nm-isolated/test_update2_client.py @@ -0,0 +1,204 @@ +"""Offline request construction tests; no D-Bus connection is made.""" + +import unittest + +import update2_client as client + + +class TypedInt(int): + pass + + +class Int64(TypedInt): + pass + + +class UInt32(TypedInt): + pass + + +class UInt64(TypedInt): + pass + + +class Dictionary(dict): + def __init__(self, values, signature): + super().__init__(values) + self.signature = signature + + +class FakeDBus: + Int64 = Int64 + UInt32 = UInt32 + UInt64 = UInt64 + Dictionary = Dictionary + + @staticmethod + def Interface(obj, name): + return obj.interfaces[name] + + +class FakeConnection: + def __init__(self): + self.settings = {"connection": {"id": "fixture"}, "ipv4": {"route-metric": Int64(600)}} + self.calls = [] + + def GetSettings(self, *, timeout): + return self.settings + + def Update2(self, *args, **kwargs): + self.calls.append(args) + self.handlers = kwargs + return {"result": "accepted"} + + +class FakeProperties: + def __init__(self, version): + self.version = version + self.calls = [] + + def Get(self, *args, timeout): + self.calls.append(args) + return self.version + + +class FakeBus: + def __init__(self, version=7): + self.connection = FakeConnection() + self.properties = FakeProperties(version) + self.interfaces = {client.CONNECTION: self.connection, client.PROPERTIES: self.properties} + self.calls = [] + + def get_object(self, *args, **kwargs): + self.calls.append(args) + return self + + def close(self): + self.closed = True + + +class FakeControl: + def __init__(self): + self.pending = [] + + def Pending(self, timeout): + return self.pending + + +class Clock: + def __init__(self): + self.value = 0 + + def __call__(self): + return self.value + + def tick(self): + self.value += 1 + + +class Update2Tests(unittest.TestCase): + def test_metric_update_builds_typed_fenced_request(self): + bus = FakeBus() + result = client.update_profile(bus, FakeDBus, "/org/freedesktop/NetworkManager/Settings/1", 20101) + self.assertEqual(result, {"result": "accepted"}) + self.assertEqual(bus.calls, [(client.SERVICE, "/org/freedesktop/NetworkManager/Settings/1")]) + self.assertEqual(bus.properties.calls, [(client.CONNECTION, "VersionId")]) + settings, flags, args = bus.connection.calls[0] + self.assertEqual(settings.signature, "sa{sv}") + self.assertEqual(settings["ipv4"].signature, "sv") + self.assertEqual(settings["connection"], {"id": "fixture"}) + self.assertIsInstance(settings["ipv4"]["route-metric"], Int64) + self.assertEqual(settings["ipv4"]["route-metric"], 20101) + self.assertEqual(bus.connection.settings["ipv4"]["route-metric"], 600) + self.assertIsInstance(flags, UInt32) + self.assertEqual(flags, 2) + self.assertEqual(args.signature, "sv") + self.assertIsInstance(args["version-id"], UInt64) + self.assertEqual(args["version-id"], 7) + + def test_omitted_metric_sends_identical_settings(self): + bus = FakeBus() + client.update_profile(bus, FakeDBus, "/profile") + self.assertEqual(bus.connection.calls[0][0], bus.connection.settings) + + def test_submission_uses_one_decreasing_deadline(self): + bus = FakeBus() + now = [0] + def get(*args, timeout): + self.assertEqual(timeout, 3) + now[0] += 1 + return 7 + def settings(*, timeout): + self.assertEqual(timeout, 2) + now[0] += 1 + return bus.connection.settings + bus.properties.Get = get + bus.connection.GetSettings = settings + client.update_profile(bus, FakeDBus, "/profile", timeout=3, clock=lambda: now[0]) + self.assertEqual(bus.connection.handlers["timeout"], 1) + + def test_explicit_unfenced_control_omits_version_id(self): + bus = FakeBus(version=0) + client.update_profile(bus, FakeDBus, "/profile", 10, unfenced=True) + self.assertEqual(bus.properties.calls, []) + self.assertEqual(bus.connection.calls[0][2], {}) + self.assertEqual(bus.connection.calls[0][2].signature, "sv") + + def test_zero_version_refuses_unfenced_update(self): + bus = FakeBus(version=0) + with self.assertRaises(ValueError): + client.update_profile(bus, FakeDBus, "/profile") + self.assertEqual(bus.connection.calls, []) + + def test_disconnect_only_after_authority_holds_request(self): + bus, control, clock = FakeBus(), FakeControl(), Clock() + def pump(): + self.assertFalse(getattr(bus, "closed", False)) + self.assertEqual(len(bus.connection.calls), 1) + control.pending = ["held-update"] + clock.tick() + result = client.submit_and_disconnect(bus, control, FakeDBus, "/profile", 10, 3, pump, clock) + self.assertEqual(result, "held-update") + self.assertTrue(bus.closed) + self.assertEqual(set(bus.connection.handlers), {"reply_handler", "error_handler", "timeout"}) + + def test_timeout_does_not_disconnect(self): + bus, control, clock = FakeBus(), FakeControl(), Clock() + with self.assertRaises(TimeoutError): + client.submit_and_disconnect(bus, control, FakeDBus, "/profile", 10, 2, clock.tick, clock) + self.assertFalse(getattr(bus, "closed", False)) + + def test_completion_before_hold_does_not_disconnect(self): + bus, control, clock = FakeBus(), FakeControl(), Clock() + def pump(): + bus.connection.handlers["error_handler"](RuntimeError("denied")) + clock.tick() + with self.assertRaisesRegex(RuntimeError, "completed before"): + client.submit_and_disconnect(bus, control, FakeDBus, "/profile", 10, 3, pump, clock) + self.assertFalse(getattr(bus, "closed", False)) + + def test_existing_or_ambiguous_requests_are_refused(self): + bus, control, clock = FakeBus(), FakeControl(), Clock() + control.pending = ["existing"] + with self.assertRaisesRegex(RuntimeError, "no pending"): + client.submit_and_disconnect(bus, control, FakeDBus, "/profile", 10, 3, clock.tick, clock) + self.assertEqual(bus.connection.calls, []) + control.pending = [] + def pump(): + control.pending = ["one", "two"] + clock.tick() + with self.assertRaisesRegex(RuntimeError, "multiple"): + client.submit_and_disconnect(bus, control, FakeDBus, "/profile", 10, 3, pump, clock) + self.assertFalse(getattr(bus, "closed", False)) + + def test_bus_errors_propagate(self): + bus = FakeBus() + def fail(*args, **kwargs): + raise RuntimeError("update failed") + bus.connection.Update2 = fail + with self.assertRaisesRegex(RuntimeError, "update failed"): + client.update_profile(bus, FakeDBus, "/profile", 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_vm_factory.py b/tests/nm-isolated/test_vm_factory.py new file mode 100644 index 00000000..fade9b26 --- /dev/null +++ b/tests/nm-isolated/test_vm_factory.py @@ -0,0 +1,68 @@ +"""Injected QEMU factory composition tests, without launching processes.""" + +import unittest +from unittest.mock import patch +from contextlib import contextmanager + +import vm_factory + + +class Socket: + def __init__(self, events): + self.events = events + def close(self): + self.events.append("socket-close") + + +class FactoryTests(unittest.TestCase): + def test_fresh_workspace_readiness_and_cleanup(self): + events = [] + paths = iter(["/one", "/two"]) + @contextmanager + def start(base, workspace, **kwargs): + events.append(("start", workspace, kwargs["shutdown_timeout"])) + try: + yield workspace, "overlay", object() + finally: + events.append(("stop", workspace)) + def connect(workspace, process, timeout): + events.append(("connect", workspace, timeout)) + return Socket(events) + owner = vm_factory.make_owner("/base", lambda: next(paths), qemu="/qemu", qemu_img="/img", attempts=1, timeout=5, vm_start=start, connect_ready=connect, clock=lambda: 0) + owner.run(lambda socket: None) + owner.run(lambda socket: None) + self.assertEqual(events, [("start", "/one", 5), ("connect", "/one", 5), "socket-close", ("stop", "/one"), ("start", "/two", 5), ("connect", "/two", 5), "socket-close", ("stop", "/two")]) + + def test_scenario_request_carries_timeout_and_returns_result(self): + with patch.object(vm_factory.guest_transport, "DeadlineStream", return_value="stream") as stream, patch.object(vm_factory.guest_transport, "request", return_value={"result": "ok"}) as request: + self.assertEqual(vm_factory.request_scenario("socket", "fenced", 12, 3), {"result": "ok"}) + stream.assert_called_once_with("socket", 3) + request.assert_called_once_with("stream", "scenario", "scenario", {"scenario": "fenced", "metric": 12, "timeout": 3}) + + def test_deadline_prevents_connect_after_start(self): + now = [0] + @contextmanager + def start(*args, **kwargs): + now[0] = 2 + yield "one", "overlay", object() + calls = [] + owner = vm_factory.make_owner("/base", lambda: "/work", qemu="/qemu", qemu_img="/img", attempts=1, timeout=2, vm_start=start, connect_ready=lambda *args: calls.append(args), clock=lambda: now[0]) + with self.assertRaises(RuntimeError): + owner.run(lambda socket: None) + self.assertEqual(calls, []) + + def test_teardown_failure_does_not_retry(self): + calls = [] + @contextmanager + def start(*args, **kwargs): + calls.append("start") + yield "one", "overlay", object() + raise RuntimeError("teardown") + owner = vm_factory.make_owner("/base", lambda: "/work", qemu="/qemu", qemu_img="/img", attempts=2, timeout=5, vm_start=start, connect_ready=lambda *args: Socket([]), clock=lambda: 0) + with self.assertRaisesRegex(RuntimeError, "teardown failed"): + owner.run(lambda socket: None) + self.assertEqual(calls, ["start"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/test_vm_scenario_owner.py b/tests/nm-isolated/test_vm_scenario_owner.py new file mode 100644 index 00000000..cc382e91 --- /dev/null +++ b/tests/nm-isolated/test_vm_scenario_owner.py @@ -0,0 +1,61 @@ +"""Offline fresh-VM composition tests with injected contexts.""" + +import unittest +from contextlib import contextmanager +from types import SimpleNamespace + +from vm_scenario_owner import VMScenarioOwner + + +class Tests(unittest.TestCase): + def test_failure_retries_only_after_first_vm_exit(self): + events = [] + @contextmanager + def vm_factory(*, timeout): + name = f"vm-{len([x for x in events if x.startswith('start')])}" + events.append("start " + name) + try: + yield SimpleNamespace(identity=name) + finally: + events.append("stop " + name) + @contextmanager + def connect(vm, *, timeout): + yield vm + def scenario(vm): + if vm.identity == "vm-0": + raise ValueError("first") + return vm.identity + owner = VMScenarioOwner(vm_factory, connect, attempts=2, timeout=5) + self.assertEqual(owner.run(scenario), "vm-1") + self.assertEqual(events, ["start vm-0", "stop vm-0", "start vm-1", "stop vm-1"]) + + def test_duplicate_identity_is_refused(self): + @contextmanager + def vm_factory(*, timeout): + yield SimpleNamespace(identity="same") + @contextmanager + def connect(vm, *, timeout): + yield vm + owner = VMScenarioOwner(vm_factory, connect, attempts=1, timeout=5) + owner.run(lambda vm: None) + with self.assertRaisesRegex(RuntimeError, "all fresh"): + owner.run(lambda vm: None) + + def test_teardown_failure_does_not_retry(self): + calls = [] + @contextmanager + def vm_factory(*, timeout): + calls.append("start") + yield SimpleNamespace(identity="one") + raise RuntimeError("teardown") + @contextmanager + def connect(vm, *, timeout): + yield vm + owner = VMScenarioOwner(vm_factory, connect, attempts=2, timeout=5) + with self.assertRaisesRegex(RuntimeError, "teardown failed"): + owner.run(lambda vm: None) + self.assertEqual(calls, ["start"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nm-isolated/update2_client.py b/tests/nm-isolated/update2_client.py new file mode 100644 index 00000000..6eb5b9c6 --- /dev/null +++ b/tests/nm-isolated/update2_client.py @@ -0,0 +1,123 @@ +#!/usr/bin/python3 +"""Send one version-fenced Update2 on an explicitly supplied test bus.""" + +import argparse +import os +import subprocess +import time +from pathlib import Path + +SERVICE = "org.freedesktop.NetworkManager" +CONNECTION = "org.freedesktop.NetworkManager.Settings.Connection" +PROPERTIES = "org.freedesktop.DBus.Properties" +IN_MEMORY = 0x2 + + +def update_profile(bus, dbus, profile_path, metric=None, reply_handler=None, error_handler=None, *, unfenced=False, timeout=10, clock=time.monotonic): + """Submit an in-memory Update2, fenced unless explicitly disabled.""" + if timeout <= 0: + raise ValueError("timeout must be positive") + deadline = clock() + timeout + def remaining(): + value = deadline - clock() + if value <= 0: + raise TimeoutError("Update2 submission deadline expired") + return value + obj = bus.get_object(SERVICE, profile_path, introspect=False) + connection = dbus.Interface(obj, CONNECTION) + args = {} + if not unfenced: + properties = dbus.Interface(obj, PROPERTIES) + version = int(properties.Get(CONNECTION, "VersionId", timeout=remaining())) + if version <= 0: + raise ValueError("a non-zero profile version is required") + args["version-id"] = dbus.UInt64(version) + settings = connection.GetSettings(timeout=remaining()) + settings = {name: dict(values) for name, values in settings.items()} + if metric is not None: + settings["ipv4"]["route-metric"] = dbus.Int64(metric) + kwargs = {"timeout": remaining()} + if reply_handler is not None and error_handler is not None: + kwargs.update(reply_handler=reply_handler, error_handler=error_handler) + return connection.Update2( + dbus.Dictionary({name: dbus.Dictionary(values, signature="sv") for name, values in settings.items()}, signature="sa{sv}"), + dbus.UInt32(IN_MEMORY), + dbus.Dictionary(args, signature="sv"), + **kwargs, + ) + + +def submit_and_disconnect(bus, control, dbus, profile_path, metric, timeout, pump, clock=time.monotonic, *, unfenced=False): + """Disconnect only after the independent authority reports the new held request.""" + if timeout <= 0: + raise ValueError("timeout must be positive") + deadline = clock() + timeout + if control.Pending(timeout=timeout): + raise RuntimeError("authority must have no pending requests before submission") + outcome = [] + update_profile(bus, dbus, profile_path, metric, lambda result: outcome.append(("reply", result)), lambda error: outcome.append(("error", error)), unfenced=unfenced, timeout=deadline - clock(), clock=clock) + while clock() < deadline: + pump() + if outcome: + raise RuntimeError(f"Update2 completed before authorisation was held: {outcome[0][0]}") + remaining = deadline - clock() + if remaining <= 0: + break + added = {str(value) for value in control.Pending(timeout=remaining)} + if len(added) > 1: + raise RuntimeError("multiple new authorisation requests prevent identifying the update") + if added: + request_id = added.pop() + bus.close() + return request_id + raise TimeoutError("Update2 was not observed at the authority before the deadline") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--private-bus-address", required=True) + parser.add_argument("--profile-path", required=True) + parser.add_argument("--metric", type=int, help="IPv4 route metric; omit for an identical update") + parser.add_argument("--disconnect-after-held", action="store_true", help="disconnect after the authority observes the request") + parser.add_argument("--unfenced", action="store_true", help="omit version-id for the unfenced control") + parser.add_argument("--timeout", type=float, default=10, help="maximum seconds to wait for held authorisation") + args = parser.parse_args() + sentinel = Path("/run/nm-isolated-test/disposable-vm") + if os.geteuid() == 0 or sentinel.is_symlink() or not sentinel.is_file(): + raise SystemExit("refusing: non-root client and disposable VM sentinel required") + if subprocess.run(["systemd-detect-virt", "--vm", "--quiet"], check=False).returncode: + raise SystemExit("refusing: disposable VM required") + prefix = "unix:path=/run/nm-isolated-test/" + address = args.private_bus_address + if not address.startswith(prefix) or any(part in address for part in (",", ";", "..")): + raise SystemExit("refusing: dedicated test bus path required") + import dbus + import dbus.bus + + if args.disconnect_after_held: + if args.timeout <= 0: + parser.error("--timeout must be positive") + from dbus.mainloop.glib import DBusGMainLoop + from gi.repository import GLib + + DBusGMainLoop(set_as_default=True) + bus = dbus.bus.BusConnection(address) + control_bus = dbus.bus.BusConnection(address) + control = dbus.Interface(control_bus.get_object("org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority"), "test.nm.IsolatedControl") + context = GLib.MainContext.default() + + def pump(): + while context.pending(): + context.iteration(False) + time.sleep(0.01) + + request_id = submit_and_disconnect(bus, control, dbus, args.profile_path, args.metric, args.timeout, pump, unfenced=args.unfenced) + print(request_id, flush=True) + control_bus.close() + else: + bus = dbus.bus.BusConnection(address) + update_profile(bus, dbus, args.profile_path, args.metric, unfenced=args.unfenced, timeout=args.timeout) + + +if __name__ == "__main__": + main() diff --git a/tests/nm-isolated/vm_factory.py b/tests/nm-isolated/vm_factory.py new file mode 100644 index 00000000..3e43df2c --- /dev/null +++ b/tests/nm-isolated/vm_factory.py @@ -0,0 +1,45 @@ +"""Compose QEMU and readiness transport for source-only fixture scenarios.""" + +import time +from contextlib import contextmanager +from dataclasses import dataclass + +import guest_transport +import host_transport +import qemu_vm +from vm_scenario_owner import VMScenarioOwner + + +@dataclass(frozen=True) +class VM: + identity: str + workspace: object + process: object + + +def request_scenario(sock, name, metric, timeout): + """Request one scenario and return its guest-supplied result.""" + stream = guest_transport.DeadlineStream(sock, timeout) + return guest_transport.request(stream, "scenario", "scenario", {"scenario": name, "metric": metric, "timeout": timeout}) + + +def make_owner(base_image, workspace_factory, *, qemu, qemu_img, attempts, timeout, vm_start=qemu_vm.disposable_vm, connect_ready=host_transport.connect_ready, clock=time.monotonic): + """Create a fresh-workspace QEMU owner returning a ready transport socket. + + The workspace factory must return an empty private directory for each call. The returned socket is not a scenario adapter. Guest provisioning and the production isolation guard remain independent prerequisites. + """ + @contextmanager + def start(*, timeout): + workspace = workspace_factory() + with vm_start(base_image, workspace, qemu=qemu, qemu_img=qemu_img, shutdown_timeout=timeout) as (name, overlay, process): + yield VM(name, workspace, process) + + @contextmanager + def connect(vm, *, timeout): + sock = connect_ready(vm.workspace, vm.process, timeout) + try: + yield sock + finally: + sock.close() + + return VMScenarioOwner(start, connect, attempts=attempts, timeout=timeout, clock=clock) diff --git a/tests/nm-isolated/vm_harness/README.md b/tests/nm-isolated/vm_harness/README.md new file mode 100644 index 00000000..6be73919 --- /dev/null +++ b/tests/nm-isolated/vm_harness/README.md @@ -0,0 +1,3 @@ +Host-side harness that produced the first real-daemon results for the Update2 fixture. `build_guest_layer_and_run.sh` runs on a KVM Linux host: it installs this directory's fixture files and `held_update2_scenario.sh` into a copy of a read-only golden Ubuntu 26.04 image (offline, via a cloud-init seed), then boots the result through `qemu_vm.disposable_vm` and prints the single JSON line the guest writes to its virtio port. The golden image must be built from a signature-verified Ubuntu cloud image with `dbus`, `network-manager`, `python3`, `python3-dbus`, `python3-gi` and `systemd` installed. + +The scenario starts the private bus, the PolicyKit stub and NetworkManager as root, adds in-memory profiles, runs the non-root `update2_client.py` with `--disconnect-after-held`, optionally changes the profile as root while the request is held, then releases authorisation and reads the final route metric. It is evidence for one NetworkManager version on in-memory profiles with the stub as authority; persistent profiles, a NetworkManager restart and the checkpoint expiry race are not covered. diff --git a/tests/nm-isolated/vm_harness/build_guest_layer_and_run.sh b/tests/nm-isolated/vm_harness/build_guest_layer_and_run.sh new file mode 100644 index 00000000..54f14d14 --- /dev/null +++ b/tests/nm-isolated/vm_harness/build_guest_layer_and_run.sh @@ -0,0 +1,43 @@ +set -eu +ROOT=~/Developer/ExaDev/Internal/github-runner-worktrees/nm-isolated-vm +cd $ROOT && rm -rf build2 && mkdir build2 && cd build2 +python3 - <<'PY' +import base64, hashlib, json, os, sys +root = os.path.expanduser("~/Developer/ExaDev/Internal/github-runner-worktrees/nm-isolated-vm") +sys.path.insert(0, root + "/fixture") +import guest_provision as gp +files, digests = [], {} +for name in gp.FILES: + data = open(os.path.join(root + "/fixture", name), "rb").read() + digests[name] = hashlib.sha256(data).hexdigest() + files.append({"path": f"{gp.DESTINATION}/{name}", "encoding": "b64", "content": base64.b64encode(data).decode(), "permissions": "0755" if name.endswith(".sh") else "0644"}) +smoke = open(os.path.expanduser('~/Developer/ExaDev/Internal/github-runner-worktrees/nm-isolated-vm/smoke.sh')).read() +files.append({"path": gp.DESTINATION + "/DIGESTS", "content": "".join(f"{n} {h}\n" for n, h in digests.items()), "permissions": "0644"}) +files.append({"path": gp.DESTINATION + "/smoke.sh", "content": smoke, "permissions": "0755"}) +files.append({"path": "/etc/systemd/system/nm-smoke.service", "content": "[Unit]\nDescription=Fixture smoke\nAfter=multi-user.target\n[Service]\nType=oneshot\nExecStart=/opt/nm-isolated-test/smoke.sh\n[Install]\nWantedBy=multi-user.target\n", "permissions": "0644"}) +ud = {"write_files": files, "runcmd": [["systemctl", "enable", "nm-smoke.service"]], "power_state": {"mode": "poweroff", "timeout": 30, "condition": True}} +open("user-data", "w").write("#cloud-config\n" + json.dumps(ud)) +open("meta-data", "w").write("instance-id: nm-golden-23\nlocal-hostname: nm-golden\n") +print("files", len(files)) +PY +cloud-localds seed2.img user-data meta-data +qemu-img create -f qcow2 -F qcow2 -b $ROOT/golden-nm-1.54.3.qcow2 build2.qcow2 >/dev/null +sg kvm -c "timeout 600 qemu-system-x86_64 -enable-kvm -cpu host -m 2048 -smp 2 -display none -serial file:console.log -monitor none -nic none -drive file=build2.qcow2,if=virtio,format=qcow2 -drive file=seed2.img,if=virtio,format=raw,readonly=on" /dev/null; rm -f golden2-nm-fixture.qcow2; qemu-img convert -O qcow2 build2/build2.qcow2 golden2-nm-fixture.qcow2 && chmod a-w golden2-nm-fixture.qcow2 +cd fixture && sg kvm -c "python3 - <<'PY' +import os, shutil, socket, tempfile, time, qemu_vm +root = os.path.expanduser('~/Developer/ExaDev/Internal/github-runner-worktrees/nm-isolated-vm') +w = tempfile.mkdtemp(prefix='nmw', dir=root); os.chmod(w, 0o700) +with qemu_vm.disposable_vm(root + '/golden2-nm-fixture.qcow2', w, qemu='/usr/bin/qemu-system-x86_64', qemu_img='/usr/bin/qemu-img') as (name, overlay, process): + for _ in range(100): + if os.path.exists(w + '/guest.sock'): break + time.sleep(0.1) + s = socket.socket(socket.AF_UNIX); s.settimeout(90); s.connect(w + '/guest.sock') + buf = b'' + while b'\n' not in buf: + c = s.recv(4096) + if not c: break + buf += c + print('guest line:', buf.decode(errors='replace').strip()) +shutil.rmtree(w, ignore_errors=True) +PY" diff --git a/tests/nm-isolated/vm_harness/held_update2_scenario.sh b/tests/nm-isolated/vm_harness/held_update2_scenario.sh new file mode 100644 index 00000000..bee94eae --- /dev/null +++ b/tests/nm-isolated/vm_harness/held_update2_scenario.sh @@ -0,0 +1,319 @@ +#!/bin/bash +# Real-NetworkManager scenario in the disposable guest: a non-root Update2 is held by the PolicyKit stub, the client disconnects, authorisation is then released; the report says whether NetworkManager applied it anyway. +set -u +D=/opt/nm-isolated-test +PORT=/dev/virtio-ports/test.nm.fixture +RUN=/run/nm-isolated-test +ADDR=unix:path=$RUN/bus +mkdir -p $RUN && touch $RUN/disposable-vm +id nm-isolated >/dev/null 2>&1 || useradd --system --shell /usr/sbin/nologin --no-create-home nm-isolated +systemctl stop NetworkManager 2>/dev/null +dbus-daemon --config-file=$D/bus.conf --nofork >$RUN/bus.log 2>&1 & +BUSPID=$! +for _ in $(seq 1 50); do [ -S $RUN/bus ] && break; sleep 0.1; done +chmod 0666 $RUN/bus 2>/dev/null +export DBUS_SYSTEM_BUS_ADDRESS=$ADDR +python3 $D/polkit_stub.py --private-bus-address $ADDR >$RUN/stub.log 2>&1 & +STUBPID=$! +modprobe dummy 2>/dev/null; ip link add dummy0 type dummy 2>$RUN/dummy.err && ip link set dummy0 up +NetworkManager --no-daemon --log-level=INFO --log-domains=ALL >$RUN/nm.log 2>&1 & +NMPID=$! +export NMPID +python3 - >$RUN/scenario.json 2>$RUN/scenario.err <<'PY' +import dbus, json, subprocess, time, os +ADDR = "unix:path=/run/nm-isolated-test/bus" +out = {} +def conn(): + return dbus.bus.BusConnection(ADDR) +b = None +for _ in range(100): + try: + b = conn() + s = b.get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings") + dbus.Interface(s, "org.freedesktop.NetworkManager.Settings").ListConnections() + break + except dbus.DBusException: + time.sleep(0.3) +out["nm_ready"] = b is not None +settings_if = dbus.Interface(b.get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings"), "org.freedesktop.NetworkManager.Settings") +ethernet = "802-3-ethernet" +def make(uuid): + prof = {"connection": {"id": "fixture-" + uuid[:4], "type": ethernet, "uuid": uuid, "autoconnect": False}, + ethernet: {}, "ipv4": {"method": "auto", "route-metric": dbus.Int64(100)}, "ipv6": {"method": "ignore"}} + path, _ = settings_if.AddConnection2(prof, dbus.UInt32(2), {}) + return str(path) +def conn_if(path): + return dbus.Interface(conn().get_object("org.freedesktop.NetworkManager", path), "org.freedesktop.NetworkManager.Settings.Connection") +def metric(path): + return int(conn_if(path).GetSettings()["ipv4"].get("route-metric", -1)) +def version(path): + o = conn().get_object("org.freedesktop.NetworkManager", path) + try: + return int(dbus.Interface(o, "org.freedesktop.DBus.Properties").Get("org.freedesktop.NetworkManager.Settings.Connection", "VersionId")) + except dbus.DBusException as e: + return "n/a:" + e.get_dbus_name() +ctl_bus = conn() +for _ in range(100): + if "org.freedesktop.PolicyKit1" in [str(n) for n in ctl_bus.list_names()]: + break + time.sleep(0.2) +out["polkit_stub_on_bus"] = "org.freedesktop.PolicyKit1" in [str(n) for n in ctl_bus.list_names()] +ctl = dbus.Interface(ctl_bus.get_object("org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority"), "test.nm.IsolatedControl") +def case(label, uuid, unfenced, root_metric): + path = make(uuid) + c = {"v0": version(path), "m0": metric(path)} + args = ["runuser", "-u", "nm-isolated", "--", "python3", "/opt/nm-isolated-test/update2_client.py", "--private-bus-address", ADDR, "--profile-path", path, "--metric", "150", "--disconnect-after-held", "--timeout", "30"] + if unfenced: + args.append("--unfenced") + r = subprocess.run(args, capture_output=True, text=True, timeout=60) + c["client_rc"] = r.returncode + if r.returncode: + c["client_err"] = r.stderr.strip()[-160:] + time.sleep(1) + pending = [str(x) for x in ctl.Pending()] + c["pending"] = len(pending) + if root_metric is not None: + s2 = conn_if(path).GetSettings() + new = {k: {kk: vv for kk, vv in v.items()} for k, v in s2.items()} + new["ipv4"]["route-metric"] = dbus.Int64(root_metric) + conn_if(path).Update2(new, dbus.UInt32(2), {}) + c["root_set"] = root_metric + c["v_after_root"] = version(path) + for cid in pending: + ctl.Release(cid, True) + time.sleep(2) + c["metric_final"] = metric(path) + c["v_final"] = version(path) + out[label] = c +case("A_fenced_no_interference", "aaaaaaaa-0000-0000-0000-000000000001", False, None) +case("B_fenced_after_root_change", "bbbbbbbb-0000-0000-0000-000000000002", False, 200) +case("C_unfenced_after_root_change", "cccccccc-0000-0000-0000-000000000003", True, 200) + +def nm_ready(timeout=40): + end = time.time() + timeout + while time.time() < end: + try: + dbus.Interface(conn().get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings"), "org.freedesktop.NetworkManager.Settings").ListConnections() + return True + except dbus.DBusException: + time.sleep(0.3) + return False +def uuid_of(pp): + return str(conn_if(pp).GetSettings()["connection"]["uuid"])[:8] +def restart_nm(): + pid = int(os.environ["NMPID"]) + os.kill(pid, 15) + for _ in range(100): + try: + os.kill(pid, 0) + time.sleep(0.1) + except OSError: + break + p = subprocess.Popen(["NetworkManager", "--no-daemon", "--log-level=INFO", "--log-domains=ALL"], stdout=open("/run/nm-isolated-test/nm2.log", "w"), stderr=subprocess.STDOUT) + os.environ["NMPID"] = str(p.pid) + return nm_ready() +def set_metric(path, value, version_id=None): + s2 = conn_if(path).GetSettings() + new = {k: {kk: vv for kk, vv in v.items()} for k, v in s2.items()} + new["ipv4"]["route-metric"] = dbus.Int64(value) + args = {"version-id": dbus.UInt64(version_id)} if version_id is not None else {} + conn_if(path).Update2(new, dbus.UInt32(1), args) +# Case D: persistent profile; a client reads its version, NetworkManager restarts, root changes the profile, then the client writes with the old version. +E = {} +prof = {"connection": {"id": "fixture-persist", "type": ethernet, "uuid": "dddddddd-0000-0000-0000-000000000004", "autoconnect": False}, + ethernet: {}, "ipv4": {"method": "auto", "route-metric": dbus.Int64(100)}, "ipv6": {"method": "ignore"}} +path, _ = settings_if.AddConnection2(prof, dbus.UInt32(1), {}) +path = str(path) +set_metric(path, 110) +E["v_client_read"] = version(path) +E["m_client_read"] = metric(path) +E["owner_before"] = str(conn().get_name_owner("org.freedesktop.NetworkManager")) +E["restarted"] = restart_nm() +E["owner_after"] = str(conn().get_name_owner("org.freedesktop.NetworkManager")) +paths = [str(x) for x in dbus.Interface(conn().get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings"), "org.freedesktop.NetworkManager.Settings").ListConnections()] +E["profile_survived"] = path in paths +E["old_path_now_uuid"] = uuid_of(path) if path in paths else None +path = [pp for pp in paths if str(conn_if(pp).GetSettings()["connection"]["uuid"]) == "dddddddd-0000-0000-0000-000000000004"][0] +E["profiles_after_restart"] = {pp.rsplit("/", 1)[-1]: [uuid_of(pp), metric(pp)] for pp in paths} +E["v_after_restart"] = version(path) +E["m_after_restart"] = metric(path) +set_metric(path, 200) +E["v_after_root_change"] = version(path) +E["m_after_root_change"] = metric(path) +try: + set_metric(path, 150, version_id=E["v_client_read"] if isinstance(E["v_client_read"], int) else None) + E["stale_write"] = "accepted" +except dbus.DBusException as e: + E["stale_write"] = "rejected:" + e.get_dbus_name().split(".")[-1] +E["m_final"] = metric(path) +out["D_restart_version_reuse"] = E + +# Case E: checkpoint expiry against a held write. +settings_if = dbus.Interface(conn().get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings"), "org.freedesktop.NetworkManager.Settings") +ctl_bus = conn() +ctl = dbus.Interface(ctl_bus.get_object("org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority"), "test.nm.IsolatedControl") +NMS = "org.freedesktop.NetworkManager" +mgr = dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager"), NMS) +E2 = {} +try: + dev = str(mgr.GetDeviceByIpIface("dummy0")) + E2["device"] = dev.rsplit("/", 1)[-1] +except dbus.DBusException as e: + dev = None + E2["device_error"] = e.get_dbus_name().split(".")[-1] +def checkpoints(): + o = conn().get_object(NMS, "/org/freedesktop/NetworkManager") + return len(dbus.Interface(o, "org.freedesktop.DBus.Properties").Get(NMS, "Checkpoints")) +def make_generic(uuid): + prof = {"connection": {"id": "gen-" + uuid[:4], "type": "generic", "uuid": uuid, "interface-name": "dummy0", "autoconnect": False}, + "generic": {}, "ipv4": {"method": "disabled", "route-metric": dbus.Int64(100)}, "ipv6": {"method": "ignore"}} + pth, _ = settings_if.AddConnection2(prof, dbus.UInt32(1), {}) + return str(pth) +if dev: + # E1: does a checkpoint roll back a persistent profile's contents? + p1 = make_generic("eeeeeeee-0000-0000-0000-000000000005") + cp = str(mgr.CheckpointCreate([dbus.ObjectPath(dev)], dbus.UInt32(4), dbus.UInt32(0))) + E2["E1_checkpoint"] = cp.rsplit("/", 1)[-1] + E2["E1_metric_at_checkpoint"] = metric(p1) + set_metric(p1, 120) + E2["E1_metric_after_change"] = metric(p1) + time.sleep(8) + E2["E1_checkpoints_left"] = checkpoints() + E2["E1_metric_after_expiry"] = metric(p1) + # E2: a held fenced write that lands after the checkpoint expired. + p2 = make_generic("ffffffff-0000-0000-0000-000000000006") + cp = mgr.CheckpointCreate([dbus.ObjectPath(dev)], dbus.UInt32(5), dbus.UInt32(0)) + E2["E2_metric_at_checkpoint"] = metric(p2) + r = subprocess.run(["runuser", "-u", "nm-isolated", "--", "python3", "/opt/nm-isolated-test/update2_client.py", "--private-bus-address", ADDR, "--profile-path", p2, "--metric", "150", "--disconnect-after-held", "--timeout", "30"], capture_output=True, text=True, timeout=60) + E2["E2_client_rc"] = r.returncode + time.sleep(1) + pend = [str(x) for x in ctl.Pending()] + E2["E2_pending"] = len(pend) + time.sleep(7) + E2["E2_checkpoints_left_before_release"] = checkpoints() + E2["E2_metric_before_release"] = metric(p2) + E2["E2_version_before_release"] = version(p2) + for cid in pend: + ctl.Release(cid, True) + time.sleep(2) + E2["E2_metric_final"] = metric(p2) + E2["E2_version_final"] = version(p2) +out["E_checkpoint_expiry"] = E2 + +# Case F: device checkpoint plus Reapply on an ACTIVE connection, the mechanism the offline transaction module uses. +F = {} +def routes(): + r = subprocess.run(["ip", "-4", "route", "show", "dev", "dummy0"], capture_output=True, text=True) + return " | ".join(x.strip() for x in r.stdout.splitlines())[:160] +if dev: + prof = {"connection": {"id": "gen-act", "type": "dummy", "uuid": "99999999-0000-0000-0000-000000000009", "interface-name": "dummy0", "autoconnect": False}, + "dummy": {}, "ipv6": {"method": "ignore"}, + "ipv4": {"method": "manual", "route-metric": dbus.Int64(100), + "address-data": dbus.Array([dbus.Dictionary({"address": "10.99.0.2", "prefix": dbus.UInt32(24)}, signature="sv")], signature="a{sv}")}} + pa, _ = settings_if.AddConnection2(prof, dbus.UInt32(1), {}) + mgr.ActivateConnection(pa, dbus.ObjectPath(dev), dbus.ObjectPath("/")) + devi = dbus.Interface(conn().get_object(NMS, dev), NMS + ".Device") + devp = dbus.Interface(conn().get_object(NMS, dev), "org.freedesktop.DBus.Properties") + state = 0 + for _ in range(100): + state = int(devp.Get(NMS + ".Device", "State")) + if state == 100: + break + time.sleep(0.2) + F["device_state"] = state + def applied_metric(): + a, v = devi.GetAppliedConnection(dbus.UInt32(0)) + return int(a["ipv4"].get("route-metric", -1)), int(v) + if state == 100: + F["applied_before"], v0 = applied_metric() + F["routes_before"] = routes() + cp = mgr.CheckpointCreate([dbus.ObjectPath(dev)], dbus.UInt32(5), dbus.UInt32(0)) + a, v = devi.GetAppliedConnection(dbus.UInt32(0)) + new = {k: {kk: vv for kk, vv in val.items()} for k, val in a.items()} + new["ipv4"]["route-metric"] = dbus.Int64(150) + devi.Reapply(new, dbus.UInt64(v), dbus.UInt32(0)) + F["applied_after_reapply"], _ = applied_metric() + F["routes_after_reapply"] = routes() + time.sleep(9) + F["checkpoints_left"] = checkpoints() + F["applied_after_expiry"], _ = applied_metric() + F["routes_after_expiry"] = routes() + F["profile_metric_after_expiry"] = metric(str(pa)) +out["F_checkpoint_reapply_active"] = F + +# Case G: a checkpoint spanning a NetworkManager restart (a crash in the middle of a change). +G = {} +if dev and F.get("device_state") == 100: + mgr = dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager"), NMS) + cp = mgr.CheckpointCreate([dbus.ObjectPath(dev)], dbus.UInt32(20), dbus.UInt32(0)) + devi = dbus.Interface(conn().get_object(NMS, dev), NMS + ".Device") + a, v = devi.GetAppliedConnection(dbus.UInt32(0)) + new = {k: {kk: vv for kk, vv in val.items()} for k, val in a.items()} + new["ipv4"]["route-metric"] = dbus.Int64(180) + devi.Reapply(new, dbus.UInt64(v), dbus.UInt32(0)) + G["routes_after_reapply"] = routes() + G["restarted"] = restart_nm() + G["routes_just_after_restart"] = routes() + time.sleep(25) + mgr2 = dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager"), NMS) + G["checkpoints_after_restart"] = checkpoints() + G["routes_after_original_timeout"] = routes() + dev2 = str(mgr2.GetDeviceByIpIface("dummy0")) + d2p = dbus.Interface(conn().get_object(NMS, dev2), "org.freedesktop.DBus.Properties") + G["device_state_after"] = int(d2p.Get(NMS + ".Device", "State")) + pf = [pp for pp in dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager/Settings"), NMS + ".Settings").ListConnections() if str(conn_if(pp).GetSettings()["connection"]["uuid"]).startswith("99999999")] + G["profile_metric_after"] = metric(str(pf[0])) if pf else None +out["G_checkpoint_across_restart"] = G + +# Case H: a writer outside NetworkManager (edits the keyfile and reloads) while a fenced write is held. +H = {} +if dev: + settings_if = dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager/Settings"), NMS + ".Settings") + ctl_bus = conn() + ctl = dbus.Interface(ctl_bus.get_object("org.freedesktop.PolicyKit1", "/org/freedesktop/PolicyKit1/Authority"), "test.nm.IsolatedControl") + ph = make_generic("a8a8a8a8-0000-0000-0000-000000000008") + H["v0"], H["m0"] = version(ph), metric(ph) + r = subprocess.run(["runuser", "-u", "nm-isolated", "--", "python3", "/opt/nm-isolated-test/update2_client.py", "--private-bus-address", ADDR, "--profile-path", ph, "--metric", "150", "--disconnect-after-held", "--timeout", "30"], capture_output=True, text=True, timeout=60) + H["client_rc"] = r.returncode + time.sleep(1) + pend = [str(x) for x in ctl.Pending()] + H["pending"] = len(pend) + import glob, re + g = subprocess.run(["grep", "-rl", "a8a8a8a8", "/etc", "/run/NetworkManager", "/var/lib/NetworkManager"], capture_output=True, text=True) + files = [f for f in g.stdout.split() if os.path.isfile(f)] + H["keyfile_paths"] = files[:3] + for f in files[:2]: + try: + H["dump_" + os.path.basename(f)[:24]] = open(f).read().replace("\n", " ; ")[:420] + except OSError as e: + H["dump_err"] = str(e) + H["keyfile_found"] = len(files) + yml = [f for f in files if f.endswith(".yaml")] + if yml: + txt = open(yml[0]).read() + H["yaml_has_metric"] = [l.strip() for l in txt.splitlines() if "route-metric" in l][:2] + new = re.sub(r'(ipv4\.route-metric:\s*)["\']?\d+["\']?', r'\g<1>"300"', txt) + H["yaml_changed"] = new != txt + open(yml[0], "w").write(new) + os.chmod(yml[0], 0o600) + g = subprocess.run(["netplan", "generate"], capture_output=True, text=True) + H["netplan_generate_rc"] = g.returncode + if g.returncode: + H["netplan_generate_err"] = g.stderr.strip()[-160:] + dbus.Interface(conn().get_object(NMS, "/org/freedesktop/NetworkManager/Settings"), NMS + ".Settings").ReloadConnections() + time.sleep(2) + ph2 = [pp for pp in settings_if.ListConnections() if str(conn_if(pp).GetSettings()["connection"]["uuid"]).startswith("a8a8a8a8")] + ph = str(ph2[0]) if ph2 else ph + H["after_external_edit_metric"], H["after_external_edit_version"] = metric(ph), version(ph) + for cid in pend: + ctl.Release(cid, True) + time.sleep(2) + H["final_metric"], H["final_version"] = metric(ph), version(ph) +out["H_external_writer"] = H +print(json.dumps(out)) +PY +sc=$(tr '\012\042\134' ' _' < $RUN/scenario.json | cut -c1-5200) +er=$(tail -c 1100 $RUN/scenario.err 2>/dev/null | tr '\012\042\134' ' _') +sl=$(tail -c 200 $RUN/stub.log 2>/dev/null | tr '\012\042\134' ' _') +printf '{"scenario":"%s","err":"%s","stub":"%s","dummy":"%s"}\n' "$sc" "$er" "$sl" "$(cat $RUN/dummy.err 2>/dev/null | tr '\012\042\134' ' _' | cut -c1-120)" > $PORT +kill $NMPID $STUBPID $BUSPID 2>/dev/null diff --git a/tests/nm-isolated/vm_scenario_owner.py b/tests/nm-isolated/vm_scenario_owner.py new file mode 100644 index 00000000..c25f7cd4 --- /dev/null +++ b/tests/nm-isolated/vm_scenario_owner.py @@ -0,0 +1,48 @@ +"""Own one disposable VM per scenario using an injected QEMU context.""" + +import time + + +class VMScenarioOwner: + """Never reuse a VM after a scenario, including after an exception. + + `vm_factory` owns VM teardown through its context manager. The caller must supply a fresh private workspace for each factory invocation. + """ + + def __init__(self, vm_factory, connect, *, attempts, timeout, clock=time.monotonic): + if attempts <= 0 or timeout <= 0: + raise ValueError("attempts and timeout must be positive") + self.vm_factory = vm_factory + self.connect = connect + self.attempts = attempts + self.timeout = timeout + self.clock = clock + self.used = set() + + def run(self, scenario): + deadline = self.clock() + self.timeout + last_error = None + for _ in range(self.attempts): + remaining = deadline - self.clock() + if remaining <= 0: + raise TimeoutError("scenario deadline expired") from last_error + entered = False + completed = False + try: + with self.vm_factory(timeout=remaining) as vm: + entered = True + if vm.identity in self.used: + raise RuntimeError("VM identity reused") + self.used.add(vm.identity) + remaining = deadline - self.clock() + if remaining <= 0: + raise TimeoutError("scenario deadline expired") + with self.connect(vm, timeout=remaining) as adapter: + result = scenario(adapter) + completed = True + return result + except Exception as error: + if completed or (entered and error.__context__ is not None): + raise RuntimeError("VM or connection teardown failed; refusing retry") from error + last_error = error + raise RuntimeError("all fresh VM attempts failed") from last_error diff --git a/tests/unit/test_connectivity_transaction.py b/tests/unit/test_connectivity_transaction.py new file mode 100644 index 00000000..7f8e0ab3 --- /dev/null +++ b/tests/unit/test_connectivity_transaction.py @@ -0,0 +1,391 @@ +"""Tests for the standalone connectivity transaction.""" + +import ctypes +import importlib.util +import sys +import multiprocessing +import os +import stat +import tempfile +import unittest +from unittest.mock import patch +from pathlib import Path + +MODULE = Path(__file__).resolve().parents[2] / "plugins/modules/connectivity_transaction.py" +spec = importlib.util.spec_from_file_location("connectivity_transaction", MODULE) +assert spec is not None and spec.loader is not None +transaction = importlib.util.module_from_spec(spec) +spec.loader.exec_module(transaction) + + +def crash_during_reload(state_dir, target): + def crash(): + os._exit(73) + + transaction.ConnectivityTransaction(Path(state_dir), crash, exclusive_writer=True).begin("crash", Path(target), b"new") + + +def begin_with_blocked_reload(state_dir, target, entered, release, finished): + def reload(): + entered.set() + try: + release.wait(15) + finally: + finished.set() + + scheduler = FakeScheduler() + manager = transaction.ConnectivityTransaction(Path(state_dir), reload, exclusive_writer=True) + manager.begin("crash", Path(target), b"new", deadline=60, scheduler=scheduler, now=lambda: 0, boot_id="boot") + + +def recover_after_crash(state_dir, results): + manager = transaction.ConnectivityTransaction(Path(state_dir), lambda: None, exclusive_writer=True) + results.put(manager.recover()) + + +def race(state_dir, operation, gate, results): + manager = transaction.ConnectivityTransaction(Path(state_dir), lambda: None, exclusive_writer=True) + gate.wait() + results.put((operation, getattr(manager, operation)("one"))) + + +class FakeScheduler: + def __init__(self, fail=None): + self.armed = None + self.fail = fail + + def arm(self, transaction_id, generation, deadline): + if self.fail == "before_arm": + raise RuntimeError("crash before arm") + self.armed = (transaction_id, generation, deadline) + if self.fail == "after_arm": + raise RuntimeError("crash after arm") + + def is_armed(self, transaction_id, generation, deadline): + if self.fail == "verify": + return False + return self.armed == (transaction_id, generation, deadline) + + +class TransactionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.target = self.root / "dropin" + self.reloads = [] + self.manager = transaction.ConnectivityTransaction(self.root, lambda: self.reloads.append(True), exclusive_writer=True) + if sys.platform == "darwin": + directory_patcher = patch.object(transaction, "_directory_has_metadata", return_value=False) + directory_patcher.start() + self.addCleanup(directory_patcher.stop) + patcher = patch.object(transaction, "_has_metadata", return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + + def test_restores_exact_bytes_and_mode(self): + self.target.write_bytes(b"\x00old\xff\n") + self.target.chmod(0o751) + self.manager.begin("one", self.target, b"new") + self.assertEqual(self.target.read_bytes(), b"new") + self.assertTrue(self.manager.rollback("one")) + self.assertEqual(self.target.read_bytes(), b"\x00old\xff\n") + self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o751) + self.assertEqual(len(self.reloads), 2) + self.assertEqual(self.manager.recover(), "rolled_back") + self.assertEqual(len(self.reloads), 2) + + def test_restores_absence_and_commit_survives_restart(self): + self.manager.begin("one", self.target, b"new") + self.assertTrue(self.manager.rollback("one")) + self.assertFalse(self.target.exists()) + self.manager.begin("two", self.target, b"new") + self.assertTrue(self.manager.commit("two")) + self.assertEqual(transaction.ConnectivityTransaction(self.root, lambda: self.fail("unexpected reload"), exclusive_writer=True).recover(), "committed") + self.assertEqual(self.target.read_bytes(), b"new") + self.assertFalse(self.manager.rollback("two")) + + def test_recovery_after_apply_reload_failure_and_rollback_reload_failure(self): + self.target.write_bytes(b"original") + def fail(): + raise RuntimeError("reload failed") + failing = transaction.ConnectivityTransaction(self.root, fail, exclusive_writer=True) + with self.assertRaisesRegex(RuntimeError, "reload failed"): + failing.begin("one", self.target, b"new") + self.assertEqual(self.target.read_bytes(), b"new") + with self.assertRaisesRegex(RuntimeError, "reload failed"): + failing.recover() + self.assertEqual(self.target.read_bytes(), b"original") + self.assertEqual(self.manager.recover(), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"original") + + def test_process_crash_releases_lock_and_recovery_restores_snapshot(self): + self.target.write_bytes(b"old") + context = multiprocessing.get_context("fork") + process = context.Process(target=crash_during_reload, args=(str(self.root), str(self.target))) + process.start() + process.join(10) + self.assertEqual(process.exitcode, 73) + self.assertEqual(self.target.read_bytes(), b"new") + self.assertEqual(self.manager.recover(), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_recovery_after_parent_dies_during_scheduled_reload(self): + self.target.write_bytes(b"old") + context = multiprocessing.get_context("fork") + entered, release, finished = context.Event(), context.Event(), context.Event() + results = context.Queue() + parent = context.Process(target=begin_with_blocked_reload, args=(str(self.root), str(self.target), entered, release, finished)) + recovery = context.Process(target=recover_after_crash, args=(str(self.root), results)) + parent.start() + try: + self.assertTrue(entered.wait(5), "reload did not start") + self.assertEqual(self.target.read_bytes(), b"new") + parent.kill() + parent.join(5) + self.assertFalse(parent.is_alive(), "transaction parent did not exit") + recovery.start() + recovery.join(3) + self.assertFalse(recovery.is_alive(), "recovery blocked on a lock inherited by the reload child") + self.assertEqual(recovery.exitcode, 0) + self.assertEqual(results.get(timeout=2), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + self.assertFalse(finished.is_set(), "original reload must still be running when recovery finishes") + finally: + release.set() + if entered.is_set(): + finished.wait(5) + for process in (parent, recovery): + if process.pid is not None and process.is_alive(): + process.kill() + process.join(5) + results.close() + results.join_thread() + + def test_commit_and_rollback_are_serialised_across_processes(self): + self.target.write_bytes(b"old") + self.manager.begin("one", self.target, b"new") + context = multiprocessing.get_context("fork") + gate = context.Event() + results = context.Queue() + processes = [context.Process(target=race, args=(str(self.root), operation, gate, results)) for operation in ("commit", "rollback")] + for process in processes: + process.start() + gate.set() + outcomes = dict(results.get(timeout=10) for _ in processes) + for process in processes: + process.join(10) + self.assertEqual(process.exitcode, 0) + self.assertNotEqual(outcomes["commit"], outcomes["rollback"]) + self.assertEqual(self.target.read_bytes(), b"new" if outcomes["commit"] else b"old") + self.assertEqual(self.manager.recover(), "committed" if outcomes["commit"] else "rolled_back") + + def test_exclusive_writer_contract_is_required(self): + with self.assertRaisesRegex(ValueError, "exclusive control"): + transaction.ConnectivityTransaction(self.root, lambda: None, exclusive_writer=False) + + def test_external_change_is_not_overwritten(self): + self.target.write_bytes(b"old") + self.manager.begin("one", self.target, b"new") + self.target.write_bytes(b"external") + with self.assertRaisesRegex(RuntimeError, "changed outside"): + self.manager.rollback("one") + self.assertEqual(self.target.read_bytes(), b"external") + with self.assertRaisesRegex(RuntimeError, "changed outside"): + self.manager.recover() + + def test_symlink_is_refused_without_following(self): + other = self.root / "other" + other.write_bytes(b"safe") + self.target.symlink_to(other) + with self.assertRaises(OSError): + self.manager.begin("one", self.target, b"new") + self.assertEqual(other.read_bytes(), b"safe") + + def test_extended_attributes_are_refused(self): + self.target.write_bytes(b"old") + if sys.platform == "darwin": + libc = ctypes.CDLL(None, use_errno=True) + result = libc.setxattr(os.fsencode(self.target), b"user.transaction_test", b"metadata", 8, 0, 0) + self.assertEqual(result, 0) + else: + os.setxattr(self.target, "user.transaction_test", b"metadata") + with patch.object(transaction, "_has_metadata", wraps=transaction._has_metadata if sys.platform != "darwin" else lambda fd: True): + with self.assertRaisesRegex(ValueError, "extended metadata"): + self.manager.begin("one", self.target, b"new") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_hard_link_is_refused(self): + self.target.write_bytes(b"old") + other = self.root / "other" + os.link(self.target, other) + with self.assertRaisesRegex(ValueError, "hard links"): + self.manager.begin("one", self.target, b"new") + self.assertEqual(other.read_bytes(), b"old") + + def test_inherited_metadata_refuses_replacement_before_rename(self): + self.target.write_bytes(b"old") + with patch.object(transaction, "_has_metadata", side_effect=[False, False, True]): + with self.assertRaisesRegex(ValueError, "replacement inherited"): + self.manager.begin("one", self.target, b"new") + self.assertEqual(self.target.read_bytes(), b"old") + self.assertEqual(self.manager.recover(), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_inherited_metadata_refuses_rollback_before_rename(self): + self.target.write_bytes(b"old") + self.manager.begin("one", self.target, b"new") + with patch.object(transaction, "_has_metadata", side_effect=[False, True]): + with self.assertRaisesRegex(ValueError, "replacement inherited"): + self.manager.rollback("one") + self.assertEqual(self.target.read_bytes(), b"new") + self.assertEqual(self.manager.recover(), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_invalid_modes_are_rejected_before_changes(self): + self.target.write_bytes(b"old") + for mode in (stat.S_IFREG | 0o644, -1, 0o10000, True, "644"): + with self.subTest(mode=mode): + with self.assertRaisesRegex(ValueError, "mode must contain"): + self.manager.begin("one", self.target, b"new", mode) + self.assertEqual(self.target.read_bytes(), b"old") + self.assertFalse(self.manager.state_path.exists()) + + def test_unreadable_owner_mode_is_rejected_before_changes(self): + self.target.write_bytes(b"old") + for mode in (0o000, 0o077): + with self.subTest(mode=mode): + with self.assertRaisesRegex(ValueError, "owner read permission"): + self.manager.begin("one", self.target, b"new", mode) + self.assertEqual(self.target.read_bytes(), b"old") + self.assertFalse(self.manager.state_path.exists()) + + def test_special_permission_bits_survive_comparison_and_rollback(self): + self.target.write_bytes(b"old") + self.manager.begin("one", self.target, b"new", 0o1750) + self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o1750) + self.assertTrue(self.manager.rollback("one")) + self.assertEqual(self.target.read_bytes(), b"old") + + def test_scheduler_boundaries_and_expiry(self): + for failure in ("before_arm", "after_arm", "verify", "reload", None): + with self.subTest(failure=failure): + root = self.root / str(failure) + root.mkdir(mode=0o700) + target = root / "dropin" + target.write_bytes(b"old") + scheduler = FakeScheduler(failure) + def reload(): + if failure == "reload": + raise RuntimeError("controller lost") + manager = transaction.ConnectivityTransaction(root, reload, exclusive_writer=True) + if failure is None: + manager.begin("one", target, b"new", deadline=20, scheduler=scheduler, now=lambda: 10, boot_id="boot") + else: + with self.assertRaises(RuntimeError): + manager.begin("one", target, b"new", deadline=20, scheduler=scheduler, now=lambda: 10, boot_id="boot") + recovery = transaction.ConnectivityTransaction(root, lambda: None, exclusive_writer=True) + generation = recovery._read()["generation"] + self.assertFalse(recovery.expire("one", generation, 19, "boot")) + if scheduler.armed is None: + self.assertEqual(target.read_bytes(), b"old") + self.assertEqual(recovery.recover(), "rolled_back") + else: + self.assertTrue(recovery.expire("one", generation, 20, "boot")) + self.assertEqual(target.read_bytes(), b"old") + + def test_expired_watchdog_cannot_rollback_committed_or_new_transaction(self): + scheduler = FakeScheduler() + self.manager.begin("one", self.target, b"new", deadline=20, scheduler=scheduler, now=lambda: 10, boot_id="boot") + generation = scheduler.armed[1] + self.assertTrue(self.manager.commit("one", now=lambda: 19, boot_id="boot")) + self.assertFalse(self.manager.expire("one", generation, 20, "boot")) + self.manager.begin("one", self.target, b"newer", deadline=30, scheduler=scheduler, now=lambda: 10, boot_id="boot") + self.assertNotEqual(generation, scheduler.armed[1]) + self.assertFalse(self.manager.expire("one", generation, 30, "boot")) + self.assertEqual(self.target.read_bytes(), b"newer") + self.assertTrue(self.manager.expire("one", scheduler.armed[1], 30, "boot")) + self.assertEqual(self.target.read_bytes(), b"new") + + def test_commit_at_deadline_cannot_beat_watchdog(self): + scheduler = FakeScheduler() + self.target.write_bytes(b"old") + self.manager.begin("one", self.target, b"new", deadline=20, scheduler=scheduler, now=lambda: 10, boot_id="boot") + with self.assertRaisesRegex(ValueError, "clock"): + self.manager.commit("one") + self.assertFalse(self.manager.commit("one", now=lambda: 20, boot_id="boot")) + self.assertTrue(self.manager.expire("one", scheduler.armed[1], 20, "boot")) + self.assertEqual(self.target.read_bytes(), b"old") + + def test_hung_scheduled_reload_releases_lock_for_expiry(self): + self.target.write_bytes(b"old") + scheduler = FakeScheduler() + gate = multiprocessing.get_context("fork").Event() + manager = transaction.ConnectivityTransaction(self.root, gate.wait, exclusive_writer=True) + with self.assertRaises(TimeoutError): + manager.begin("one", self.target, b"new", deadline=0.1, scheduler=scheduler, now=lambda: 0, boot_id="boot") + self.assertTrue(self.manager.expire("one", scheduler.armed[1], 0.1, "boot")) + self.assertEqual(self.target.read_bytes(), b"old") + + def test_state_directory_permissions_and_symlink_are_rejected(self): + self.root.chmod(0o755) + with self.assertRaisesRegex(PermissionError, "0700"): + self.manager.begin("one", self.target, b"new") + self.assertFalse(self.manager.state_path.exists()) + self.root.chmod(0o700) + link = self.root / "link" + link.symlink_to(self.root, target_is_directory=True) + manager = transaction.ConnectivityTransaction(link, lambda: None, exclusive_writer=True) + with self.assertRaises(OSError): + manager.begin("one", self.target, b"new") + self.assertFalse(self.manager.state_path.exists()) + + def test_state_directory_owner_and_metadata_are_checked(self): + with patch.object(transaction.os, "geteuid", return_value=os.geteuid() + 1): + with self.assertRaisesRegex(PermissionError, "owned"): + self.manager.begin("one", self.target, b"new") + with patch.object(transaction, "_directory_has_metadata", return_value=True): + with self.assertRaisesRegex(PermissionError, "metadata"): + self.manager.begin("one", self.target, b"new") + self.assertFalse(self.manager.state_path.exists()) + + def test_separate_module_refuses_before_any_writer_action(self): + import ast + module_path = MODULE.parent / "networkmanager_connectivity.py" + tree = ast.parse(module_path.read_text()) + function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "run") + self.assertIsInstance(function.body[0], ast.Expr) + call = function.body[0].value + self.assertIsInstance(call, ast.Call) + self.assertIsInstance(call.func, ast.Attribute) + self.assertEqual(call.func.attr, "fail_json") + + def test_nonparticipating_writer_can_replace_file_despite_lock(self): + self.target.write_bytes(b"old") + replacement = self.root / "replacement" + replacement.write_bytes(b"external") + with self.manager._locked(): + os.replace(replacement, self.target) + self.assertEqual(self.target.read_bytes(), b"external") + + def test_lock_symlink_is_rejected(self): + other = self.root / "other" + other.write_text("safe") + self.manager.lock_path.symlink_to(other) + with self.assertRaises(OSError): + self.manager.begin("one", self.target, b"new") + self.assertEqual(other.read_text(), "safe") + + def test_active_transaction_and_wrong_id_are_rejected(self): + self.manager.begin("one", self.target, b"new") + with self.assertRaises(RuntimeError): + self.manager.begin("two", self.target, b"other") + with self.assertRaises(ValueError): + self.manager.commit("two") + with self.assertRaises(ValueError): + self.manager.rollback("two") + self.assertEqual(self.target.read_bytes(), b"new") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_connectivity_watchdog.py b/tests/unit/test_connectivity_watchdog.py new file mode 100644 index 00000000..104cd036 --- /dev/null +++ b/tests/unit/test_connectivity_watchdog.py @@ -0,0 +1,250 @@ +"""Tests for the independently running host watchdog.""" + +import multiprocessing +import socket +import threading +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +MODULES = Path(__file__).resolve().parents[2] / "plugins/modules" +sys.path.insert(0, str(MODULES)) +import connectivity_transaction as transaction +import connectivity_watchdog as watchdog + + +def acknowledge_then_wait(state_dir, ready): + root = Path(state_dir) + manager = transaction.ConnectivityTransaction(root, lambda: None, exclusive_writer=True) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: + listener.bind(str(root / "watchdog.sock")) + listener.listen() + listener.settimeout(3) + ready.set() + watchdog.serve_once(listener, manager, "boot", time.monotonic) + time.sleep(30) + + +def run_watchdog(state_dir): + manager = transaction.ConnectivityTransaction(Path(state_dir), lambda: None, exclusive_writer=True) + while watchdog.scan(manager, time.monotonic(), "boot") not in ("rolled_back", "committed"): + time.sleep(0.01) + + +class WatchdogTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.target = self.root / "dropin" + self.target.write_bytes(b"old") + self.manager = transaction.ConnectivityTransaction(self.root, lambda: None, exclusive_writer=True) + if sys.platform == "darwin": + directory_patcher = patch.object(transaction, "_directory_has_metadata", return_value=False) + directory_patcher.start() + self.addCleanup(directory_patcher.stop) + patcher = patch.object(transaction, "_has_metadata", return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + + def prepare(self, deadline): + class Scheduler: + def arm(self, *args): + pass + + def is_armed(self, *args): + return True + + self.manager.begin("one", self.target, b"new", deadline=deadline, scheduler=Scheduler(), now=time.monotonic, boot_id="boot") + + def test_independent_worker_restores_after_expiry(self): + self.prepare(time.monotonic() + 0.3) + process = multiprocessing.get_context("fork").Process(target=run_watchdog, args=(str(self.root),)) + process.start() + process.join(5) + if process.is_alive(): + process.kill() + process.join() + self.fail("watchdog did not expire transaction") + self.assertEqual(process.exitcode, 0) + self.assertEqual(self.target.read_bytes(), b"old") + self.assertEqual(self.manager._read()["phase"], "rolled_back") + + def test_restart_finishes_interrupted_rollback(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + state["phase"] = "rolling_back" + self.manager._save(state) + self.assertEqual(watchdog.scan(self.manager, time.monotonic(), "boot"), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_restart_expires_prepared_state_and_preserves_committed(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + state["phase"] = "prepared" + self.manager._save(state) + self.assertEqual(watchdog.scan(self.manager, state["deadline"], "boot"), "rolled_back") + self.manager.begin("two", self.target, b"committed") + self.assertTrue(self.manager.commit("two")) + self.assertEqual(watchdog.scan(self.manager, time.monotonic(), "boot"), "committed") + self.assertEqual(self.target.read_bytes(), b"committed") + + def test_reboot_expires_and_blocks_commit_even_before_old_deadline(self): + self.prepare(time.monotonic() + 60) + self.assertFalse(self.manager.commit("one", now=time.monotonic, boot_id="new-boot")) + self.assertEqual(watchdog.scan(self.manager, 0, "new-boot"), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + + def test_same_boot_does_not_expire_before_monotonic_deadline(self): + self.prepare(time.monotonic() + 60) + with patch.object(watchdog.time, "time", return_value=10**12): + self.assertEqual(watchdog.scan(self.manager, time.monotonic(), "boot"), "pending") + self.assertEqual(self.target.read_bytes(), b"new") + + def test_worker_challenge_checks_generation_and_liveness(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + state["phase"] = "prepared" + self.manager._save(state) + address = str(self.root / "watchdog.sock") + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(address) + listener.listen() + listener.settimeout(2) + self.addCleanup(listener.close) + + def probe(generation): + worker = threading.Thread(target=watchdog.serve_once, args=(listener, self.manager, "boot", time.monotonic)) + worker.start() + result = watchdog.probe_worker(self.root, "one", generation, state["deadline"]) + worker.join(3) + self.assertFalse(worker.is_alive()) + return result + + self.assertTrue(probe(state["generation"])) + self.assertFalse(probe("stale")) + listener.close() + self.assertFalse(watchdog.probe_worker(self.root, "one", state["generation"], state["deadline"])) + + def test_worker_death_after_acknowledgement_leaves_state_for_restart(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + state["phase"] = "prepared" + self.manager._save(state) + context = multiprocessing.get_context("fork") + ready = context.Event() + worker = context.Process(target=acknowledge_then_wait, args=(str(self.root), ready)) + worker.start() + try: + self.assertTrue(ready.wait(3)) + self.assertTrue(watchdog.probe_worker(self.root, "one", state["generation"], state["deadline"])) + worker.kill() + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertFalse(watchdog.probe_worker(self.root, "one", state["generation"], state["deadline"])) + self.assertEqual(self.target.read_bytes(), b"new") + self.assertEqual(watchdog.scan(self.manager, state["deadline"], "boot"), "rolled_back") + self.assertEqual(self.target.read_bytes(), b"old") + finally: + if worker.is_alive(): + worker.kill() + worker.join(3) + + def test_worker_cannot_acknowledge_hung_scan(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + state["phase"] = "prepared" + self.manager._save(state) + request = {"id": "one", "generation": state["generation"], "deadline": state["deadline"], "challenge": "fresh"} + with self.manager._locked(): + result = watchdog.answer_probe(self.manager, request, time.monotonic(), "boot") + self.assertEqual(result, {"challenge": "fresh", "ready": False}) + + def test_active_socket_without_worker_is_not_ready(self): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(self.root / "watchdog.sock")) + listener.listen() + self.addCleanup(listener.close) + with patch.object(watchdog, "HANDSHAKE_TIMEOUT_SECONDS", 0.05): + self.assertFalse(watchdog.probe_worker(self.root, "one", "generation", 10)) + + def test_scheduler_handshake_does_not_hold_transaction_lock(self): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(self.root / "watchdog.sock")) + listener.listen() + listener.settimeout(2) + self.addCleanup(listener.close) + def serve(): + for _ in range(2): + watchdog.serve_once(listener, self.manager, "boot", time.monotonic) + class ProbeScheduler(watchdog.ServiceScheduler): + def arm(self, transaction_id, generation, deadline): + if not self.is_armed(transaction_id, generation, deadline): + raise RuntimeError("worker not ready") + worker = threading.Thread(target=serve) + worker.start() + try: + self.manager.begin("one", self.target, b"new", deadline=time.monotonic() + 10, scheduler=ProbeScheduler(self.root), now=time.monotonic, boot_id="boot") + finally: + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(self.target.read_bytes(), b"new") + + def test_expiry_during_arm_prevents_application(self): + manager = self.manager + class ExpiringScheduler: + def arm(self, transaction_id, generation, deadline): + self.assert_expired = manager.expire(transaction_id, generation, deadline, "boot") + def is_armed(self, transaction_id, generation, deadline): + return True + scheduler = ExpiringScheduler() + with self.assertRaisesRegex(RuntimeError, "changed while arming"): + manager.begin("one", self.target, b"new", deadline=time.monotonic() + 10, scheduler=scheduler, now=time.monotonic, boot_id="boot") + self.assertTrue(scheduler.assert_expired) + self.assertEqual(self.target.read_bytes(), b"old") + + def test_replaced_generation_during_arm_prevents_application(self): + manager = self.manager + class ReplacingScheduler: + def arm(self, transaction_id, generation, deadline): + manager.expire(transaction_id, generation, deadline, "boot") + manager.begin("two", self_target, b"replacement") + def is_armed(self, transaction_id, generation, deadline): + return True + self_target = self.target + with self.assertRaisesRegex(RuntimeError, "changed while arming"): + manager.begin("one", self.target, b"new", deadline=time.monotonic() + 10, scheduler=ReplacingScheduler(), now=time.monotonic, boot_id="boot") + self.assertEqual(self.target.read_bytes(), b"replacement") + + def test_real_scheduler_refuses_even_after_worker_acknowledges(self): + self.prepare(time.monotonic() + 60) + state = self.manager._read() + with patch.object(watchdog, "probe_worker", return_value=True): + scheduler = watchdog.ServiceScheduler(self.root) + self.assertTrue(scheduler.is_armed("one", state["generation"], state["deadline"])) + with self.assertRaisesRegex(RuntimeError, "arm disabled"): + scheduler.arm("one", state["generation"], state["deadline"]) + self.assertEqual(self.target.read_bytes(), b"new") + + def test_scheduler_refuses_unverified_readiness(self): + scheduler = watchdog.ServiceScheduler(self.root) + self.assertFalse(scheduler.is_armed("one", "generation", 12)) + with self.assertRaisesRegex(RuntimeError, "arm disabled"): + scheduler.arm("one", "generation", 12) + + def test_rollback_reload_has_a_timeout_and_remains_recoverable(self): + self.prepare(time.monotonic() + 60) + manager = transaction.ConnectivityTransaction(self.root, watchdog.reload_networkmanager, exclusive_writer=True) + with patch.object(watchdog.subprocess, "run", side_effect=watchdog.subprocess.TimeoutExpired("nmcli", watchdog.RELOAD_TIMEOUT_SECONDS)) as run: + with self.assertRaises(watchdog.subprocess.TimeoutExpired): + watchdog.scan(manager, time.monotonic() + 120, "boot") + self.assertEqual(run.call_args.kwargs["timeout"], watchdog.RELOAD_TIMEOUT_SECONDS) + self.assertEqual(self.manager._read()["phase"], "rolling_back") + self.assertEqual(watchdog.scan(self.manager, time.monotonic(), "boot"), "rolled_back") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_isolated_polkit.py b/tests/unit/test_isolated_polkit.py new file mode 100644 index 00000000..294be52f --- /dev/null +++ b/tests/unit/test_isolated_polkit.py @@ -0,0 +1,71 @@ +"""Unit tests for the private-bus authorisation fixture, without D-Bus.""" + +import importlib.util +import unittest +from pathlib import Path + +path = Path(__file__).resolve().parents[1] / "nm-isolated/polkit_stub.py" +spec = importlib.util.spec_from_file_location("isolated_polkit", path) +assert spec is not None and spec.loader is not None +stub = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stub) + + +class FakeDaemon: + def __init__(self): + self.metric = 600 + self.version = 2 + + def submit(self, authority, request_id, version, metric): + def authorised(result): + if result[0] and version == self.version: + self.metric = metric + self.version += 1 + authority.hold(("unix-process", {"uid": 1000}), request_id, authorised, lambda error: None) + + +class PolicyFixtureTests(unittest.TestCase): + def test_no_fence_allows_delayed_request(self): + daemon = FakeDaemon() + authority = stub.HeldAuthorisation() + daemon.submit(authority, "pending", daemon.version, 20101) + authority.release("pending", True) + self.assertEqual(daemon.metric, 20101) + + def test_identical_fence_rejects_delayed_request(self): + daemon = FakeDaemon() + authority = stub.HeldAuthorisation() + daemon.submit(authority, "pending", daemon.version, 20101) + authority.allow_later = True + daemon.submit(authority, "fence", daemon.version, 600) + self.assertEqual((daemon.metric, daemon.version), (600, 3)) + self.assertEqual(list(authority.pending), ["pending"]) + authority.release("pending", True) + self.assertEqual((daemon.metric, daemon.version), (600, 3)) + + def test_allow_later_does_not_release_existing_and_still_refuses_root(self): + authority = stub.HeldAuthorisation() + replies, errors = [], [] + authority.hold(("unix-process", {"uid": 1000}), "old", replies.append, errors.append) + authority.allow_later = True + authority.hold(("unix-process", {"uid": 1000}), "new", replies.append, errors.append) + authority.hold(("unix-process", {"uid": 0}), "root", replies.append, errors.append) + self.assertEqual(replies, [(True, False, {})]) + self.assertEqual(list(authority.pending), ["old"]) + self.assertEqual(len(errors), 1) + authority.release("old", True) + self.assertEqual(len(replies), 2) + + def test_refuses_root_and_cancels_pending_request(self): + authority = stub.HeldAuthorisation() + errors = [] + authority.hold(("unix-process", {"uid": 0}), "root", lambda result: self.fail(), errors.append) + self.assertEqual(len(errors), 1) + authority.hold(("unix-process", {"uid": 1000}), "pending", lambda result: self.fail(), errors.append) + authority.cancel("pending") + self.assertEqual(errors[-1], "cancelled") + self.assertEqual(authority.pending, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_linux_host.py b/tests/unit/test_linux_host.py index c69509d4..fc11e9fd 100644 --- a/tests/unit/test_linux_host.py +++ b/tests/unit/test_linux_host.py @@ -2,6 +2,7 @@ from __future__ import annotations +import concurrent.futures import json import os import re @@ -18,6 +19,19 @@ ROOT = Path(__file__).resolve().parents[2] ROLE = ROOT / "roles" / "github_runner_linux_host" RUN = ROOT / "tests" / "linux_host" / "run.yml" +# Calls the module by its short name, which resolves to the test copy on the library path. It is written to the test's temporary directory because a lint of the repository cannot resolve a name that only exists while the test runs. +MODULE_PLAYBOOK = """--- +- name: Run the connectivity module + hosts: all + connection: local + gather_facts: false + tasks: + - name: Apply or remove the connectivity drop-in + networkmanager_connectivity: + dropin: "{{ github_runner_linux_host_networkmanager_connectivity_dropin }}" + disabled: "{{ github_runner_linux_host_networkmanager_connectivity_check_off }}" + become: "{{ ansible_become | default(false) }}" +""" ANSIBLE_PLAYBOOK = os.environ.get("ANSIBLE_PLAYBOOK", "ansible-playbook") DEFAULTS: dict[str, Any] = yaml.safe_load((ROLE / "defaults" / "main.yml").read_text()) SPECS: dict[str, Any] = yaml.safe_load((ROLE / "meta" / "argument_specs.yml").read_text())["argument_specs"]["main"]["options"] @@ -44,8 +58,9 @@ def test_every_step_is_off_by_default_and_has_a_variable_of_its_own(self) -> Non with self.subTest(step=step): self.assertIs(DEFAULTS[step], False) tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) - gated = [next(condition for condition in task["when"] if "| bool" in condition).split(" ")[0] for task in tasks if "when" in task and isinstance(task["when"], list)] + gated = [next(condition for condition in task["when"] if "| bool" in condition).split(" ")[0] for task in tasks if "when" in task and isinstance(task["when"], list) and any("| bool" in condition for condition in task["when"])] self.assertEqual(gated, STEPS + [TAILSCALE]) + self.assertIsNone(DEFAULTS["github_runner_linux_host_networkmanager_connectivity_check_off"]) def test_the_tailscale_update_is_the_one_step_that_is_on_by_default(self) -> None: self.assertIs(DEFAULTS[TAILSCALE], True) @@ -125,6 +140,7 @@ def setUp(self) -> None: self.host = self.tmp / "host" self.logind = self.host / "etc" / "systemd" / "logind.conf.d" / "90-github-runner-keep-awake.conf" self.networkmanager = self.host / "etc" / "NetworkManager" / "conf.d" / "zz-github-runner-wifi-powersave.conf" + self.connectivity = self.host / "etc" / "NetworkManager" / "conf.d" / "zz-github-runner-connectivity.conf" self.modules_file = self.host / "etc" / "modules-load.d" / "github-runner-k3s.conf" self.sysctl_file = self.host / "etc" / "sysctl.d" / "90-github-runner-k3s.conf" self.sys_module = self.host / "sys" / "module" @@ -203,7 +219,13 @@ def make_stand_ins(self) -> None: """) stand_in(bin_dir / "nmcli", log + r"""case "$*" in *"device status"*) [ -e "$STUB/state/no-networkmanager" ] && { echo "nmcli: not running" >&2; exit 8; }; cat "$STUB/state/devices" ;; + "general reload conf") + [ -e "$STUB/state/reload-interrupt" ] && { kill -KILL "$PPID"; exit 1; } + [ -e "$STUB/state/reload-fails" ] && exit 1 + if [ -e "$CONNECTIVITY_DROPIN" ] && [ ! -e "$STUB/state/connectivity-override" ]; then touch "$STUB/state/connectivity-disabled"; else rm -f "$STUB/state/connectivity-disabled"; fi ;; esac +""") + stand_in(bin_dir / "busctl", log + r"""if [ -e "$STUB/state/connectivity-disabled" ]; then echo 'b false'; else echo 'b true'; fi """) stand_in(bin_dir / "iw", log + r"""if [ -e "$STUB/state/no-iw" ]; then echo "iw: command not found" >&2; exit 127; fi device="$2" @@ -282,13 +304,14 @@ def make_stand_ins(self) -> None: """) stand_in(bin_dir / "shutdown", log) - def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_family: str = "Debian", **variables: Any) -> subprocess.CompletedProcess[str]: - inventory = self.tmp / "inventory.yml" + def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_family: str = "Debian", inventory_name: str = "inventory.yml", playbook: Path = RUN, **variables: Any) -> subprocess.CompletedProcess[str]: + inventory = self.tmp / inventory_name host = { "ansible_connection": "local", "ansible_become": False, "github_runner_linux_host_logind_dropin": str(self.logind), "github_runner_linux_host_networkmanager_dropin": str(self.networkmanager), + "github_runner_linux_host_networkmanager_connectivity_dropin": str(self.connectivity), "github_runner_linux_host_sysfs_net_dir": str(self.sysfs_net), "github_runner_linux_host_wifi_udev_rule": str(self.wifi_rule), "github_runner_linux_host_wifi_powersave_unit": str(self.wifi_unit), @@ -315,20 +338,37 @@ def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_fa **os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "STUB": str(self.stubs), + "CONNECTIVITY_DROPIN": str(self.connectivity), "SYS_MODULE": str(self.sys_module), "REBOOT_REQUIRED": str(self.reboot_required), "APPARMOR_PROFILES": str(self.apparmor_profiles), "ANSIBLE_COLLECTIONS_PATH": str(ROOT / "playbooks" / "collections"), + "ANSIBLE_LIBRARY": str(self.tmp / "library"), "ANSIBLE_NOCOLOR": "1", "ANSIBLE_LOCALHOST_WARNING": "false", "ANSIBLE_INVENTORY_UNPARSED_WARNING": "false", "ANSIBLE_RETRY_FILES_ENABLED": "false", } - command = [ANSIBLE_PLAYBOOK, "-i", str(inventory), str(RUN), "-e", json.dumps({"host_system": system, "host_os_family": os_family}), *args] + command = [ANSIBLE_PLAYBOOK, "-i", str(inventory), str(playbook), "-e", json.dumps({"host_system": system, "host_os_family": os_family}), *args] if check: command.append("--check") return subprocess.run(command, env=env, capture_output=True, text=True, timeout=300, check=False, cwd=ROOT / "ansible") + def run_module(self, *args: str, **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Run the connectivity module's logic on its own. + + Production refuses every change: the role fails, and the module's first statement fails. This exercises the logic below that refusal through a copy of the module in which only that one statement is removed, so a change to the logic is still tested and the refusal itself is tested separately. + """ + library = self.tmp / "library" + library.mkdir(exist_ok=True) + source = (ROOT / "playbooks" / "collections" / "ansible_collections" / "exadev" / "github_runner" / "plugins" / "modules" / "networkmanager_connectivity.py").read_text() + guard = ' module.fail_json(msg="NetworkManager connectivity changes are not ready: reload isolation and the exclusive writer boundary are not established")\n' + self.assertEqual(source.count(guard), 1, "the module's refusal moved: update this test helper") + (library / "networkmanager_connectivity.py").write_text(source.replace(guard, "")) + playbook = self.tmp / "connectivity_module.yml" + playbook.write_text(MODULE_PLAYBOOK) + return self.run_role(*args, playbook=playbook, **kwargs) + def calls(self) -> list[str]: log = self.stubs / "calls.log" return log.read_text().splitlines() if log.exists() else [] @@ -347,6 +387,167 @@ def test_with_both_steps_off_the_role_reads_nothing_and_changes_nothing(self) -> self.assertEqual(self.calls(), []) self.assertFalse(self.logind.exists() or self.networkmanager.exists()) + def test_connectivity_check_is_opt_in_and_reloads_only_on_change(self) -> None: + self.assertEqual(self.run_role().returncode, 0) + self.assertFalse(self.connectivity.exists()) + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("[connectivity]\nenabled=false\n", self.connectivity.read_text()) + self.assertEqual(stat.S_IMODE(self.connectivity.stat().st_mode), 0o644) + self.assertEqual(self.changes(), ["nmcli general reload conf"]) + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.changed(result), 0, result.stdout) + self.assertEqual(self.changes(), ["nmcli general reload conf"]) + + def test_the_module_refuses_every_change_and_touches_nothing(self) -> None: + for value in (True, False): + with self.subTest(check_off=value): + inventory = self.tmp / "refusal.yml" + inventory.write_text(yaml.safe_dump({"all": {"hosts": {"host": {"ansible_connection": "local", "github_runner_linux_host_networkmanager_connectivity_dropin": str(self.connectivity), "github_runner_linux_host_networkmanager_connectivity_check_off": value}}}})) + env = {**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "STUB": str(self.stubs), "ANSIBLE_COLLECTIONS_PATH": str(ROOT / "playbooks" / "collections"), "ANSIBLE_NOCOLOR": "1", "ANSIBLE_LOCALHOST_WARNING": "false", "ANSIBLE_INVENTORY_UNPARSED_WARNING": "false"} + result = subprocess.run([ANSIBLE_PLAYBOOK, "-i", str(inventory), str(ROOT / "tests" / "linux_host" / "connectivity_module_real.yml")], env=env, capture_output=True, text=True, timeout=300, check=False, cwd=ROOT / "ansible") + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("connectivity changes are not ready", result.stdout + result.stderr) + self.assertFalse(self.connectivity.exists()) + self.assertEqual(self.changes(), []) + + def test_the_role_refuses_a_connectivity_change_until_reloads_are_serialised(self) -> None: + for value in (True, False): + with self.subTest(check_off=value): + result = self.run_role(github_runner_linux_host_networkmanager_connectivity_check_off=value) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("connectivity changes are not ready", result.stdout + result.stderr) + self.assertFalse(self.connectivity.exists()) + self.assertEqual(self.changes(), []) + + def test_connectivity_defaults_do_not_escalate_privileges(self) -> None: + result = self.run_role(ansible_become=None) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse([call for call in self.calls() if call.startswith("sudo ")]) + self.assertEqual(self.changes(), []) + self.assertFalse(self.connectivity.exists()) + + def test_connectivity_check_mode_reports_without_writing_or_reloading(self) -> None: + result = self.run_module(check=True, ansible_become=None, github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertGreater(self.changed(result), 0, result.stdout) + self.assertFalse(self.connectivity.exists()) + self.assertEqual(self.changes(), []) + self.assertFalse([call for call in self.calls() if call.startswith("sudo ")]) + + def test_connectivity_opt_out_removes_only_its_drop_in_and_reloads_once(self) -> None: + self.assertEqual(self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True).returncode, 0) + self.networkmanager.write_text("other configuration\n") + result = self.run_module(check=True, github_runner_linux_host_networkmanager_connectivity_check_off=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertGreater(self.changed(result), 0, result.stdout) + self.assertTrue(self.connectivity.exists()) + self.assertEqual(self.changes(), ["nmcli general reload conf"]) + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(self.connectivity.exists()) + self.assertEqual(self.networkmanager.read_text(), "other configuration\n") + self.assertEqual(self.changes(), ["nmcli general reload conf", "nmcli general reload conf"]) + result = self.run_role() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.changed(result), 0, result.stdout) + self.assertEqual(len(self.changes()), 2) + + def test_connectivity_concurrent_opt_in_and_opt_out_converge(self) -> None: + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + enabled = pool.submit(self.run_module, inventory_name="enabled.yml", github_runner_linux_host_networkmanager_connectivity_check_off=True) + disabled = pool.submit(self.run_module, inventory_name="disabled.yml", github_runner_linux_host_networkmanager_connectivity_check_off=False) + results = [enabled.result(), disabled.result()] + for result in results: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.connectivity.exists(), (self.state / "connectivity-disabled").exists()) + self.assertFalse(Path(str(self.connectivity) + ".reload-pending").exists()) + + def test_connectivity_recovers_after_interrupted_reload(self) -> None: + (self.state / "reload-interrupt").touch() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertNotEqual(result.returncode, 0) + marker = Path(str(self.connectivity) + ".reload-pending") + self.assertTrue(marker.exists()) + self.assertTrue(self.connectivity.exists()) + before = self.changes() + result = self.run_module(check=True, github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertGreater(self.changed(result), 0) + self.assertTrue(marker.exists()) + self.assertEqual(self.changes(), before) + (self.state / "reload-interrupt").unlink() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(marker.exists()) + self.assertTrue((self.state / "connectivity-disabled").exists()) + + def test_connectivity_retries_a_failed_reload(self) -> None: + (self.state / "reload-fails").touch() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(self.connectivity.exists()) + (self.state / "reload-fails").unlink() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((self.state / "connectivity-disabled").exists()) + self.assertEqual(self.changes(), ["nmcli general reload conf", "nmcli general reload conf"]) + + def test_connectivity_check_mode_reports_stale_runtime_without_reloading(self) -> None: + self.assertEqual(self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True).returncode, 0) + (self.state / "connectivity-disabled").unlink() + before = self.changes() + result = self.run_module(check=True, github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertGreater(self.changed(result), 0, result.stdout) + self.assertEqual(self.changes(), before) + self.assertFalse((self.state / "connectivity-disabled").exists()) + + def test_connectivity_opt_out_retries_failed_reload_and_check_mode_preserves_marker(self) -> None: + self.assertEqual(self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True).returncode, 0) + (self.state / "reload-fails").touch() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=False) + self.assertNotEqual(result.returncode, 0) + marker = Path(str(self.connectivity) + ".reload-pending") + self.assertTrue(marker.exists()) + self.assertFalse(self.connectivity.exists()) + before = self.changes() + result = self.run_module(check=True, github_runner_linux_host_networkmanager_connectivity_check_off=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertGreater(self.changed(result), 0, result.stdout) + self.assertTrue(marker.exists()) + self.assertEqual(self.changes(), before) + (self.state / "reload-fails").unlink() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(marker.exists()) + self.assertEqual(len(self.changes()), len(before) + 1) + self.assertEqual(self.changed(self.run_role()), 0) + + def test_connectivity_recovers_a_marker_left_before_the_file_change(self) -> None: + self.connectivity.parent.mkdir(parents=True) + marker = Path(str(self.connectivity) + ".reload-pending") + marker.write_text("pending\n") + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(self.connectivity.exists()) + self.assertFalse(marker.exists()) + self.assertTrue((self.state / "connectivity-disabled").exists()) + + def test_connectivity_refuses_an_effective_override(self) -> None: + (self.state / "connectivity-override").touch() + result = self.run_module(github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Verify NetworkManager connectivity checking is disabled", result.stdout) + self.assertFalse((self.state / "connectivity-disabled").exists()) + + def test_connectivity_check_is_skipped_on_non_linux(self) -> None: + result = self.run_role(system="Darwin", github_runner_linux_host_networkmanager_connectivity_check_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(self.connectivity.exists()) + self.assertEqual(self.changes(), []) + def test_keep_awake_masks_every_sleep_target_and_has_logind_ignore_the_lid(self) -> None: result = self.run_role(github_runner_linux_host_keep_awake=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/tests/unit/test_metric_checkpoint_model.py b/tests/unit/test_metric_checkpoint_model.py new file mode 100644 index 00000000..547c796b --- /dev/null +++ b/tests/unit/test_metric_checkpoint_model.py @@ -0,0 +1,447 @@ +"""Executable checkpoint model, not evidence of real NetworkManager behaviour. + +Assumptions: a checkpoint atomically snapshots all modelled persistent profiles; one checkpoint at a time owns them; expiry restores the snapshot; a committed checkpoint leaves changes persistent. The injected clock is monotonic. NetworkManager daemon restarts, actual D-Bus ordering and profile scope are not modelled. +""" + +import importlib.util +import json +import threading +import unittest +from pathlib import Path +from dataclasses import dataclass + + +PLANNER_PATH = Path(__file__).resolve().parents[2] / "plugins/modules/networkmanager_metric_plan.py" +spec = importlib.util.spec_from_file_location("networkmanager_metric_plan", PLANNER_PATH) +assert spec is not None and spec.loader is not None +planner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(planner) +transaction_spec = importlib.util.spec_from_file_location("networkmanager_metric_transaction", PLANNER_PATH.parent / "networkmanager_metric_transaction.py") +assert transaction_spec is not None and transaction_spec.loader is not None +metric_transaction = importlib.util.module_from_spec(transaction_spec) +transaction_spec.loader.exec_module(metric_transaction) + + +class VersionFenceModel: + """Offline assumptions: daemon checks version after authorisation; every accepted update bumps it, including identical values; owner identifies one daemon lifetime. Unversioned writers are not fenced.""" + + def __init__(self): + self.owner = ":1.1" + self.version = 2 + self.metric = 600 + + def snapshot(self): + return self.owner, self.version + + def update(self, owner, version, metric): + if owner != self.owner or (version != 0 and version != self.version): + return False + self.metric = metric + self.version += 1 + return True + + def restart(self): + self.owner = ":1.2" + self.version = 2 + + +@dataclass(frozen=True) +class Profile: + metric: int + default_route: bool = True + + +class CheckpointModel: + def __init__(self, profiles: dict[str, Profile]) -> None: + self.profiles = profiles.copy() + self._lock = threading.Lock() + self._snapshot: dict[str, Profile] | None = None + self._token: object | None = None + self._deadline = 0.0 + + def _expire(self, now: float) -> None: + if self._token is not None and now >= self._deadline: + assert self._snapshot is not None + self.profiles = self._snapshot + self._snapshot = None + self._token = None + + def begin(self, now: float, deadline: float) -> object: + with self._lock: + self._expire(now) + if self._token is not None: + raise RuntimeError("checkpoint already active") + if deadline <= now: + raise ValueError("deadline must be in the future") + self._snapshot = self.profiles.copy() + self._token = object() + self._deadline = deadline + return self._token + + def set_metric(self, token: object, name: str, metric: int, now: float) -> None: + with self._lock: + self._expire(now) + if token is not self._token or self._token is None: + raise RuntimeError("checkpoint is not active") + previous = self.profiles[name] + self.profiles[name] = Profile(metric, previous.default_route) + + def commit(self, token: object, now: float) -> bool: + with self._lock: + self._expire(now) + if token is not self._token or self._token is None: + return False + self._token = None + self._snapshot = None + return True + + def expire(self, now: float) -> None: + with self._lock: + self._expire(now) + + +class MetricCheckpointModelTests(unittest.TestCase): + def setUp(self) -> None: + self.original = {"ethernet": Profile(100), "wifi": Profile(600)} + self.model = CheckpointModel(self.original) + + def profiles(self): + common = {"active": True, "default_route": True, "explicit_route_metric": False, "address_family": "ipv4", "table": 254, "gateway": "192.0.2.1"} + return [{**common, "kind": "ethernet", "uuid": "ethernet"}, {**common, "kind": "wifi", "uuid": "wifi"}] + + def rules(self): + def rule(priority, action, table, mark=0, mask=0, invert=False): + return {"priority": priority, "mark": mark, "mask": mask, "invert": invert, "action": action, "table": table} + return [rule(0, "lookup", 255), rule(5210, "lookup", 254, 0x80000, 0xff0000), rule(5230, "lookup", 253, 0x80000, 0xff0000), rule(5250, "unreachable", 0, 0x80000, 0xff0000), rule(5270, "lookup", 52), rule(32766, "lookup", 254)] + + def candidate(self, profiles): + return planner.plan(profiles, self.rules(), 0, {255: "miss", 52: "miss", 254: "main-default", 253: "miss"}) + + def test_planned_metrics_rollback_with_checkpoint_model(self) -> None: + candidates = self.candidate(self.profiles()) + self.assertEqual(candidates, [{"uuid": "ethernet", "metric": 100}, {"uuid": "wifi", "metric": 20101}]) + token = self.model.begin(0, 10) + for candidate in candidates: + self.model.set_metric(token, candidate["uuid"], candidate["metric"], 1) + self.assertTrue(self.model.profiles["wifi"].default_route) + self.model.expire(10) + self.assertEqual(self.model.profiles, self.original) + + def test_planner_rejects_unestablished_assumptions(self) -> None: + for field, value in (("active", False), ("default_route", False), ("explicit_route_metric", True), ("address_family", "ipv6"), ("table", 100), ("gateway", ""), ("uuid", "")): + with self.subTest(field=field): + profiles = self.profiles() + profiles[1][field] = value + with self.assertRaises(ValueError): + self.candidate(profiles) + for field, value in (("gateway", "192.0.2.2"), ("uuid", "ethernet"), ("kind", "ethernet")): + profiles = self.profiles() + profiles[1][field] = value + with self.assertRaises(ValueError): + self.candidate(profiles) + with self.assertRaises(ValueError): + self.candidate(self.profiles()[:1]) + + def test_read_only_observation_collects_narrow_profile_fields_and_policy_rules(self) -> None: + calls = [] + rules = [{"priority": 5210, "fwmark": "0x80000", "fwmask": "0xff0000", "table": "main"}, {"priority": 5230, "fwmark": "0x80000", "fwmask": "0xff0000", "table": "default"}, {"priority": 5250, "fwmark": "0x80000", "fwmask": "0xff0000", "action": "unreachable"}, {"priority": 5270, "table": 52}] + routes = [{"dst": "default", "gateway": "192.0.2.1", "dev": "eth0", "metric": 100, "table": "main"}, {"dst": "default", "gateway": "192.0.2.1", "dev": "wlan0", "metric": 600, "table": "main"}] + def run(argv): + calls.append(argv) + if argv[:3] == ["ip", "-j", "-4"]: + return json.dumps(rules if "rule" in argv else routes) + if argv[1] == "--terse": + return "profile-one:eth0\n" + return {"connection.uuid": argv[-1], "connection.type": "802-3-ethernet", "ipv4.route-metric": "100", "ipv4.route-table": "0", "ipv4.never-default": "no", "ipv4.routes": ""}[argv[2]] + result = planner.collect_observations(run, ["profile-one"]) + self.assertEqual(result["rules"], rules) + self.assertEqual(result["routes"], routes) + self.assertEqual(result["profiles"][0]["ipv4.route-metric"], "100") + self.assertEqual(result["profiles"][0]["device"], "eth0") + self.assertEqual(len(calls), 9) + self.assertTrue(all("password" not in " ".join(call) and "secrets" not in " ".join(call) for call in calls)) + self.assertTrue(all(call[0] in ("ip", "nmcli") for call in calls)) + + def test_observation_refuses_invalid_inputs_and_invalid_json(self) -> None: + with self.assertRaises(ValueError): + planner.collect_observations(lambda argv: "", ["--bad"]) + with self.assertRaises(ValueError): + planner.collect_observations(lambda argv: "{}", []) + + def test_active_association_mismatch_is_rejected(self) -> None: + def run(argv): + if argv[:3] == ["ip", "-j", "-4"]: + return "[]" + if argv[1] == "--terse": + return "other:eth0\n" + return "requested" if argv[2] == "connection.uuid" else "value" + with self.assertRaisesRegex(ValueError, "not uniquely active"): + planner.collect_observations(run, ["requested"]) + + def test_destination_outcomes_change_with_routes(self) -> None: + routes = [{"dst": "default", "dev": "eth0", "metric": 100}, {"dst": "10.42.0.0/24", "table": "52", "dev": "tailscale0"}] + self.assertEqual(planner.destination_tables(routes, "203.0.113.1"), {"main": "main-default"}) + self.assertEqual(planner.destination_tables(routes, "10.42.0.2"), {"main": "main-default", "52": "other-route"}) + routes.append({"dst": "203.0.113.0/24", "dev": "wlan0"}) + self.assertEqual(planner.destination_tables(routes, "203.0.113.1"), {"main": "other-route"}) + routes[-1]["multipath"] = [] + with self.assertRaises(ValueError): + planner.destination_tables(routes, "203.0.113.1") + + def test_evaluate_observed_rules_and_device_defaults(self) -> None: + observations = {"profiles": [{"device": "eth0"}, {"device": "wlan0"}], "rules": [{"priority": 0, "src": "all", "table": "local"}, {"priority": 5270, "src": "all", "table": "52"}, {"priority": 32766, "src": "all", "table": "main"}], "routes": [{"dst": "default", "dev": "eth0", "gateway": "192.0.2.1"}, {"dst": "default", "dev": "wlan0", "gateway": "192.0.2.1"}, {"dst": "10.42.0.0/24", "dev": "tailscale0", "table": "52"}]} + self.assertTrue(planner.evaluate_observations(observations, "203.0.113.1", 0)) + self.assertFalse(planner.evaluate_observations(observations, "10.42.0.1", 0)) + observations["profiles"][1]["device"] = "missing" + with self.assertRaises(ValueError): + planner.evaluate_observations(observations, "203.0.113.1", 0) + + def test_changed_snapshots_are_rejected(self) -> None: + from unittest.mock import patch + for field in ("rules", "routes"): + first = {"profiles": [], "rules": [], "routes": []} + second = {"profiles": [], "rules": [], "routes": []} + second[field] = [{"changed": True}] + with patch.object(planner, "collect_observations", side_effect=[first, second]): + with self.assertRaisesRegex(ValueError, "changed"): + planner.collect_consistent(lambda argv: "", []) + + def test_policy_rules_select_per_flow_table(self) -> None: + tables = {255: "miss", 52: "miss", 254: "main-default", 253: "miss"} + self.assertTrue(planner.controls_destination(self.rules(), 0, tables)) + self.assertTrue(planner.controls_destination(self.rules(), 0x80000, tables)) + self.assertFalse(planner.controls_destination(self.rules(), 0, {**tables, 52: "other-route"})) + self.assertFalse(planner.controls_destination(self.rules(), 0x80000, {**tables, 254: "miss"})) + self.assertFalse(planner.controls_destination(self.rules(), 0, {**tables, 254: "other-route"})) + with self.assertRaises(ValueError): + planner.controls_destination(self.rules(), 0, {255: "miss"}) + rules = self.rules() + rules[0]["source"] = "192.0.2.0/24" + with self.assertRaises(ValueError): + planner.controls_destination(rules, 0, tables) + + def test_adapter_is_bound_to_one_daemon_process_by_its_unique_name(self) -> None: + from unittest.mock import MagicMock + dbus = MagicMock() + dbus.Interface.side_effect = lambda obj, name: obj + live = {":1.5"} + reached = [] + + class Gone(Exception): + pass + + class Bus: + def get_name_owner(self, service): + return ":1.5" + + def get_object(self, name, path): + if name not in live: + raise Gone("ServiceUnknown: " + name) + reached.append(name) + return MagicMock() + + adapter = metric_transaction.NetworkManagerAdapter(Bus(), dbus) + adapter.destroy_checkpoint("/checkpoint") + self.assertEqual(reached, [":1.5"]) + # The daemon restarts: the well-known name now belongs to a new process, and the old unique name is gone. + live.clear() + live.add(":1.34") + with self.assertRaisesRegex(Gone, "ServiceUnknown: :1.5"): + adapter.destroy_checkpoint("/checkpoint") + for owner in ("org.freedesktop.NetworkManager", "", None, 5): + bus = MagicMock() + bus.get_name_owner.return_value = owner + with self.assertRaisesRegex(ValueError, "unique bus name"): + metric_transaction.NetworkManagerAdapter(bus, dbus) + + def test_dbus_adapter_profile_checkpoint_and_errors(self) -> None: + from unittest.mock import MagicMock + dbus = MagicMock() + dbus.Interface.side_effect = lambda obj, name: obj + dbus.UInt32.side_effect = int + dbus.UInt64.side_effect = lambda value, **kwargs: int(value) + dbus.Int64.side_effect = lambda value, **kwargs: value + dbus.Array.side_effect = lambda value, **kwargs: value + dbus.Dictionary.side_effect = lambda value, **kwargs: value + bus = MagicMock() + bus.get_name_owner.return_value = ":1.5" + manager, settings, connection, device = MagicMock(), MagicMock(), MagicMock(), MagicMock() + paths = {"/org/freedesktop/NetworkManager": manager, "/org/freedesktop/NetworkManager/Settings": settings, "/profile": connection, "/device": device} + bus.get_object.side_effect = lambda service, path: paths[path] + settings.GetConnectionByUuid.return_value = "/profile" + connection.Get.return_value = 17 + connection.GetSettings.return_value = {"connection": {"id": "fixture"}, "ipv4": {"route-metric": -1}} + manager.CheckpointCreate.return_value = "/checkpoint" + manager.CheckpointRollback.return_value = {"/device": 0} + adapter = metric_transaction.NetworkManagerAdapter(bus, dbus) + self.assertEqual(adapter.create_checkpoint(["/device"], 30, 0), "/checkpoint") + manager.CheckpointCreate.assert_called_once_with(["/device"], 30, 0) + adapter.set_metric("fixture-uuid", 20101) + self.assertEqual(connection.Update2.call_args.args[0]["ipv4"]["route-metric"], 20101) + self.assertEqual(connection.Update2.call_args.args[0]["connection"]["id"], "fixture") + self.assertEqual(connection.Update2.call_args.args[1:], (1, {"version-id": 17})) + connection.Update2.side_effect = RuntimeError("version mismatch") + with self.assertRaisesRegex(RuntimeError, "version mismatch"): + adapter.set_metric("fixture-uuid", 100) + device.GetAppliedConnection.return_value = ({"connection": {"uuid": "fixture-uuid"}, "ipv4": {"route-metric": -1}}, 42) + applied, version = adapter.inspect_device("/device", "fixture-uuid") + with self.assertRaises(ValueError): + adapter.inspect_device("/device", "other") + adapter.reapply("/device", "fixture-uuid", 20101, applied, version) + device.Reapply.assert_called_once_with(applied, 42, 1) + adapter.rollback_checkpoint("/checkpoint", ["/device"]) + manager.CheckpointRollback.return_value = {} + with self.assertRaisesRegex(RuntimeError, "incomplete"): + adapter.rollback_checkpoint("/checkpoint", ["/device"]) + manager.CheckpointRollback.return_value = {"/device": 3} + with self.assertRaisesRegex(RuntimeError, "incomplete or failed"): + adapter.rollback_checkpoint("/checkpoint", ["/device"]) + with self.assertRaises(ValueError): + adapter.create_checkpoint(["/device"], 0, 0) + manager.CheckpointCreate.side_effect = RuntimeError("overlapping checkpoint") + with self.assertRaisesRegex(RuntimeError, "overlapping"): + adapter.create_checkpoint(["/device"], 30, 0) + + def test_protocol_retains_original_and_rollback_failures(self) -> None: + from unittest.mock import MagicMock + adapter = MagicMock() + adapter.inspect_device.side_effect = ValueError("wrong profile") + adapter.rollback_checkpoint.side_effect = RuntimeError("rollback incomplete") + profiles = [{"device": "/a", "uuid": "a", "metric": 100}, {"device": "/b", "uuid": "b", "metric": 20101}] + with self.assertRaises(ExceptionGroup) as raised: + metric_transaction._execute_for_test(adapter, profiles, 30, lambda: True) + self.assertEqual([str(e) for e in raised.exception.exceptions], ["wrong profile", "rollback incomplete"]) + adapter.set_metric.assert_not_called() + adapter.rollback_checkpoint.assert_called_once_with(adapter.create_checkpoint.return_value, ["/a", "/b"]) + + def test_version_fence_rejects_delayed_authorisation(self) -> None: + model = VersionFenceModel() + owner, version = model.snapshot() + self.assertTrue(model.update(owner, version, 600)) + self.assertEqual(model.version, version + 1) + self.assertFalse(model.update(owner, version, 20101)) + self.assertEqual(model.metric, 600) + + def test_version_reuse_after_restart_requires_owner_identity(self) -> None: + model = VersionFenceModel() + owner, version = model.snapshot() + model.restart() + self.assertEqual(model.version, version) + self.assertFalse(model.update(owner, version, 20101)) + self.assertTrue(model.update(model.owner, version, 20101)) + + def test_unversioned_writer_bypasses_version_fence(self) -> None: + model = VersionFenceModel() + owner, version = model.snapshot() + self.assertTrue(model.update(owner, version, 600)) + self.assertTrue(model.update(owner, 0, 20101)) + self.assertEqual(model.metric, 20101) + + def test_late_daemon_write_can_follow_offline_recovery(self) -> None: + """Model a request accepted before writer death but executed after recovery.""" + durable = {"phase": "prepared", "before": self.original.copy()} + profile_store = self.original.copy() + pending_daemon_write = ("wifi", Profile(20101)) + durable["phase"] = "rolling_back" + profile_store.update(durable["before"]) + durable["phase"] = "rolled_back" + name, value = pending_daemon_write + profile_store[name] = value + self.assertEqual(durable["phase"], "rolled_back") + self.assertNotEqual(profile_store, durable["before"]) + self.assertEqual(profile_store["wifi"].metric, 20101) + + def test_guarded_transaction_never_calls_adapter(self) -> None: + class Forbidden: + def __getattribute__(self, name): + raise AssertionError("host adapter was touched") + with self.assertRaisesRegex(RuntimeError, "disabled"): + metric_transaction.execute(Forbidden(), [], 10, lambda: True) + + def test_checkpoint_protocol_competitor_and_timeout(self) -> None: + model = self.model + now = [0] + class Adapter: + def create_checkpoint(self, devices, timeout, flags): + self.assertion = (devices, timeout, flags) + return model.begin(now[0], now[0] + timeout) + def inspect_device(self, device, uuid): + return {}, 1 + def set_metric(self, uuid, metric): + model.set_metric(self.token, uuid, metric, now[0]) + def reapply(self, device, uuid, metric, applied, version): + pass + def destroy_checkpoint(self, checkpoint): + if not model.commit(checkpoint, now[0]): + raise RuntimeError("checkpoint expired") + def rollback_checkpoint(self, checkpoint, devices): + model.expire(now[0]) + profiles = [{"device": "eth0", "uuid": "ethernet", "metric": 100}, {"device": "wlan0", "uuid": "wifi", "metric": 20101}] + existing = model.begin(0, 10) + with self.assertRaisesRegex(RuntimeError, "already active"): + metric_transaction._execute_for_test(Adapter(), profiles, 10, lambda: True) + self.assertTrue(model.commit(existing, 1)) + adapter = Adapter() + create = adapter.create_checkpoint + def create_with_token(devices, timeout, flags): + adapter.token = create(devices, timeout, flags) + return adapter.token + adapter.create_checkpoint = create_with_token + def expire_before_commit(): + now[0] = 10 + return True + with self.assertRaisesRegex(RuntimeError, "expired"): + metric_transaction._execute_for_test(adapter, profiles, 10, expire_before_commit) + self.assertEqual(model.profiles, self.original) + self.assertEqual(adapter.assertion, (["eth0", "wlan0"], 10, 0)) + + def test_expiry_restores_both_persistent_profiles(self) -> None: + token = self.model.begin(0, 10) + self.model.set_metric(token, "ethernet", 100, 1) + self.model.set_metric(token, "wifi", 20101, 2) + self.model.expire(9) + self.assertEqual(self.model.profiles["wifi"].metric, 20101) + self.model.expire(10) + self.assertEqual(self.model.profiles, self.original) + self.assertFalse(self.model.commit(token, 10)) + + def test_commit_preserves_metrics_and_wifi_default_route(self) -> None: + token = self.model.begin(0, 10) + self.model.set_metric(token, "wifi", 20101, 1) + self.assertTrue(self.model.commit(token, 2)) + self.model.expire(10) + self.assertEqual(self.model.profiles["wifi"], Profile(20101)) + self.assertLess(self.model.profiles["ethernet"].metric + 20000, self.model.profiles["wifi"].metric) + + def test_competing_transactions_have_one_owner(self) -> None: + barrier = threading.Barrier(3) + outcomes: list[object] = [] + def begin() -> None: + barrier.wait() + try: + outcomes.append(self.model.begin(0, 10)) + except RuntimeError: + outcomes.append(None) + workers = [threading.Thread(target=begin) for _ in range(2)] + for worker in workers: + worker.start() + barrier.wait() + for worker in workers: + worker.join(2) + self.assertFalse(worker.is_alive()) + self.assertEqual(sum(value is not None for value in outcomes), 1) + + def test_expired_owner_cannot_modify_or_commit_new_checkpoint(self) -> None: + old = self.model.begin(0, 10) + self.model.set_metric(old, "wifi", 20101, 1) + new = self.model.begin(10, 20) + with self.assertRaises(RuntimeError): + self.model.set_metric(old, "wifi", 1, 11) + self.assertFalse(self.model.commit(old, 11)) + self.model.set_metric(new, "wifi", 21000, 11) + self.assertTrue(self.model.commit(new, 12)) + self.assertEqual(self.model.profiles["wifi"].metric, 21000) + + +if __name__ == "__main__": + unittest.main()