left4me/docs/superpowers/plans/2026-05-06-left4me-deployment.md

38 KiB

Left4me Deployment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a production-like test deployment path that installs the current working tree on a Linux host as left4me, with configurable runtime state, root-owned systemd units, and constrained sudo helpers for game-server control.

Architecture: Runtime state is rooted at LEFT4ME_ROOT, defaulting to /var/lib/left4me, while deployed code lives directly in /opt/left4me with its virtualenv at /opt/left4me/.venv. The web app runs as a global system service, and each game server runs as a root-owned global template unit left4me-server@.service under /usr/local/lib/systemd/system/. l4d2ctl manages server units through allowlisted helper commands invoked with sudo -n, not by writing user units.

Tech Stack: Python 3.12+, Typer, Flask, SQLAlchemy, pytest, POSIX shell, systemd, sudo, SteamCMD, fuse-overlayfs.


Approval gate: This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation.

Source Design

  • docs/superpowers/specs/2026-05-06-left4me-deployment-design.md

File Map

  • l4d2host/paths.py: central LEFT4ME_ROOT and safe overlay-ref resolution.
  • l4d2host/instances.py: derive installation, overlays, instances, and runtime directories from LEFT4ME_ROOT; stop installing user units.
  • l4d2host/steam_install.py: install SteamCMD app content into ${LEFT4ME_ROOT}/installation by default.
  • l4d2host/service_control.py: call constrained sudo helpers for start, stop, status, and logs.
  • l4d2host/status.py: parse status from left4me-systemctl show helper output.
  • l4d2host/logs.py: stream logs through left4me-journalctl helper.
  • l4d2host/pyproject.toml: remove the user-unit template package data if the template file is removed.
  • l4d2host/tests/test_paths.py: cover root config and overlay-ref validation.
  • l4d2host/tests/test_initialize.py: cover initialized paths under the configured root.
  • l4d2host/tests/test_lifecycle.py: cover helper-based start/stop/delete commands.
  • l4d2host/tests/test_status.py: cover helper-based status parsing.
  • l4d2host/tests/test_logs.py: cover helper-based log streaming command construction.
  • l4d2web/config.py: read deployment config from environment.
  • l4d2web/cli.py: add non-public user/admin bootstrap command.
  • l4d2web/services/security.py: validate overlay refs rather than absolute overlay paths.
  • l4d2web/routes/overlay_routes.py: store validated overlay refs in the existing Overlay.path column.
  • l4d2web/services/l4d2_facade.py: emit overlay refs from Overlay.path into generated host specs.
  • l4d2web/pyproject.toml: include gunicorn for the systemd web service.
  • l4d2web/tests/test_config.py: cover environment-derived config.
  • l4d2web/tests/test_auth.py: cover user/admin bootstrap CLI.
  • l4d2web/tests/test_overlays.py: cover safe overlay refs.
  • l4d2web/tests/test_l4d2_facade.py: cover generated specs using overlay refs.
  • deploy/files/usr/local/lib/systemd/system/left4me-web.service: global web systemd service.
  • deploy/files/usr/local/lib/systemd/system/left4me-server@.service: global per-server systemd template.
  • deploy/files/usr/local/libexec/left4me/left4me-systemctl: root-run helper for validated service actions.
  • deploy/files/usr/local/libexec/left4me/left4me-journalctl: root-run helper for validated journal reads.
  • deploy/files/etc/sudoers.d/left4me: allowlist for the helper commands.
  • deploy/templates/etc/left4me/host.env: default host env file.
  • deploy/templates/etc/left4me/web.env.template: web env template without committed secrets.
  • deploy/deploy-test-server.sh: idempotent SSH deployment script for a sudo-capable deploy user.
  • deploy/tests/test_deploy_artifacts.py: static checks for deployment artifacts.
  • deploy/README.md: operator documentation for the test deployment.
  • README.md, l4d2host/README.md, l4d2web/README.md, AGENTS.md: document the deployment contract changes.

Locked Deployment Layout

Remote runtime user:

user: left4me
home: /var/lib/left4me
shell: /usr/sbin/nologin

Remote paths:

/etc/left4me/host.env
/etc/left4me/web.env
/opt/left4me/.venv
/opt/left4me/<repository contents>
/var/lib/left4me/left4me.db
/var/lib/left4me/installation
/var/lib/left4me/overlays
/var/lib/left4me/instances
/var/lib/left4me/runtime
/var/lib/left4me/tmp
/usr/local/lib/systemd/system/left4me-web.service
/usr/local/lib/systemd/system/left4me-server@.service
/usr/local/libexec/left4me/left4me-systemctl
/usr/local/libexec/left4me/left4me-journalctl
/etc/sudoers.d/left4me

Overlay refs are relative refs under ${LEFT4ME_ROOT}/overlays. Valid examples are standard, competitive/base, and users/42/custom. Absolute paths, .., empty components, and symlink escapes are rejected.

Task 1: Add Configurable Host Root And Overlay Refs

Files:

  • Create: l4d2host/paths.py

  • Modify: l4d2host/instances.py

  • Modify: l4d2host/steam_install.py

  • Create: l4d2host/tests/test_paths.py

  • Modify: l4d2host/tests/test_initialize.py

  • Modify: l4d2host/tests/test_install.py

  • Step 1: Write failing host path tests

Create l4d2host/tests/test_paths.py:

from pathlib import Path

import pytest

from l4d2host.paths import get_left4me_root, overlay_path, validate_overlay_ref


def test_left4me_root_defaults_to_var_lib(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("LEFT4ME_ROOT", raising=False)

    assert get_left4me_root() == Path("/var/lib/left4me")


def test_left4me_root_can_be_set_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("LEFT4ME_ROOT", "/srv/left4me")

    assert get_left4me_root() == Path("/srv/left4me")


@pytest.mark.parametrize("ref", ["standard", "competitive/base", "users/42/custom"])
def test_overlay_ref_accepts_safe_relative_refs(ref: str) -> None:
    assert validate_overlay_ref(ref) == ref


@pytest.mark.parametrize("ref", ["", "/tmp/bad", "../bad", "bad/../evil", "bad//evil", " bad"])
def test_overlay_ref_rejects_unsafe_refs(ref: str) -> None:
    with pytest.raises(ValueError):
        validate_overlay_ref(ref)


def test_overlay_path_resolves_under_root(tmp_path: Path) -> None:
    assert overlay_path("standard", root=tmp_path) == tmp_path / "overlays" / "standard"
  • Step 2: Run path tests and verify failure

Run: pytest l4d2host/tests/test_paths.py -q

Expected: FAIL because l4d2host.paths does not exist.

  • Step 3: Implement l4d2host.paths

Create l4d2host/paths.py:

from pathlib import Path
import os


DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me")


def get_left4me_root() -> Path:
    raw = os.environ.get("LEFT4ME_ROOT", str(DEFAULT_LEFT4ME_ROOT)).strip()
    if not raw:
        raise ValueError("LEFT4ME_ROOT must not be empty")
    return Path(raw)


def validate_overlay_ref(raw: str) -> str:
    ref = raw.strip()
    if not ref:
        raise ValueError("overlay ref must not be empty")
    path = Path(ref)
    if path.is_absolute():
        raise ValueError("overlay ref must be relative")
    if any(part in {"", ".", ".."} for part in path.parts):
        raise ValueError("overlay ref must not contain empty, current, or parent components")
    if ref != path.as_posix():
        raise ValueError("overlay ref must use forward-slash path separators")
    return ref


def overlay_path(ref: str, *, root: Path | None = None) -> Path:
    base = root if root is not None else get_left4me_root()
    safe_ref = validate_overlay_ref(ref)
    candidate = (base / "overlays" / safe_ref).resolve(strict=False)
    overlay_root = (base / "overlays").resolve(strict=False)
    if candidate != overlay_root and overlay_root not in candidate.parents:
        raise ValueError("overlay ref escapes overlay root")
    return candidate
  • Step 4: Update host instance and install defaults

Modify l4d2host/instances.py so functions take root: Path | None = None, resolve root = get_left4me_root() when omitted, and build lowerdirs with overlay_path:

from l4d2host.paths import get_left4me_root, overlay_path


def _root_or_default(root: Path | None) -> Path:
    return root if root is not None else get_left4me_root()

Use _root_or_default(root) at the top of initialize_instance, start_instance, stop_instance, and delete_instance. In initialize_instance, replace direct overlay path construction with:

lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays]
lowerdirs.append(str(root / "installation"))

Modify l4d2host/steam_install.py so SteamInstaller() defaults to ${LEFT4ME_ROOT}/installation:

from l4d2host.paths import get_left4me_root


class SteamInstaller:
    def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"):
        self.install_dir = install_dir if install_dir is not None else get_left4me_root() / "installation"
        self.steamcmd = steamcmd
  • Step 5: Update initialize and install tests

In l4d2host/tests/test_initialize.py, add:

def test_initialize_uses_configured_left4me_root(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
    spec = tmp_path / "spec.yaml"
    spec.write_text("port: 27015\noverlays: [standard]\n")

    initialize_instance("alpha", spec)

    env = (tmp_path / "instances/alpha/instance.env").read_text()
    assert f"L4D2_LOWERDIRS={tmp_path}/overlays/standard:{tmp_path}/installation" in env

In l4d2host/tests/test_install.py, add:

def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch):
    monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
    calls = []
    monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: calls.append(cmd))

    SteamInstaller().install_or_update()

    assert str(tmp_path / "installation") in calls[0]
  • Step 6: Run host path tests and verify pass

Run: pytest l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py -q

Expected: PASS.

  • Step 7: Commit configurable host root
git add l4d2host/paths.py l4d2host/instances.py l4d2host/steam_install.py l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py
git commit -m "feat(l4d2): configure host state root"

Task 2: Switch Host Control To Global Unit Helpers

Files:

  • Create: l4d2host/service_control.py

  • Modify: l4d2host/instances.py

  • Modify: l4d2host/status.py

  • Modify: l4d2host/logs.py

  • Modify: l4d2host/pyproject.toml

  • Delete: l4d2host/systemd_user.py

  • Delete: l4d2host/templates/l4d2@.service

  • Modify: l4d2host/tests/test_lifecycle.py

  • Modify: l4d2host/tests/test_status.py

  • Modify: l4d2host/tests/test_logs.py

  • Step 1: Write failing service-control tests

Create or extend l4d2host/tests/test_status.py with:

from l4d2host.status import get_instance_status


def test_status_uses_left4me_systemctl_helper(monkeypatch) -> None:
    calls = []

    def fake_run_command(cmd, **kwargs):
        del kwargs
        calls.append(cmd)
        return type("Result", (), {"stdout": "ActiveState=active\nSubState=running\n"})()

    monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)

    status = get_instance_status("alpha")

    assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "show", "alpha"]]
    assert status.state == "running"

Extend l4d2host/tests/test_logs.py with:

def test_logs_use_left4me_journalctl_helper(monkeypatch) -> None:
    calls = []

    def fake_stream(cmd):
        calls.append(cmd)
        return iter(["one"])

    monkeypatch.setattr("l4d2host.service_control.stream_command", fake_stream)

    from l4d2host.logs import stream_instance_logs

    assert list(stream_instance_logs("alpha", lines=25, follow=False)) == ["one"]
    assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-journalctl", "alpha", "--lines", "25", "--no-follow"]]

Update l4d2host/tests/test_lifecycle.py start/stop expectations to use left4me-systemctl helper instead of systemctl --user.

  • Step 2: Run service-control tests and verify failure

Run: pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q

Expected: FAIL because l4d2host.service_control does not exist and lifecycle code still uses user units.

  • Step 3: Implement service-control module

Create l4d2host/service_control.py:

import subprocess
from typing import Iterator

from l4d2host.process import run_command


SYSTEMCTL_HELPER = "/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = "/usr/local/libexec/left4me/left4me-journalctl"


def systemctl_command(action: str, name: str) -> list[str]:
    return ["sudo", "-n", SYSTEMCTL_HELPER, action, name]


def journalctl_command(name: str, *, lines: int = 200, follow: bool = True) -> list[str]:
    return [
        "sudo",
        "-n",
        JOURNALCTL_HELPER,
        name,
        "--lines",
        str(lines),
        "--follow" if follow else "--no-follow",
    ]


def start_service(name: str, **kwargs) -> None:
    run_command(systemctl_command("start", name), **kwargs)


def stop_service(name: str, **kwargs) -> None:
    run_command(systemctl_command("stop", name), **kwargs)


def show_service(name: str):
    return run_command(systemctl_command("show", name))


def stream_command(cmd: list[str]) -> Iterator[str]:
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
    try:
        if proc.stdout is None:
            return
        for raw in iter(proc.stdout.readline, ""):
            yield raw.rstrip("\n")
    finally:
        if proc.poll() is None:
            proc.terminate()
            proc.wait(timeout=2)


def stream_journal(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
    return stream_command(journalctl_command(name, lines=lines, follow=follow))
  • Step 4: Update host lifecycle/status/log implementations

In l4d2host/instances.py, remove ensure_template_unit and daemon_reload imports and calls. Replace service operations:

from l4d2host.service_control import start_service, stop_service

Use:

start_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel)

and:

stop_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel)

In delete_instance, call stop_service(...) before unmount/removal when instance or runtime exists.

In l4d2host/status.py, replace direct systemctl --user show with:

from l4d2host.service_control import show_service

and call result = show_service(name).

In l4d2host/logs.py, replace direct journalctl --user with:

from l4d2host.service_control import stream_journal


def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
    return stream_journal(name, lines=lines, follow=follow)

Delete l4d2host/systemd_user.py and l4d2host/templates/l4d2@.service. In l4d2host/pyproject.toml, remove "l4d2host.templates" from packages and remove the tool.setuptools.package-data entry for templates.

  • Step 5: Run host service-control tests and verify pass

Run: pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q

Expected: PASS.

  • Step 6: Commit global helper service boundary
git add l4d2host/service_control.py l4d2host/instances.py l4d2host/status.py l4d2host/logs.py l4d2host/pyproject.toml l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py
git rm l4d2host/systemd_user.py l4d2host/templates/l4d2@.service
git commit -m "feat(l4d2): manage servers through global unit helpers"

Task 3: Add Deployment Artifacts

Files:

  • Create: deploy/files/usr/local/lib/systemd/system/left4me-web.service

  • Create: deploy/files/usr/local/lib/systemd/system/left4me-server@.service

  • Create: deploy/files/usr/local/libexec/left4me/left4me-systemctl

  • Create: deploy/files/usr/local/libexec/left4me/left4me-journalctl

  • Create: deploy/files/etc/sudoers.d/left4me

  • Create: deploy/templates/etc/left4me/host.env

  • Create: deploy/templates/etc/left4me/web.env.template

  • Create: deploy/tests/test_deploy_artifacts.py

  • Step 1: Write failing deployment artifact tests

Create deploy/tests/test_deploy_artifacts.py:

from pathlib import Path
import subprocess


ROOT = Path(__file__).resolve().parents[2]


def read(path: str) -> str:
    return (ROOT / path).read_text()


def test_global_unit_names_are_product_level() -> None:
    assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-web.service").exists()
    assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-server@.service").exists()


def test_web_unit_uses_left4me_paths_and_env_files() -> None:
    body = read("deploy/files/usr/local/lib/systemd/system/left4me-web.service")
    assert "User=left4me" in body
    assert "WorkingDirectory=/opt/left4me" in body
    assert "EnvironmentFile=/etc/left4me/host.env" in body
    assert "EnvironmentFile=/etc/left4me/web.env" in body
    assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in body


def test_server_unit_runs_as_left4me_and_has_hardening() -> None:
    body = read("deploy/files/usr/local/lib/systemd/system/left4me-server@.service")
    assert "User=left4me" in body
    assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in body
    assert "WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2" in body
    assert "NoNewPrivileges=true" in body
    assert "ProtectSystem=strict" in body
    assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in body


def test_helper_scripts_reject_bad_arguments_without_systemctl() -> None:
    for helper in [
        ROOT / "deploy/files/usr/local/libexec/left4me/left4me-systemctl",
        ROOT / "deploy/files/usr/local/libexec/left4me/left4me-journalctl",
    ]:
        subprocess.run(["sh", "-n", str(helper)], check=True)
        result = subprocess.run(["sh", str(helper), "bad/action", "../evil"], capture_output=True, text=True)
        assert result.returncode != 0


def test_sudoers_allows_only_left4me_helpers() -> None:
    body = read("deploy/files/etc/sudoers.d/left4me")
    assert "/usr/local/libexec/left4me/left4me-systemctl *" in body
    assert "/usr/local/libexec/left4me/left4me-journalctl *" in body
    assert "/bin/systemctl" not in body
    assert "/bin/journalctl" not in body
  • Step 2: Run deployment artifact tests and verify failure

Run: pytest deploy/tests/test_deploy_artifacts.py -q

Expected: FAIL because deployment artifact files do not exist.

  • Step 3: Add web service unit

Create deploy/files/usr/local/lib/systemd/system/left4me-web.service:

[Unit]
Description=left4me web application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=left4me
Group=left4me
WorkingDirectory=/opt/left4me
Environment=HOME=/var/lib/left4me
EnvironmentFile=/etc/left4me/host.env
EnvironmentFile=/etc/left4me/web.env
ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 1 --threads 8 --bind 0.0.0.0:8000 'l4d2web.app:create_app()'
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/var/lib/left4me

[Install]
WantedBy=multi-user.target
  • Step 4: Add server service unit

Create deploy/files/usr/local/lib/systemd/system/left4me-server@.service:

[Unit]
Description=left4me server instance %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=left4me
Group=left4me
EnvironmentFile=/etc/left4me/host.env
EnvironmentFile=/var/lib/left4me/instances/%i/instance.env
WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2
ExecStart=/var/lib/left4me/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} ${L4D2_ARGS}
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectHome=true
ProtectSystem=strict
ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays
ReadWritePaths=/var/lib/left4me/runtime/%i
RestrictSUIDSGID=true
LockPersonality=true

[Install]
WantedBy=multi-user.target
  • Step 5: Add sudo helper scripts

Create deploy/files/usr/local/libexec/left4me/left4me-systemctl:

#!/bin/sh
set -eu

usage() {
  printf '%s\n' 'usage: left4me-systemctl start|stop|show <server-name>' >&2
  exit 64
}

validate_name() {
  name=$1
  case "$name" in
    ''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;;
  esac
}

[ "$#" -eq 2 ] || usage
action=$1
name=$2
validate_name "$name"
unit="left4me-server@${name}.service"

case "$action" in
  start) exec /bin/systemctl start "$unit" ;;
  stop) exec /bin/systemctl stop "$unit" ;;
  show) exec /bin/systemctl show "$unit" --property=ActiveState --property=SubState --no-pager ;;
  *) usage ;;
esac

Create deploy/files/usr/local/libexec/left4me/left4me-journalctl:

#!/bin/sh
set -eu

usage() {
  printf '%s\n' 'usage: left4me-journalctl <server-name> --lines <n> --follow|--no-follow' >&2
  exit 64
}

validate_name() {
  name=$1
  case "$name" in
    ''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;;
  esac
}

[ "$#" -eq 4 ] || usage
name=$1
lines_flag=$2
lines=$3
follow_flag=$4
validate_name "$name"
[ "$lines_flag" = '--lines' ] || usage
case "$lines" in ''|*[!0-9]*) usage ;; esac

unit="left4me-server@${name}.service"

case "$follow_flag" in
  --follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat -f ;;
  --no-follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat ;;
  *) usage ;;
esac
  • Step 6: Add sudoers and env templates

Create deploy/files/etc/sudoers.d/left4me:

Defaults:left4me !requiretty
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl *
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl *

Create deploy/templates/etc/left4me/host.env:

LEFT4ME_ROOT=/var/lib/left4me

Create deploy/templates/etc/left4me/web.env.template:

DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=replace-with-generated-secret
JOB_WORKER_THREADS=4
  • Step 7: Run deployment artifact tests and verify pass

Run: pytest deploy/tests/test_deploy_artifacts.py -q

Expected: PASS.

  • Step 8: Commit deployment artifacts
git add deploy/files deploy/templates deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): add left4me systemd and sudo artifacts"

Task 4: Add Web Deployment Config And Bootstrap CLI

Files:

  • Modify: l4d2web/config.py

  • Modify: l4d2web/cli.py

  • Modify: l4d2web/services/security.py

  • Modify: l4d2web/routes/overlay_routes.py

  • Modify: l4d2web/services/l4d2_facade.py

  • Modify: l4d2web/pyproject.toml

  • Create: l4d2web/tests/test_config.py

  • Modify: l4d2web/tests/test_auth.py

  • Modify: l4d2web/tests/test_overlays.py

  • Modify: l4d2web/tests/test_l4d2_facade.py

  • Step 1: Write failing web config and bootstrap tests

Create l4d2web/tests/test_config.py:

from l4d2web.config import load_config


def test_load_config_uses_environment(monkeypatch) -> None:
    monkeypatch.setenv("DATABASE_URL", "sqlite:////var/lib/left4me/left4me.db")
    monkeypatch.setenv("SECRET_KEY", "secret-from-env")
    monkeypatch.setenv("JOB_WORKER_THREADS", "2")

    config = load_config()

    assert config["DATABASE_URL"] == "sqlite:////var/lib/left4me/left4me.db"
    assert config["SECRET_KEY"] == "secret-from-env"
    assert config["JOB_WORKER_THREADS"] == 2

Extend l4d2web/tests/test_auth.py:

def test_create_user_cli_creates_admin(app, monkeypatch):
    monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret")
    result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])

    assert result.exit_code == 0
    assert "created user admin" in result.output

Extend l4d2web/tests/test_overlays.py:

def test_overlay_ref_must_be_relative(admin_client):
    r = admin_client.post("/overlays", data={"name": "bad", "path": "/tmp/bad"})

    assert r.status_code == 400


def test_admin_can_create_overlay_with_relative_ref(admin_client):
    r = admin_client.post("/overlays", data={"name": "standard", "path": "standard"})

    assert r.status_code == 302

Update l4d2web/tests/test_l4d2_facade.py seed data so Overlay.path is a relative ref, and assert generated specs contain the path ref rather than the display name.

  • Step 2: Run web config tests and verify failure

Run: pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q

Expected: FAIL because load_config, create-user, and overlay-ref validation are not implemented.

  • Step 3: Implement environment config

Modify l4d2web/config.py:

import os


def load_config() -> dict[str, object]:
    return {
        "SECRET_KEY": os.getenv("SECRET_KEY", "dev"),
        "DATABASE_URL": os.getenv("DATABASE_URL", "sqlite:///l4d2web.db"),
        "STATUS_REFRESH_SECONDS": int(os.getenv("STATUS_REFRESH_SECONDS", "8")),
        "JOB_WORKER_THREADS": int(os.getenv("JOB_WORKER_THREADS", "4")),
        "JOB_WORKER_ENABLED": os.getenv("JOB_WORKER_ENABLED", "true").lower() not in {"0", "false", "no"},
        "JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")),
        "JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")),
        "JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
    }


DEFAULT_CONFIG = load_config()

Modify l4d2web/app.py to call load_config() inside create_app() instead of reusing import-time DEFAULT_CONFIG:

from l4d2web.config import load_config


app.config.from_mapping(load_config())
  • Step 4: Implement create-user CLI

Modify l4d2web/cli.py:

import os

from l4d2web.auth import hash_password


@click.command("create-user")
@click.argument("username")
@click.option("--admin", is_flag=True, default=False)
def create_user(username: str, admin: bool) -> None:
    password = os.environ.get("LEFT4ME_ADMIN_PASSWORD")
    if password is None:
        password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
    with session_scope() as db:
        existing = db.scalar(select(User).where(User.username == username))
        if existing is not None:
            raise click.ClickException("user already exists")
        db.add(User(username=username, password_digest=hash_password(password), admin=admin))
    click.echo(f"created user {username}")

Register it in register_cli(app):

app.cli.add_command(create_user)
  • Step 5: Implement web overlay refs

Modify l4d2web/services/security.py:

from pathlib import Path


def validate_overlay_ref(raw: str) -> str:
    ref = raw.strip()
    if not ref:
        raise ValueError("overlay ref must not be empty")
    path = Path(ref)
    if path.is_absolute():
        raise ValueError("overlay ref must be relative")
    if any(part in {"", ".", ".."} for part in path.parts):
        raise ValueError("overlay ref must not contain empty, current, or parent components")
    if ref != path.as_posix():
        raise ValueError("overlay ref must use forward-slash path separators")
    return ref

Modify l4d2web/routes/overlay_routes.py to import validate_overlay_ref and store the returned ref in Overlay.path:

from l4d2web.services.security import validate_overlay_ref


validated_ref = validate_overlay_ref(raw_path)
db.add(Overlay(name=name, path=validated_ref))

Apply the same assignment in update_overlay.

Modify l4d2web/services/l4d2_facade.py so load_server_blueprint_bundle selects Overlay.path instead of Overlay.name:

select(Overlay.path)
  • Step 6: Add gunicorn dependency

Modify l4d2web/pyproject.toml dependencies:

dependencies = [
  "Flask>=3.0",
  "SQLAlchemy>=2.0",
  "alembic>=1.13",
  "PyYAML>=6.0",
  "gunicorn>=22.0",
]
  • Step 7: Run web config/bootstrap tests and verify pass

Run: pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q

Expected: PASS.

  • Step 8: Commit web deployment config
git add l4d2web/config.py l4d2web/app.py l4d2web/cli.py l4d2web/services/security.py l4d2web/routes/overlay_routes.py l4d2web/services/l4d2_facade.py l4d2web/pyproject.toml l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py
git commit -m "feat(l4d2-web): add deployment config and bootstrap cli"

Task 5: Add Test-Server Deployment Script

Files:

  • Create: deploy/deploy-test-server.sh

  • Modify: deploy/tests/test_deploy_artifacts.py

  • Step 1: Write failing deployment script tests

Extend deploy/tests/test_deploy_artifacts.py:

def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
    script = read("deploy/deploy-test-server.sh")
    assert "useradd --system --home-dir /var/lib/left4me" in script
    assert "/var/lib/left4me/installation" in script
    assert "/var/lib/left4me/overlays" in script
    assert "/var/lib/left4me/instances" in script
    assert "/var/lib/left4me/runtime" in script
    assert "tar" in script
    assert "pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web" in script
    assert "systemctl enable --now left4me-web.service" in script


def test_deploy_script_shell_syntax() -> None:
    subprocess.run(["sh", "-n", str(ROOT / "deploy/deploy-test-server.sh")], check=True)
  • Step 2: Run deployment script tests and verify failure

Run: pytest deploy/tests/test_deploy_artifacts.py -q

Expected: FAIL because deploy/deploy-test-server.sh does not exist.

  • Step 3: Implement test deployment script

Create deploy/deploy-test-server.sh:

#!/bin/sh
set -eu

usage() {
  printf '%s\n' 'usage: deploy/deploy-test-server.sh <ssh-user@host>' >&2
  exit 64
}

[ "$#" -eq 1 ] || usage
target=$1
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
archive=$(mktemp -t left4me-deploy.XXXXXX.tar.gz)
remote_archive=/tmp/left4me-deploy.tar.gz

cleanup() {
  rm -f "$archive"
}
trap cleanup EXIT INT TERM

tar \
  --exclude='.git' \
  --exclude='__pycache__' \
  --exclude='.pytest_cache' \
  --exclude='*.egg-info' \
  --exclude='l4d2web.db' \
  --exclude='l4d2web.db-shm' \
  --exclude='l4d2web.db-wal' \
  -C "$repo_root" \
  -czf "$archive" \
  .

scp "$archive" "$target:$remote_archive"

ssh "$target" 'set -eu
if ! id left4me >/dev/null 2>&1; then
  sudo useradd --system --home-dir /var/lib/left4me --create-home --shell /usr/sbin/nologin left4me
fi

if command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo
elif command -v dnf >/dev/null 2>&1; then
  sudo dnf install -y python3 python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo
else
  printf "%s\n" "unsupported package manager: install python3, venv tooling, fuse-overlayfs, fuse3, sudo, curl, tar, gzip" >&2
  exit 1
fi

sudo mkdir -p /etc/left4me /opt/left4me /usr/local/lib/systemd/system /usr/local/libexec/left4me /var/lib/left4me/installation /var/lib/left4me/overlays /var/lib/left4me/instances /var/lib/left4me/runtime /var/lib/left4me/tmp
sudo chown -R left4me:left4me /var/lib/left4me /opt/left4me
sudo chmod 0750 /var/lib/left4me

sudo find /opt/left4me -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} +
sudo tar -xzf /tmp/left4me-deploy.tar.gz -C /opt/left4me
sudo chown -R left4me:left4me /opt/left4me

sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service
sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl
sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
sudo install -m 0440 /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
sudo visudo -cf /etc/sudoers.d/left4me

sudo install -m 0644 /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env
if ! sudo test -f /etc/left4me/web.env; then
  secret=$(python3 -c "import secrets; print(secrets.token_hex(32))")
  printf "DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\nSECRET_KEY=%s\nJOB_WORKER_THREADS=4\n" "$secret" | sudo tee /etc/left4me/web.env >/dev/null
  sudo chmod 0640 /etc/left4me/web.env
  sudo chown root:left4me /etc/left4me/web.env
fi

sudo -u left4me python3 -m venv /opt/left4me/.venv
sudo -u left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip
sudo -u left4me /opt/left4me/.venv/bin/python -m pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web

sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; /opt/left4me/.venv/bin/python -c '"'"'from l4d2web.app import create_app; create_app()'"'"'"

if [ -n "${LEFT4ME_ADMIN_USERNAME:-}" ] && [ -n "${LEFT4ME_ADMIN_PASSWORD:-}" ]; then
  sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; LEFT4ME_ADMIN_PASSWORD=\"$LEFT4ME_ADMIN_PASSWORD\" /opt/left4me/.venv/bin/flask --app '"'"'l4d2web.app:create_app'"'"' create-user \"$LEFT4ME_ADMIN_USERNAME\" --admin || true"
fi

sudo systemctl daemon-reload
sudo systemctl enable --now left4me-web.service
sudo systemctl restart left4me-web.service
curl -fsS http://127.0.0.1:8000/health
'
  • Step 4: Run deployment script tests and verify pass

Run: pytest deploy/tests/test_deploy_artifacts.py -q

Expected: PASS.

  • Step 5: Commit deployment script
git add deploy/deploy-test-server.sh deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): add ssh test deployment script"

Task 6: Document Deployment Contract And Verify

Files:

  • Create: deploy/README.md

  • Modify: README.md

  • Modify: l4d2host/README.md

  • Modify: l4d2web/README.md

  • Modify: AGENTS.md

  • Step 1: Write deployment README

Create deploy/README.md:

# left4me Deployment

This directory contains the production-like test deployment for a Linux server.

## Target Layout

- `/etc/left4me/host.env`
- `/etc/left4me/web.env`
- `/opt/left4me/.venv`
- `/opt/left4me/<repository contents>`
- `/var/lib/left4me/left4me.db`
- `/var/lib/left4me/installation`
- `/var/lib/left4me/overlays`
- `/var/lib/left4me/instances`
- `/var/lib/left4me/runtime`
- `/var/lib/left4me/tmp`
- `/usr/local/lib/systemd/system/left4me-web.service`
- `/usr/local/lib/systemd/system/left4me-server@.service`
- `/usr/local/libexec/left4me/left4me-systemctl`
- `/usr/local/libexec/left4me/left4me-journalctl`
- `/etc/sudoers.d/left4me`

## Runtime User

The deployment creates a system user named `left4me` with home directory `/var/lib/left4me` and shell `/usr/sbin/nologin`.

## Running A Test Deployment

Run from the repository root:

```bash
deploy/deploy-test-server.sh deploy-user@example-host

The SSH user must be able to run sudo. The script deploys the current local working tree, installs or updates the virtualenv, preserves /var/lib/left4me, installs systemd units and sudo helpers, and restarts left4me-web.service.

To bootstrap an admin user during deployment:

LEFT4ME_ADMIN_USERNAME=admin LEFT4ME_ADMIN_PASSWORD='change-me' deploy/deploy-test-server.sh deploy-user@example-host

Overlay Refs

Overlay refs are relative paths under ${LEFT4ME_ROOT}/overlays, for example standard, competitive/base, and users/42/custom. Absolute paths, .., empty components, and symlink escapes are rejected.


- [ ] **Step 2: Update component docs**

Update `README.md` with a link to `deploy/README.md` and state that production-like test deployment uses `/var/lib/left4me`, `/opt/left4me`, `/etc/left4me`, and global units under `/usr/local/lib/systemd/system`.

Update `l4d2host/README.md` to document:

```text
LEFT4ME_ROOT defaults to /var/lib/left4me.
l4d2ctl start/stop/status/logs use sudo -n /usr/local/libexec/left4me/left4me-systemctl and left4me-journalctl.
Deployment or config management owns left4me-server@.service.

Update l4d2web/README.md to document:

DATABASE_URL, SECRET_KEY, and JOB_WORKER_THREADS are read from environment.
The systemd deployment loads /etc/left4me/host.env and /etc/left4me/web.env.
Use flask create-user <username> --admin with LEFT4ME_ADMIN_PASSWORD for bootstrap.

Update AGENTS.md so the host path rule references LEFT4ME_ROOT instead of hard-coded /opt/l4d2, and so global unit/helper ownership is recorded.

  • Step 3: Run focused verification

Run:

pytest l4d2host/tests -q
pytest l4d2web/tests -q
pytest deploy/tests -q

Expected: PASS for all three commands.

  • Step 4: Run code index refresh

Run: ccc index

Expected: command exits 0, or reports that ccc is unavailable. If ccc is unavailable, report that the index refresh was skipped because the command is missing.

  • Step 5: Commit docs and verification updates
git add deploy/README.md README.md l4d2host/README.md l4d2web/README.md AGENTS.md
git commit -m "docs(deploy): document left4me deployment contract"

Self-Review

  • Spec coverage: the plan covers left4me user setup, /var/lib/left4me home/state, /opt/left4me source/venv, /etc/left4me env files, LEFT4ME_ROOT, global units under /usr/local/lib/systemd/system, product-level server unit/helper names, constrained sudo helpers, relative overlay refs, deployment from local working tree, admin bootstrap, docs, and verification.
  • Placeholder scan: no task uses unresolved placeholders, unspecified error handling, or references to undefined helper names.
  • Type consistency: host root is consistently LEFT4ME_ROOT; server systemd unit is consistently left4me-server@.service; helpers are consistently left4me-systemctl and left4me-journalctl; web overlay refs are stored in existing Overlay.path and emitted to host specs from that column.
  • Scope: the plan does not execute deployment on a real server; it adds local deploy assets and code changes needed before a separate approved deployment run.