Files
hermes-agent/tests/e2e/core/upgrade/_helpers.py

232 lines
8.6 KiB
Python

"""Lane-private helpers for the upgrade / install-integrity and config round-trip suites.
Every Hermes process these suites spawn runs:
* with a HOME/HERMES_HOME under the test's tmp dir and an environment built from an
allowlist (no inherited ``*_API_KEY`` / ``HERMES_*``), so only the fake provider is
configured;
* inside a ``bwrap`` sandbox when bubblewrap is usable: its own PID namespace (the
updater's process-table scans cannot see, let alone signal, any real gateway on the
host), a tmpfs over ``/run/user/<uid>`` (no user systemd bus), the real
``~/.hermes`` bind-mounted read-only, and ``--die-with-parent`` so killing the
sandbox kills every descendant (no orphans);
* with ``systemctl``/``launchctl``/``sudo``/``loginctl`` shims first on PATH that log
their argv and fail, so a service-restart attempt is observable and never reaches a
real supervisor.
"""
from __future__ import annotations
import os
import pwd
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Iterable, Sequence
WORKTREE = Path(__file__).resolve().parents[4]
REAL_HOME = Path(pwd.getpwuid(os.getuid()).pw_dir)
UID = os.getuid()
_ENV_ALLOW = ("LANG", "LC_ALL", "TZ", "TERM", "SHELL", "USER", "LOGNAME", "TMPDIR", "SSL_CERT_FILE")
_SHIMMED = ("systemctl", "launchctl", "sudo", "loginctl", "journalctl")
def _bwrap_usable() -> bool:
exe = shutil.which("bwrap")
if not exe or sys.platform != "linux":
return False
try:
r = subprocess.run(
[exe, "--dev-bind", "/", "/", "--unshare-pid", "--proc", "/proc", "--die-with-parent", "true"],
capture_output=True, timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return False
return r.returncode == 0
BWRAP_OK = _bwrap_usable()
def sandbox_required_reason() -> str | None:
"""Why a test that runs the real updater must skip here, or None when it is safe.
Without a PID-namespace sandbox the updater's all-profile gateway scan could reach a
real gateway on a developer box; CI runners have none, so plain processes are fine
there.
"""
if BWRAP_OK:
return None
if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
return None
return "bubblewrap sandbox unavailable and not on CI; refusing to run the real updater next to a live install"
def write_shims(bin_dir: Path) -> Path:
"""Create failing, argv-logging shims for service managers; returns the call log path."""
bin_dir.mkdir(parents=True, exist_ok=True)
log = bin_dir / "shim-calls.log"
for name in _SHIMMED:
p = bin_dir / name
p.write_text(
"#!/bin/sh\n"
f'printf "%s\\n" "{name} $*" >> "{log}"\n'
'echo "Failed to connect to bus: sandboxed test shim" >&2\n'
"exit 1\n",
encoding="utf-8",
)
p.chmod(0o755)
return log
def isolated_env(
root: Path,
*,
extra_path: Iterable[Path] = (),
pythonpath: Path | None = None,
extra: dict[str, str] | None = None,
) -> dict[str, str]:
"""Allowlisted environment with HOME/HERMES_HOME under ``root``."""
home = root / "home"
hermes_home = home / ".hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
shim_dir = root / "shims"
write_shims(shim_dir)
env = {k: os.environ[k] for k in _ENV_ALLOW if k in os.environ}
env.setdefault("LANG", "C.UTF-8")
env.update(
HOME=str(home),
HERMES_HOME=str(hermes_home),
# The pytest ancestor marks this child as guarded; its HOME is already the sandbox.
HERMES_STATE_DB_GUARD_BYPASS="1",
XDG_RUNTIME_DIR=str(root / "run"),
XDG_CONFIG_HOME=str(home / ".config"),
XDG_DATA_HOME=str(home / ".local" / "share"),
XDG_CACHE_HOME=str(home / ".cache"),
DBUS_SESSION_BUS_ADDRESS="unix:path=/nonexistent/hermes-test-bus",
NO_COLOR="1",
TERM="dumb",
PYTHONUNBUFFERED="1",
PYTHONHASHSEED="0",
HERMES_DISABLE_LAZY_INSTALLS="1",
TIRITH_ENABLED="false",
GIT_TERMINAL_PROMPT="0",
GIT_CONFIG_NOSYSTEM="1",
GIT_CONFIG_GLOBAL=str(home / ".gitconfig"),
GIT_AUTHOR_NAME="e2e", GIT_AUTHOR_EMAIL="e2e@example.invalid",
GIT_COMMITTER_NAME="e2e", GIT_COMMITTER_EMAIL="e2e@example.invalid",
)
(root / "run").mkdir(parents=True, exist_ok=True)
# Reuse the host uv cache (read/write, uv is concurrency-safe) so dependency syncs are
# warm; never the real ~/.hermes.
real_uv_cache = Path(os.environ.get("UV_CACHE_DIR") or REAL_HOME / ".cache" / "uv")
if real_uv_cache.is_dir():
env["UV_CACHE_DIR"] = str(real_uv_cache)
base_path = os.environ.get("PATH", "/usr/bin:/bin")
uv = shutil.which("uv") or (str(REAL_HOME / ".hermes" / "bin" / "uv") if (REAL_HOME / ".hermes" / "bin" / "uv").exists() else None)
path_parts = [str(shim_dir), *[str(p) for p in extra_path]]
if uv:
path_parts.append(str(Path(uv).parent))
env["PATH"] = os.pathsep.join(path_parts + [base_path])
if pythonpath is not None:
env["PYTHONPATH"] = str(pythonpath)
if extra:
env.update(extra)
return env
def sandbox_argv(argv: Sequence[str], *, writable: Iterable[Path], unshare_net: bool = False,
ro_binds: Iterable[tuple[Path, Path]] = ()) -> list[str]:
"""Wrap ``argv`` in the bwrap sandbox (no-op when bubblewrap is unusable).
Opt-in: ``unshare_net`` gives the sandbox its own network namespace (loopback only; the
hostile-network suites bridge their fakes in); ``ro_binds`` are ``(host_path, sandbox_path)``
read-only overlays (e.g. a CA bundle over the distro trust store).
"""
if not BWRAP_OK:
if unshare_net:
raise RuntimeError("unshare_net requires the bwrap sandbox")
return list(argv)
# The child's allowlisted PATH may omit the Nix-provided bwrap (notably a login shell
# with a clean distro PATH); resolve it in the parent before wrapping the command.
cmd = [shutil.which("bwrap") or "bwrap", "--dev-bind", "/", "/"]
real_hermes = REAL_HOME / ".hermes"
if real_hermes.is_dir():
cmd += ["--ro-bind", str(real_hermes), str(real_hermes)]
for w in writable:
w = Path(w)
w.mkdir(parents=True, exist_ok=True)
cmd += ["--bind", str(w), str(w)]
run_user = Path(f"/run/user/{UID}")
if run_user.is_dir():
cmd += ["--tmpfs", str(run_user)]
for src, dest in ro_binds:
cmd += ["--ro-bind", str(src), str(dest)]
if unshare_net:
cmd += ["--unshare-net"]
cmd += ["--unshare-pid", "--proc", "/proc", "--die-with-parent", "--"]
return cmd + list(argv)
def run(
argv: Sequence[str],
*,
env: dict[str, str],
cwd: Path,
writable: Iterable[Path],
timeout: float = 300,
input: str | None = None,
unshare_net: bool = False,
ro_binds: Iterable[tuple[Path, Path]] = (),
) -> subprocess.CompletedProcess:
"""Run one sandboxed process to completion; kills the whole sandbox on timeout."""
proc = subprocess.Popen(
sandbox_argv(argv, writable=writable, unshare_net=unshare_net, ro_binds=ro_binds),
env=env, cwd=str(cwd), text=True,
stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True,
)
try:
out, err = proc.communicate(input=input, timeout=timeout)
except subprocess.TimeoutExpired:
kill_tree(proc)
out, err = proc.communicate()
raise AssertionError(
f"{list(argv)} timed out after {timeout}s\nSTDOUT tail:\n{out[-4000:]}\nSTDERR tail:\n{err[-4000:]}"
)
return subprocess.CompletedProcess(list(argv), proc.returncode, out, err)
def kill_tree(proc: subprocess.Popen) -> None:
"""SIGKILL the process group we started (the sandbox's PID namespace dies with it)."""
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
pass
def wait_for(predicate, *, timeout: float, interval: float = 0.1, what: str = "condition"):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(interval)
raise AssertionError(f"timed out after {timeout}s waiting for {what}")
def describe(cp: subprocess.CompletedProcess, limit: int = 6000) -> str:
return (
f"argv={cp.args}\nrc={cp.returncode}\n--- stdout (tail) ---\n{(cp.stdout or '')[-limit:]}"
f"\n--- stderr (tail) ---\n{(cp.stderr or '')[-limit:]}"
)