left4me/deploy/tests/test_example_units.py
mwiegand 434ee20339
refactor(deploy): venv + steam now under /var/lib/left4me
Sync deployment references for the runtime state relocation
shipped via ckn-bw (commit 6fae2fd). /opt/left4me/ is now a
root-owned deploy-artifact root (just src/); .venv and steamcmd
live at /var/lib/left4me/{.venv,steam}.

Touches:
- deploy/files/.../left4me-web.service: PATH + ExecStart
- deploy/files/.../left4me-workshop-refresh.service: WorkingDirectory
  (was /opt/left4me, now /opt/left4me/src to match the web unit),
  PATH, ExecStart
- scripts/sbin/left4me wrapper: flask path
- deploy/tests/test_example_units.py: PATH + ExecStart assertions
  for the web unit; also fix a pre-existing broken assertion that
  read "Environment=PATH=..." (the unit has Environment=HOME=...
  PATH=... on one line, so "Environment=PATH=" was never present)
  - now reads just "PATH=..."
- deploy/README.md: paths
- l4d2host/tests/test_cli.py: LEFT4ME_STEAMCMD fixture path

Design + as-shipped record:
docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md.
The original (narrower) prereq spec at
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
is marked superseded with a pointer to what shipped + why the
scope grew (setuptools writes egg-info to source during PEP 517
build prep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:56:32 +02:00

233 lines
9.2 KiB
Python

"""Lockdown tests for the curated examples kept under `deploy/files/`.
`deploy/` is reference material. The production units are emitted by
ckn-bw's `systemd_units` reactor in `bundles/left4me/metadata.py`;
when reactor output drifts intentionally, update these examples to match.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DEPLOY = ROOT / "deploy"
WEB_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-web.service"
SERVER_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-server@.service"
GAME_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-game.slice"
BUILD_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-build.slice"
SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf"
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
def test_global_unit_files_exist_at_product_level_paths():
assert WEB_UNIT.is_file()
assert SERVER_UNIT.is_file()
def test_web_unit_contains_required_runtime_contract():
unit = WEB_UNIT.read_text()
assert "User=left4me" in unit
assert "Group=left4me" in unit
assert "WorkingDirectory=/opt/left4me" in unit
assert "PATH=/var/lib/left4me/.venv/bin:" in unit
assert "EnvironmentFile=/etc/left4me/host.env" in unit
assert "EnvironmentFile=/etc/left4me/web.env" in unit
assert "ExecStart=/var/lib/left4me/.venv/bin/gunicorn" in unit
assert "--workers 1" in unit
assert "--threads 32" in unit
# NoNewPrivileges must remain unset because sudo (used by the overlay,
# systemctl and journalctl helpers) is setuid.
assert "NoNewPrivileges=true" not in unit
# Restored now that fuse-overlayfs propagation is no longer the mechanism.
assert "PrivateTmp=true" in unit
assert "ProtectSystem=full" in unit
assert "ReadWritePaths=/var/lib/left4me" in unit
# Mounts now happen in PID 1's namespace via the left4me-overlay helper,
# so MountFlags propagation is irrelevant — and the previous assumption
# that MountFlags=shared made it work was incorrect.
assert "MountFlags=" not in unit
def test_server_unit_contains_required_runtime_contract():
unit = SERVER_UNIT.read_text()
assert "User=left4me" in unit
assert "Group=left4me" in unit
assert "EnvironmentFile=/etc/left4me/host.env" in unit
assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in unit
# `-` prefix: chdir failure is non-fatal so ExecStartPre can run the
# mount helper before the merged dir exists. ExecStart re-applies and
# finds the dir once the mount has landed.
assert "WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2" in unit
# ExecStart must invoke srcds_run from the *merged* overlay tree, not
# from installation/. srcds_run cds to its own dirname; if we point at
# installation/, the engine reads gameinfo.txt and addons from the lower
# layer and never sees overlay plugins (Metamod/SourceMod) or cfgs.
assert "ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run" in unit
assert "$L4D2_ARGS" in unit
assert "${L4D2_ARGS}" not in unit
assert "NoNewPrivileges=true" in unit
assert "PrivateTmp=true" in unit
assert "PrivateDevices=true" in unit
assert "ProtectHome=true" in unit
assert "ProtectSystem=strict" in unit
assert "ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays" in unit
assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in unit
assert "RestrictSUIDSGID=true" in unit
assert "LockPersonality=true" in unit
def test_server_unit_mounts_overlay_via_exec_start_pre():
"""At boot, systemd auto-starts enabled units before the web app gets a
chance to run start_instance's pre-start mount. The unit itself must
re-mount the overlay so reboots are transparent. Pairs with the helper's
idempotency check (test_overlay_helper_mount_is_idempotent_when_mounted).
The unit-level `nsenter --mount=/proc/1/ns/mnt --` is what makes
umount fast: without it, the helper Python process would inherit
the unit's per-service mount namespace and pin it alive, blocking
PID 1's umount until the helper exited. Wrapping with nsenter at
the Exec line puts the helper itself in PID 1's namespace.
"""
unit = SERVER_UNIT.read_text()
# `+` prefix: runs as PID 1 (root, no sandbox). Required because
# the unit has NoNewPrivileges=true, which blocks sudo's setuid
# escalation — and the helper needs root for the mount syscall.
assert (
"ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- "
"/usr/local/libexec/left4me/left4me-overlay mount %i"
in unit
)
# Bound the restart loop; without these, a CHDIR-failure (or any other
# pre-start error) spins indefinitely.
assert "StartLimitBurst=5" in unit
assert "StartLimitIntervalSec=60s" in unit
def test_server_unit_unmounts_overlay_via_exec_stop_post():
"""Single source of truth for unmount, mirroring the mount path.
ExecStopPost (not ExecStop) so it runs after srcds has fully exited
and the cgroup is cleared.
Same nsenter-at-Exec-line wrapping as ExecStartPre — without it,
the helper process would itself hold a reference to the unit's
per-service mount namespace, and umount in PID 1 would loop on
EBUSY until the helper gave up. With it, umount succeeds first try.
"""
unit = SERVER_UNIT.read_text()
assert (
"ExecStopPost=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- "
"/usr/local/libexec/left4me/left4me-overlay umount %i"
in unit
)
def test_server_unit_contains_perf_baseline_directives():
unit = SERVER_UNIT.read_text()
# Slice membership.
assert "Slice=l4d2-game.slice" in unit
# CFS priority bump (no SCHED_FIFO).
assert "Nice=-5" in unit
assert "CPUSchedulingPolicy=" not in unit
# I/O priority.
assert "IOSchedulingClass=best-effort" in unit
assert "IOSchedulingPriority=4" in unit
# OOM ordering: game servers survive, sandbox dies first.
assert "OOMScoreAdjust=-200" in unit
# Memory caps with headroom for map-load spikes.
assert "MemoryHigh=1.5G" in unit
assert "MemoryMax=2G" in unit
# Bounded fork surface.
assert "TasksMax=256" in unit
# Plenty of fds for plugin-heavy setups.
assert "LimitNOFILE=65536" in unit
# srcds clean shutdown via SIGINT, with time to flush. With the
# helper running in PID 1's mount namespace (via the unit-level
# nsenter on ExecStopPost), umount has no race window and the
# default 15 s is plenty for the whole stop transition.
assert "KillSignal=SIGINT" in unit
assert "TimeoutStopSec=15s" in unit
# Per-unit override of journald rate limiting (default drops srcds output).
assert "LogRateLimitIntervalSec=0" in unit
def test_l4d2_game_slice_exists_with_high_weights():
assert GAME_SLICE.is_file()
text = GAME_SLICE.read_text()
assert "[Slice]" in text
assert "CPUWeight=1000" in text
assert "IOWeight=1000" in text
def test_l4d2_build_slice_exists_with_low_weights():
assert BUILD_SLICE.is_file()
text = BUILD_SLICE.read_text()
assert "[Slice]" in text
assert "CPUWeight=10" in text
assert "IOWeight=10" in text
def test_sysctl_conf_present_with_perf_settings():
assert SYSCTL_CONF.is_file()
text = SYSCTL_CONF.read_text()
for line in (
"net.core.rmem_max = 8388608",
"net.core.wmem_max = 8388608",
"net.core.rmem_default = 524288",
"net.core.wmem_default = 524288",
"net.core.netdev_max_backlog = 5000",
"net.core.netdev_budget = 600",
"vm.swappiness = 10",
"net.ipv4.udp_rmem_min = 16384",
"net.ipv4.udp_wmem_min = 16384",
"net.core.default_qdisc = fq_codel",
"net.ipv4.tcp_congestion_control = bbr",
):
assert line in text, f"missing {line!r} in 99-left4me.conf"
def test_env_templates_contain_required_defaults():
host_env = HOST_ENV.read_text()
assert "Deployment units use fixed /var/lib/left4me paths" in host_env
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
web_env = WEB_ENV_TEMPLATE.read_text()
assert web_env.startswith(
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
"SECRET_KEY=replace-with-generated-secret\n"
"JOB_WORKER_THREADS=4\n"
)
assert web_env.rstrip().endswith("STEAM_WEB_API_KEY=")
def test_sandbox_resolv_conf_exists():
assert SANDBOX_RESOLV_CONF.is_file()
text = SANDBOX_RESOLV_CONF.read_text()
nameservers = [
line.split()[1]
for line in text.splitlines()
if line.startswith("nameserver ")
]
assert len(nameservers) >= 2, "expected at least two nameservers for redundancy"
# Sanity: the resolvers must be public (not RFC1918 / loopback). We don't
# pin the exact IPs — Cloudflare/Google/Quad9 are all acceptable.
for ns in nameservers:
assert not ns.startswith("127."), ns
assert not ns.startswith("10."), ns
assert not ns.startswith("192.168."), ns
first_octet = int(ns.split(".")[0])
# Reject 172.16.0.0/12.
if first_octet == 172:
second_octet = int(ns.split(".")[1])
assert not (16 <= second_octet <= 31), ns