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: centralLEFT4ME_ROOTand safe overlay-ref resolution.l4d2host/instances.py: derive installation, overlays, instances, and runtime directories fromLEFT4ME_ROOT; stop installing user units.l4d2host/steam_install.py: install SteamCMD app content into${LEFT4ME_ROOT}/installationby default.l4d2host/service_control.py: call constrained sudo helpers for start, stop, status, and logs.l4d2host/status.py: parse status fromleft4me-systemctl showhelper output.l4d2host/logs.py: stream logs throughleft4me-journalctlhelper.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 existingOverlay.pathcolumn.l4d2web/services/l4d2_facade.py: emit overlay refs fromOverlay.pathinto generated host specs.l4d2web/pyproject.toml: includegunicornfor 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
left4meuser setup,/var/lib/left4mehome/state,/opt/left4mesource/venv,/etc/left4meenv 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 consistentlyleft4me-server@.service; helpers are consistentlyleft4me-systemctlandleft4me-journalctl; web overlay refs are stored in existingOverlay.pathand 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.