Compare commits

..

No commits in common. "bbfc52835428f0ecfaadd4cba60f4e6b4051d964" and "a3478296085c3c1f3786f19e80f93235ae78327f" have entirely different histories.

54 changed files with 208 additions and 3139 deletions

View file

@ -13,8 +13,9 @@ Do not invent architecture outside these plans unless explicitly requested.
## Current Project State ## Current Project State
- `l4d2host/` and `l4d2web/` implementation directories exist. - Repo is newly initialized.
- Implementation plans remain the source of truth for contract changes and task sequencing. - Only planning docs exist right now.
- Implementation directories are planned, not yet created.
## Non-Negotiable Constraints ## Non-Negotiable Constraints
@ -30,17 +31,13 @@ Do not invent architecture outside these plans unless explicitly requested.
### Host library (`l4d2host` / `l4d2ctl`) ### Host library (`l4d2host` / `l4d2ctl`)
- Exposed CLI write command set is fixed: - Exposed CLI command set is fixed:
- `install` - `install`
- `initialize <name> -f <spec.yaml>` - `initialize <name> -f <spec.yaml>`
- `start <name>` - `start <name>`
- `stop <name>` - `stop <name>`
- `delete <name>` - `delete <name>`
- CLI read commands are allowed for web/host boundary consistency: - Hard-coded paths under `/opt/l4d2`.
- `status <name> --json`
- `logs <name> --lines <n> --follow/--no-follow`
- Runtime paths are rooted at `LEFT4ME_ROOT`, defaulting to `/var/lib/left4me`.
- Deployment/config management owns global units under `/usr/local/lib/systemd/system` and privileged helpers under `/usr/local/libexec/left4me`.
- Overlays are external directories (no overlay content management here). - Overlays are external directories (no overlay content management here).
- Fail-fast subprocess behavior; pass raw stderr; propagate return code. - Fail-fast subprocess behavior; pass raw stderr; propagate return code.
- No lock manager, no rollback, no preflight runtime checks. - No lock manager, no rollback, no preflight runtime checks.
@ -58,7 +55,6 @@ Do not invent architecture outside these plans unless explicitly requested.
- Persist command logs in `job_logs` table (retain indefinitely). - Persist command logs in `job_logs` table (retain indefinitely).
- Desired vs actual server state model. - Desired vs actual server state model.
- Live logs in UI for both jobs and servers. - Live logs in UI for both jobs and servers.
- Web app host operations go through `l4d2ctl` via a host command client, not direct `l4d2host` imports.
- Blueprint semantics (locked): - Blueprint semantics (locked):
- private per user in v1 - private per user in v1
- live-linked to servers - live-linked to servers

View file

@ -7,7 +7,8 @@
## Status ## Status
Implementation plans remain the source of truth for architecture and task sequencing: This repository is currently in planning phase.
Implementation plans are the source of truth:
- `docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md` - `docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md`
- `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` - `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`
@ -16,17 +17,13 @@ Implementation plans remain the source of truth for architecture and task sequen
- Naming is strictly `l4d2` (not `l4d`). - Naming is strictly `l4d2` (not `l4d`).
- Host library and web app are separate components. - Host library and web app are separate components.
- Host CLI write commands are fixed to: - Host CLI commands are fixed to:
- `install` - `install`
- `initialize <name> -f <spec.yaml>` - `initialize <name> -f <spec.yaml>`
- `start <name>` - `start <name>`
- `stop <name>` - `stop <name>`
- `delete <name>` - `delete <name>`
- Host CLI read commands are available for the web/host boundary: - Runtime paths are hard-coded under `/opt/l4d2`.
- `status <name> --json`
- `logs <name> --lines <n> --follow/--no-follow`
- The web app calls host operations through `l4d2ctl`, not direct `l4d2host` imports.
- Deployment uses `/var/lib/left4me` for runtime state, `/opt/left4me` for repository contents and the virtualenv, `/etc/left4me` for environment files, and global units under `/usr/local/lib/systemd/system`.
- Overlay handling is directory-based and externally populated. - Overlay handling is directory-based and externally populated.
- No lock manager, no rollback, no preflight checks in host library. - No lock manager, no rollback, no preflight checks in host library.
- CLI propagates subprocess failures via stderr and return code. - CLI propagates subprocess failures via stderr and return code.
@ -43,13 +40,8 @@ Implementation plans remain the source of truth for architecture and task sequen
- `l4d2host/` - `l4d2host/`
- `l4d2web/` - `l4d2web/`
- `deploy/`
- `docs/superpowers/plans/` - `docs/superpowers/plans/`
## Deployment
See `deploy/README.md` for the Linux test deployment contract, including the runtime user, target filesystem layout, systemd units, privileged helpers, sudoers rules, admin bootstrap, and overlay reference rules.
## Tech Stack (planned) ## Tech Stack (planned)
- Python 3.12+ - Python 3.12+

View file

@ -1,72 +0,0 @@
# left4me Deployment
This directory contains the production-like test deployment for a Linux server. It installs the repository into a fixed host layout, configures a dedicated runtime user, installs systemd units, and wires the web app to host operations through privileged helper commands.
## Target Layout
The deployment uses these paths:
- `/etc/left4me/host.env`: host library environment configuration.
- `/etc/left4me/web.env`: web app environment configuration.
- `/opt/left4me/.venv`: Python virtual environment for deployed commands.
- `/opt/left4me`: deployed repository contents.
- `/var/lib/left4me/left4me.db`: SQLite database used by the web app.
- `/var/lib/left4me/installation`: shared L4D2 installation.
- `/var/lib/left4me/overlays`: externally managed overlay directories.
- `/var/lib/left4me/instances`: rendered instance specifications and per-instance state.
- `/var/lib/left4me/runtime`: per-instance runtime mount directories.
- `/var/lib/left4me/tmp`: temporary files used by deployment/runtime operations.
- `/usr/local/lib/systemd/system`: global systemd unit files, including `left4me-server@.service`.
- `/usr/local/libexec/left4me`: privileged helper commands, including `left4me-systemctl` and `left4me-journalctl`.
- `/etc/sudoers.d/left4me`: sudoers rules allowing the web/runtime commands to call the helpers non-interactively.
Static units are generated for `/var/lib/left4me`. If `LEFT4ME_ROOT` changes, regenerate and reinstall the unit files instead of reusing the existing static units.
## Runtime User
The deployment creates and runs host operations as the dedicated runtime user:
- Username: `left4me`
- Home: `/var/lib/left4me`
- Shell: `/usr/sbin/nologin`
## Running A Test Deployment
Run the deployment from the repository root:
```bash
deploy/deploy-test-server.sh deploy-user@example-host
```
The SSH user must be able to run `sudo` on the target host. The deployment configures system packages, directories, environment files, helper scripts, sudoers rules, Python dependencies, and systemd units.
## Admin Bootstrap
Set the bootstrap credentials in the environment when creating the first admin user:
```bash
LEFT4ME_ADMIN_USERNAME=admin \
LEFT4ME_ADMIN_PASSWORD='change-me' \
flask create-user "$LEFT4ME_ADMIN_USERNAME" --admin
```
Use a strong one-time password and rotate it after first login if needed.
## Overlay References
Overlay references are relative paths below `${LEFT4ME_ROOT}/overlays`. With the default deployment root, they resolve under `/var/lib/left4me/overlays`.
Valid examples:
- `standard`
- `competitive/base`
- `users/42/custom`
Invalid references are rejected:
- Absolute paths such as `/srv/overlay`.
- Parent traversal such as `../other` or `competitive/../../base`.
- Empty path components such as `competitive//base`.
- Symlink escapes that resolve outside `${LEFT4ME_ROOT}/overlays`.
Overlay content is external to the host library and deployment contract. Populate overlay directories separately before referencing them from blueprints or instance specs.

View file

@ -1,176 +0,0 @@
#!/bin/sh
set -eu
usage() {
printf 'Usage: %s <ssh-user@host>\n' "$0" >&2
exit 2
}
if [ "$#" -ne 1 ]; then
usage
fi
target=$1
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
tmp_dir=$(mktemp -d)
archive="$tmp_dir/left4me.tar.gz"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT INT HUP TERM
tar -czf "$archive" \
--exclude .git \
--exclude .venv \
--exclude __pycache__ \
--exclude .pytest_cache \
--exclude '*.egg-info' \
--exclude 'l4d2web.db*' \
-C "$repo_root" .
remote_tmp=$(ssh "$target" 'mktemp -d')
scp "$archive" "$target:$remote_tmp/left4me.tar.gz"
admin_username_file=
admin_password_file=
if [ "${LEFT4ME_ADMIN_USERNAME+x}" = x ] && [ "${LEFT4ME_ADMIN_PASSWORD+x}" = x ]; then
admin_username_file="$tmp_dir/admin_username"
admin_password_file="$tmp_dir/admin_password"
umask 077
printf '%s' "$LEFT4ME_ADMIN_USERNAME" > "$admin_username_file"
printf '%s' "$LEFT4ME_ADMIN_PASSWORD" > "$admin_password_file"
scp "$admin_username_file" "$target:$remote_tmp/admin_username"
scp "$admin_password_file" "$target:$remote_tmp/admin_password"
fi
ssh "$target" sh -s -- "$remote_tmp" <<'REMOTE'
set -eu
remote_tmp=$1
archive="$remote_tmp/left4me.tar.gz"
repo_tmp="$remote_tmp/repo"
if [ "$(id -u)" -eq 0 ]; then
sudo_cmd=
else
sudo_cmd=sudo
fi
run_as_left4me() {
sudo -u left4me "$@"
}
run_left4me_with_env() {
run_as_left4me sh -c 'set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; exec "$@"' sh "$@"
}
cleanup_remote() {
rm -rf "$remote_tmp"
}
trap cleanup_remote EXIT INT HUP TERM
if ! id left4me >/dev/null 2>&1; then
$sudo_cmd 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_cmd apt-get update
$sudo_cmd 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_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo
else
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
exit 1
fi
$sudo_cmd 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_cmd chown -R left4me:left4me /var/lib/left4me /opt/left4me
mkdir -p "$repo_tmp"
tar -xzf "$archive" -C "$repo_tmp"
if [ -d /opt/left4me/.venv ]; then
$sudo_cmd mv /opt/left4me/.venv "$remote_tmp/venv"
fi
$sudo_cmd find /opt/left4me -mindepth 1 -maxdepth 1 -exec rm -rf {} +
$sudo_cmd cp -R "$repo_tmp"/. /opt/left4me/
if [ -d "$remote_tmp/venv" ]; then
$sudo_cmd mv "$remote_tmp/venv" /opt/left4me/.venv
fi
$sudo_cmd chown -R left4me:left4me /opt/left4me
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl
$sudo_cmd cp /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
$sudo_cmd visudo -cf /etc/sudoers.d/left4me
$sudo_cmd cp /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env
$sudo_cmd chmod 0644 /etc/left4me/host.env
if [ ! -f /etc/left4me/web.env ]; then
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
tmp_web_env="$remote_tmp/web.env"
{
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
printf 'SECRET_KEY=%s\n' "$secret_key"
printf 'JOB_WORKER_THREADS=4\n'
} > "$tmp_web_env"
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
fi
if [ ! -x /opt/left4me/.venv/bin/python ]; then
run_as_left4me python3 -m venv /opt/left4me/.venv
fi
run_as_left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip
run_as_left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web
run_left4me_with_env env \
JOB_WORKER_ENABLED=false \
/opt/left4me/.venv/bin/python -c "from l4d2web.app import create_app; create_app()"
if [ -f "$remote_tmp/admin_username" ] && [ -f "$remote_tmp/admin_password" ]; then
LEFT4ME_ADMIN_USERNAME=$(cat "$remote_tmp/admin_username")
LEFT4ME_ADMIN_PASSWORD=$(cat "$remote_tmp/admin_password")
if ! create_user_output=$(run_left4me_with_env env \
JOB_WORKER_ENABLED=false \
LEFT4ME_ADMIN_PASSWORD="$LEFT4ME_ADMIN_PASSWORD" \
/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app create-user "$LEFT4ME_ADMIN_USERNAME" --admin 2>&1); then
case "$create_user_output" in
*'user already exists'*) printf '%s\n' "$create_user_output" ;;
*) printf '%s\n' "$create_user_output" >&2; exit 1 ;;
esac
else
printf '%s\n' "$create_user_output"
fi
fi
$sudo_cmd systemctl daemon-reload
$sudo_cmd systemctl enable --now left4me-web.service
$sudo_cmd systemctl restart left4me-web.service
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS http://127.0.0.1:8000/health; then
exit 0
fi
sleep 1
done
$sudo_cmd systemctl status left4me-web.service --no-pager >&2 || true
$sudo_cmd journalctl -u left4me-web.service -n 80 --no-pager >&2 || true
exit 1
REMOTE

View file

@ -1,3 +0,0 @@
Defaults:left4me !requiretty
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl *
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl *

View file

@ -1,27 +0,0 @@
[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

View file

@ -1,23 +0,0 @@
[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

View file

@ -1,53 +0,0 @@
#!/bin/sh
set -eu
usage() {
printf '%s\n' "usage: left4me-journalctl <server-name> --lines <n> --follow|--no-follow" >&2
exit 2
}
validate_name() {
name=$1
[ -n "$name" ] || usage
case "$name" in
.*|*..*|*/*|*\\*) usage ;;
esac
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
follow_arg=
case "$follow_flag" in
--follow) follow_arg=-f ;;
--no-follow) ;;
*) usage ;;
esac
unit="left4me-server@${name}.service"
if [ -x /bin/journalctl ]; then
journalctl=/bin/journalctl
elif [ -x /usr/bin/journalctl ]; then
journalctl=/usr/bin/journalctl
else
printf '%s\n' 'journalctl not found at /bin/journalctl or /usr/bin/journalctl' >&2
exit 69
fi
if [ -n "$follow_arg" ]; then
exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg"
fi
exec "$journalctl" -u "$unit" -n "$lines" -o cat

View file

@ -1,44 +0,0 @@
#!/bin/sh
set -eu
usage() {
printf '%s\n' "usage: left4me-systemctl start|stop|show <server-name>" >&2
exit 2
}
validate_name() {
name=$1
[ -n "$name" ] || usage
case "$name" in
.*|*..*|*/*|*\\*) usage ;;
esac
case "$name" in
*[!A-Za-z0-9_.-]*) usage ;;
esac
}
[ "$#" -eq 2 ] || usage
action=$1
name=$2
case "$action" in
start|stop|show) ;;
*) usage ;;
esac
validate_name "$name"
unit="left4me-server@${name}.service"
if [ -x /bin/systemctl ]; then
systemctl=/bin/systemctl
elif [ -x /usr/bin/systemctl ]; then
systemctl=/usr/bin/systemctl
else
printf '%s\n' 'systemctl not found at /bin/systemctl or /usr/bin/systemctl' >&2
exit 69
fi
case "$action" in
start) exec "$systemctl" start "$unit" ;;
stop) exec "$systemctl" stop "$unit" ;;
show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;;
esac

View file

@ -1,2 +0,0 @@
# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes.
LEFT4ME_ROOT=/var/lib/left4me

View file

@ -1,3 +0,0 @@
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=replace-with-generated-secret
JOB_WORKER_THREADS=4

View file

@ -1,186 +0,0 @@
import os
import subprocess
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"
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
DEPLOY_SCRIPT = DEPLOY / "deploy-test-server.sh"
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 "EnvironmentFile=/etc/left4me/host.env" in unit
assert "EnvironmentFile=/etc/left4me/web.env" in unit
assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in unit
assert "--workers 1" in unit
assert "NoNewPrivileges=true" in unit
assert "PrivateTmp=true" in unit
assert "ProtectSystem=full" in unit
assert "ReadWritePaths=/var/lib/left4me" 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
assert "WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2" in unit
assert "ExecStart=/var/lib/left4me/installation/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 _fake_command(tmp_path, command_name):
marker = tmp_path / f"{command_name}.args"
command = tmp_path / command_name
command.write_text(f"#!/bin/sh\nprintf '%s\n' \"$*\" > '{marker}'\nexit 0\n")
command.chmod(0o755)
return marker
def _env_with_fake_commands(tmp_path):
env = os.environ.copy()
env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}"
return env
def test_helpers_use_fixed_system_tool_paths_not_sudo_path():
systemctl = SYSTEMCTL_HELPER.read_text()
journalctl = JOURNALCTL_HELPER.read_text()
assert "command -v systemctl" not in systemctl
assert "command -v journalctl" not in journalctl
assert "/bin/systemctl" in systemctl or "/usr/bin/systemctl" in systemctl
assert "/bin/journalctl" in journalctl or "/usr/bin/journalctl" in journalctl
def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
subprocess.run(["sh", "-n", str(SYSTEMCTL_HELPER)], check=True)
marker = _fake_command(tmp_path, "systemctl")
for args in [
["bad/action", "alpha"],
["start", ""],
["start", ".hidden"],
["start", "bad..name"],
["start", "bad/name"],
["start", "bad\\name"],
["start", "bad name"],
]:
result = subprocess.run(["sh", str(SYSTEMCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False)
assert result.returncode != 0
assert not marker.exists()
script = SYSTEMCTL_HELPER.read_text()
assert 'unit="left4me-server@${name}.service"' in script
assert 'start) exec "$systemctl" start "$unit"' in script
assert 'stop) exec "$systemctl" stop "$unit"' in script
assert "--property=ActiveState" in script
assert "--property=SubState" in script
def test_journalctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
subprocess.run(["sh", "-n", str(JOURNALCTL_HELPER)], check=True)
marker = _fake_command(tmp_path, "journalctl")
for args in [
["../evil", "--lines", "25", "--no-follow"],
["alpha", "--bad", "25", "--no-follow"],
["alpha", "--lines", "not-number", "--no-follow"],
["alpha", "--lines", "25", "--bad-follow"],
["bad/name", "--lines", "25", "--no-follow"],
]:
result = subprocess.run(["sh", str(JOURNALCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False)
assert result.returncode != 0
assert not marker.exists()
script = JOURNALCTL_HELPER.read_text()
assert 'unit="left4me-server@${name}.service"' in script
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg"' in script
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat' in script
def test_sudoers_allows_only_left4me_helpers_not_raw_system_tools():
sudoers = SUDOERS.read_text()
assert (
"left4me ALL=(root) NOPASSWD: "
"/usr/local/libexec/left4me/left4me-systemctl *"
) in sudoers
assert (
"left4me ALL=(root) NOPASSWD: "
"/usr/local/libexec/left4me/left4me-journalctl *"
) in sudoers
assert "/bin/systemctl" not in sudoers
assert "/usr/bin/systemctl" not in sudoers
assert "/bin/journalctl" not in sudoers
assert "/usr/bin/journalctl" not in sudoers
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")
assert WEB_ENV_TEMPLATE.read_text() == (
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
"SECRET_KEY=replace-with-generated-secret\n"
"JOB_WORKER_THREADS=4\n"
)
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
script = DEPLOY_SCRIPT.read_text()
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 "--exclude .venv" in script
assert "pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web" in script
assert "systemctl enable --now left4me-web.service" in script
assert "for attempt in" in script
assert "/opt/left4me/.venv" in script
assert "visudo -cf /etc/sudoers.d/left4me" in script
assert "if [ ! -f /etc/left4me/web.env ]" in script
assert ". /etc/left4me/web.env\n" not in script
assert "run_left4me_with_env" in script
assert "LEFT4ME_ADMIN_USERNAME" in script
assert "LEFT4ME_ADMIN_PASSWORD" in script
assert "user already exists" in script
assert "deploy/files" in script
def test_deploy_script_shell_syntax() -> None:
subprocess.run(["sh", "-n", str(DEPLOY_SCRIPT)], check=True)

View file

@ -2,9 +2,9 @@
> **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. > **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:** Build a Python `l4d2` host library and `l4d2ctl` CLI with fixed write commands (`install`, `initialize`, `start`, `stop`, `delete`) plus read commands/APIs needed by the web app. **Goal:** Build a Python `l4d2` host library and `l4d2ctl` CLI with exactly five commands (`install`, `initialize`, `start`, `stop`, `delete`) plus read APIs needed by the local web app.
**Architecture:** Runtime paths are hard-coded under `/opt/l4d2`. Write operations are imperative and fail-fast with no lock manager, no rollback, and no preflight checks. CLI write behavior remains raw/stderr-first, and CLI read commands expose status/log output for the web/host boundary while library internals keep callback-based streaming and read APIs. **Architecture:** Runtime paths are hard-coded under `/opt/l4d2`. Write operations are imperative and fail-fast with no lock manager, no rollback, and no preflight checks. CLI behavior remains raw/stderr-first, while library internals additionally expose callback-based streaming and read APIs (`get_instance_status`, `stream_instance_logs`) for the web app.
**Tech Stack:** Python 3.12+, Typer, PyYAML, pytest, subprocess, systemd user units, fuse-overlayfs. **Tech Stack:** Python 3.12+, Typer, PyYAML, pytest, subprocess, systemd user units, fuse-overlayfs.
@ -12,7 +12,7 @@
## Scope and Contracts ## Scope and Contracts
- Write command surface is fixed in v1: - Command surface is fixed in v1:
- `l4d2ctl install` - `l4d2ctl install`
- `l4d2ctl initialize <name> -f <spec.yaml>` - `l4d2ctl initialize <name> -f <spec.yaml>`
- `l4d2ctl start <name>` - `l4d2ctl start <name>`
@ -32,10 +32,7 @@
- `initialize` always writes `server.cfg`; if `config` is empty/missing, `server.cfg` is empty. - `initialize` always writes `server.cfg`; if `config` is empty/missing, `server.cfg` is empty.
- `delete` is no-op success when instance/runtime directories are already missing. - `delete` is no-op success when instance/runtime directories are already missing.
- CLI errors: print raw subprocess stderr and exit with subprocess return code. - CLI errors: print raw subprocess stderr and exit with subprocess return code.
- Read commands are allowed for web/host boundary consistency: - Additional read APIs for web app (no extra CLI commands):
- `l4d2ctl status <name> --json`
- `l4d2ctl logs <name> --lines <n> --follow/--no-follow`
- Additional host-local read APIs:
- `get_instance_status(name)` - `get_instance_status(name)`
- `stream_instance_logs(name, lines=200, follow=True)` - `stream_instance_logs(name, lines=200, follow=True)`
- Blueprints are intentionally out of scope for this library; callers must resolve any blueprint linkage to a concrete YAML spec before calling `initialize`. - Blueprints are intentionally out of scope for this library; callers must resolve any blueprint linkage to a concrete YAML spec before calling `initialize`.
@ -737,7 +734,7 @@ git commit -m "docs(l4d2): finalize v1 CLI contracts and web-facing read APIs"
## Self-Review ## Self-Review
- [ ] Spec coverage: write command surface fixed, read commands allowed, hard-coded paths, config semantics, delete no-op, callback streaming, read APIs. - [ ] Spec coverage: command surface fixed, hard-coded paths, config semantics, delete no-op, callback streaming, read APIs.
- [ ] Placeholder scan: no TODO/TBD placeholders. - [ ] Placeholder scan: no TODO/TBD placeholders.
- [ ] Consistency: argument names (`on_stdout`, `on_stderr`, `passthrough`) are consistent across tasks. - [ ] Consistency: argument names (`on_stdout`, `on_stderr`, `passthrough`) are consistent across tasks.
- [ ] Verification: each task contains exact test commands and expected outcomes. - [ ] Verification: each task contains exact test commands and expected outcomes.

View file

@ -4,7 +4,7 @@
**Goal:** Build a local Flask web app where users create blueprints and manage L4D2 servers derived from those blueprints, with async lifecycle jobs and live logs. **Goal:** Build a local Flask web app where users create blueprints and manage L4D2 servers derived from those blueprints, with async lifecycle jobs and live logs.
**Architecture:** Run a single Flask process with Jinja templates, vendored HTMX, custom CSS, and in-process worker threads. Persist app state in SQLite with Rails-style foreign-key naming (`user_id`, `server_id`, `blueprint_id`, `overlay_id`, `job_id`). Integrate with host operations through `l4d2ctl` via a host command client: jobs call `install/initialize/start/stop/delete` with output callbacks, while status/logs use `status --json` and `logs` CLI read commands. **Architecture:** Run a single Flask process with Jinja templates, vendored HTMX, custom CSS, and in-process worker threads. Persist app state in SQLite with Rails-style foreign-key naming (`user_id`, `server_id`, `blueprint_id`, `overlay_id`, `job_id`). Integrate directly with `l4d2host` write/read APIs: jobs call `install/initialize/start/stop/delete` with output callbacks, while status/logs use `get_instance_status` and `stream_instance_logs`.
**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, Alembic, pytest, vendored HTMX, custom CSS, vanilla JS (SSE). **Tech Stack:** Python 3.12+, Flask, SQLAlchemy, Alembic, pytest, vendored HTMX, custom CSS, vanilla JS (SSE).
@ -505,7 +505,7 @@ git add l4d2web/routes/server_routes.py l4d2web/tests/test_servers.py
git commit -m "feat(l4d2-web): add server creation and blueprint reassignment routes" git commit -m "feat(l4d2-web): add server creation and blueprint reassignment routes"
``` ```
### Task 7: Add `l4d2ctl` facade and blueprint-to-spec generation ### Task 7: Add direct `l4d2host` facade and blueprint-to-spec generation
**Files:** **Files:**
- Create: `l4d2web/services/spec_yaml.py` - Create: `l4d2web/services/spec_yaml.py`

View file

@ -297,7 +297,7 @@ Report:
Task 3 evidence: Task 3 evidence:
- archive creation/copy: report local archive path and remote unpack path - archive creation/copy: report local archive path and remote unpack path
- venv/pip install: report virtualenv path and pip install status - venv/pip install: report virtualenv path and pip install status
- l4d2ctl command surface: report write commands plus status/log read commands found in help output - l4d2ctl command surface: report the five commands found in help output
Approve Task 4: run l4d2ctl install on ckn@10.0.4.128? Approve Task 4: run l4d2ctl install on ckn@10.0.4.128?
``` ```

View file

@ -1,66 +0,0 @@
# L4D2 CLI Host Client 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:** Make `l4d2web` manage the local host through `l4d2ctl` instead of importing `l4d2host` internals, so the same execution boundary can later be transported over SSH.
**Architecture:** `l4d2host` remains the host-local implementation behind `l4d2ctl`. `l4d2web` gains a small local command runner that streams CLI stdout/stderr into jobs, supports cancellation, and parses status JSON. Hosts and overlay sync remain out of this change; the current machine is the implicit local host.
**Tech Stack:** Python 3.12+, Typer, subprocess, Flask, SQLAlchemy, pytest.
---
## File Map
- `l4d2host/cli.py`: add read commands for status and logs.
- `l4d2host/tests/test_cli.py`: cover the expanded CLI contract.
- `l4d2web/services/host_commands.py`: new subprocess-based host command runner and cancellation exception.
- `l4d2web/services/l4d2_facade.py`: call `l4d2ctl` through `host_commands` instead of importing `l4d2host` internals.
- `l4d2web/services/job_worker.py`: catch the web-side cancellation exception.
- `l4d2web/tests/test_host_commands.py`: cover callback streaming, failures, and cancellation.
- `l4d2web/tests/test_l4d2_facade.py`: verify facade emits CLI commands and parses status.
- `l4d2web/tests/test_job_worker.py`: update cancellation imports.
- `l4d2host/README.md`, `l4d2web/README.md`, existing implementation plans: document the relaxed CLI boundary.
## Tasks
### Task 1: Add host CLI read commands
- [x] Write failing tests for `l4d2ctl status <name> --json` and `l4d2ctl logs <name> --no-follow`.
- [x] Run `pytest l4d2host/tests/test_cli.py -q` and confirm the new tests fail because commands do not exist.
- [x] Add the `status` and `logs` commands to `l4d2host/cli.py` using existing `get_instance_status` and `stream_instance_logs` APIs.
- [x] Run `pytest l4d2host/tests/test_cli.py -q` and confirm it passes.
### Task 2: Add web host command runner
- [x] Write failing tests for streaming stdout/stderr callbacks, non-zero exit propagation, and cancellation.
- [x] Run `pytest l4d2web/tests/test_host_commands.py -q` and confirm failures are for the missing module.
- [x] Implement `l4d2web/services/host_commands.py` with `run_command`, `HostCommandError`, and `CommandCancelledError`.
- [x] Run `pytest l4d2web/tests/test_host_commands.py -q` and confirm it passes.
### Task 3: Switch web facade to CLI calls
- [x] Update facade tests so they monkeypatch `host_commands.run_command` and assert emitted `l4d2ctl` commands.
- [x] Run `pytest l4d2web/tests/test_l4d2_facade.py -q` and confirm failures show the facade still imports/calls `l4d2host` internals.
- [x] Replace direct `l4d2host` imports in `l4d2web/services/l4d2_facade.py` with CLI command calls.
- [x] Run `pytest l4d2web/tests/test_l4d2_facade.py -q` and confirm it passes.
### Task 4: Update worker cancellation boundary
- [x] Update job worker tests to import `CommandCancelledError` from `l4d2web.services.host_commands`.
- [x] Run `pytest l4d2web/tests/test_job_worker.py -q` and confirm failures identify the old boundary.
- [x] Update `l4d2web/services/job_worker.py` to catch the web-side cancellation exception.
- [x] Run `pytest l4d2web/tests/test_job_worker.py -q` and confirm it passes.
### Task 5: Update docs and verify
- [x] Update README/plan language from “fixed write commands only” to “fixed write commands plus read commands”.
- [x] Run `pytest l4d2host/tests -q` and confirm pass.
- [x] Run `pytest l4d2web/tests -q` and confirm pass.
- [x] Run `ccc index` if available so the code index reflects the boundary change.
## Self-Review
- Spec coverage: covers CLI read commands, web-side CLI execution, status/log parsing, cancellation, docs, and verification.
- Scope: hosts table, SSH transport, and overlay sync are explicitly excluded from this change.
- Type consistency: the web-side cancellation type is `l4d2web.services.host_commands.CommandCancelledError`; the host-side process type remains internal to `l4d2host`.

View file

@ -2,7 +2,7 @@
> **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. > **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.
**Goal:** Complete the `l4d2web` async lifecycle queue so queued jobs are claimed, executed through the `l4d2ctl` host command boundary, logged to `job_logs`, reflected in server state, and streamed live to the UI. **Goal:** Complete the `l4d2web` async lifecycle queue so queued jobs are claimed, executed through the direct `l4d2host` Python APIs, logged to `job_logs`, reflected in server state, and streamed live to the UI.
**Architecture:** Keep the v1 single-process Flask architecture. Use DB-backed queued jobs as the durable source of truth, worker threads inside the Flask process, SQLite-safe process-local locks, and direct imports through `l4d2web.services.l4d2_facade`. Do not shell out to `l4d2ctl` from the web app. **Architecture:** Keep the v1 single-process Flask architecture. Use DB-backed queued jobs as the durable source of truth, worker threads inside the Flask process, SQLite-safe process-local locks, and direct imports through `l4d2web.services.l4d2_facade`. Do not shell out to `l4d2ctl` from the web app.

File diff suppressed because it is too large Load diff

View file

@ -1,244 +0,0 @@
# Left4me Deployment Design
## Goal
Provide a production-like test deployment for `left4me` on a real Linux server. The deployment should be quick to run from the local working tree, while keeping the runtime layout, service ownership, and hardening model close enough to the intended production setup that problems appear early.
## Context
`left4me` has two components:
- `l4d2host`, exposed through `l4d2ctl`, manages L4D2 installation, per-server initialization, lifecycle, status, and logs.
- `l4d2web` is the Flask web UI and worker process. It calls host operations through `l4d2ctl` rather than importing host internals.
The existing code uses hard-coded `/opt/l4d2` paths and user systemd units. The deployment design replaces that with a product-level root, root-owned global units, and constrained helper commands so per-server hardening is owned by the host setup rather than by the runtime user.
## Runtime User
The deployment uses one runtime user:
```text
user: left4me
home: /var/lib/left4me
shell: /usr/sbin/nologin
```
The SSH deployment user is separate and must have sudo privileges. The `left4me` user owns runtime state and runs the web app and game servers, but does not own systemd unit definitions, sudoers rules, or helper installation paths.
## Filesystem Layout
The deployed server uses these paths:
```text
/etc/left4me/
host.env
web.env
/opt/left4me/
.venv/
<repository contents>
/var/lib/left4me/
left4me.db
installation/
overlays/
instances/
runtime/
tmp/
/usr/local/lib/systemd/system/
left4me-web.service
left4me-server@.service
/usr/local/libexec/left4me/
left4me-systemctl
left4me-journalctl
/etc/sudoers.d/
left4me
```
`/var/lib/left4me` is both the `left4me` home directory and the product state root. This keeps mutable state in one place and avoids mixing runtime data with deployed code.
## Configuration
Configuration is split by component boundary:
`/etc/left4me/host.env`:
```text
LEFT4ME_ROOT=/var/lib/left4me
```
`/etc/left4me/web.env`:
```text
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=<generated-or-managed-secret>
JOB_WORKER_THREADS=4
```
`LEFT4ME_ROOT` replaces the previous host-library hard-coded `/opt/l4d2` root. Host paths derive from it:
```text
${LEFT4ME_ROOT}/installation
${LEFT4ME_ROOT}/overlays
${LEFT4ME_ROOT}/instances
${LEFT4ME_ROOT}/runtime
${LEFT4ME_ROOT}/tmp
```
The test deployment script can generate `web.env` when missing. Production config management can own both env files directly.
## Systemd Model
Both web and game servers use global root-owned systemd units under `/usr/local/lib/systemd/system`.
`left4me-web.service`:
- Runs as `User=left4me`.
- Loads `/etc/left4me/host.env` and `/etc/left4me/web.env`.
- Uses `/opt/left4me` as its working directory.
- Starts the web app from `/opt/left4me/.venv`.
- Uses one process worker because lifecycle jobs run in-process.
- Uses moderate systemd hardening, but must still allow the web worker to call `l4d2ctl`, write SQLite state, and run host lifecycle commands.
`left4me-server@.service`:
- Runs each game server as `User=left4me`.
- Reads generated per-server environment from `${LEFT4ME_ROOT}/instances/%i/instance.env`.
- Uses `${LEFT4ME_ROOT}/runtime/%i/merged/left4dead2` as `WorkingDirectory`.
- Starts `${LEFT4ME_ROOT}/installation/srcds_run`.
- Applies stronger hardening than the web unit because each game server is the more important sandbox boundary.
The server unit template is root-owned. A compromised `left4me` process should not be able to edit the template to weaken future server sandboxing.
## Privileged Helper Model
`l4d2ctl` should not call arbitrary `sudo systemctl` or `sudo journalctl` commands. Instead it calls constrained helpers:
```text
sudo -n /usr/local/libexec/left4me/left4me-systemctl start <server-name>
sudo -n /usr/local/libexec/left4me/left4me-systemctl stop <server-name>
sudo -n /usr/local/libexec/left4me/left4me-systemctl show <server-name>
sudo -n /usr/local/libexec/left4me/left4me-journalctl <server-name> --lines <n> --follow|--no-follow
```
The helpers validate the action and server name, then map the server name to `left4me-server@<server-name>.service`. The sudoers rule only allows the `left4me` user to run these helpers as root.
## Host Library Contract Changes
The host library changes from:
```text
/opt/l4d2/installation
/opt/l4d2/overlays
/opt/l4d2/instances
/opt/l4d2/runtime
systemd --user units
```
to:
```text
${LEFT4ME_ROOT}/installation
${LEFT4ME_ROOT}/overlays
${LEFT4ME_ROOT}/instances
${LEFT4ME_ROOT}/runtime
global left4me-server@.service managed through sudo helpers
```
The host library remains fail-fast and prerequisite-light. It assumes deployment or config management installed OS packages, created directories, installed units, and configured sudoers.
## Overlay References
Overlay configuration should store safe relative refs under `${LEFT4ME_ROOT}/overlays`, not absolute paths.
Valid examples:
```text
standard
competitive/base
users/42/custom
```
Rejected examples:
```text
/tmp/bad
../bad
bad/../evil
bad//evil
```
This supports a flat overlay catalog now and leaves room for user-managed overlays later through namespaced refs such as `users/<user-id>/<overlay-name>`.
## Deployment Script
The test deployment script lives in `deploy/deploy-test-server.sh`. It runs locally and takes an SSH target for a sudo-capable deployment user.
The script should:
- Archive the current local working tree.
- Copy it to the remote host.
- Create the `left4me` system user if missing.
- Install OS prerequisites when a supported package manager is detected.
- Create `/etc/left4me`, `/opt/left4me`, `/var/lib/left4me`, `/usr/local/libexec/left4me`, and `/usr/local/lib/systemd/system` as needed.
- Preserve `/var/lib/left4me` across redeployments.
- Replace repository contents under `/opt/left4me` while preserving `/opt/left4me/.venv`.
- Install deployment units, helper scripts, sudoers rules, and env templates.
- Create or update `/opt/left4me/.venv`.
- Install `l4d2host` and `l4d2web` from the deployed local source.
- Initialize the SQLite schema by importing/creating the Flask app.
- Optionally bootstrap an admin user from environment variables.
- Reload systemd and restart `left4me-web.service`.
- Verify `/health` locally on the server.
The script is a test-server convenience, not a replacement for production config management. Production can implement the same layout with stronger package/version control.
## Documentation Structure
Local deployment assets live under:
```text
deploy/
README.md
deploy-test-server.sh
files/
templates/
tests/
```
`deploy/files` mirrors target filesystem paths for root-owned deployment artifacts. `deploy/templates` contains env templates that may need generated secrets. `deploy/README.md` is the operator-facing guide.
Component documentation should explain only component-specific deployment contracts:
- `l4d2host/README.md`: `LEFT4ME_ROOT`, global service helpers, and overlay refs.
- `l4d2web/README.md`: env vars, admin bootstrap, and systemd service expectations.
- Root `README.md`: link to `deploy/README.md`.
## Verification
Local verification before deploying:
```bash
pytest l4d2host/tests -q
pytest l4d2web/tests -q
pytest deploy/tests -q
```
Deployment verification on the target server should include:
- `systemctl status left4me-web.service --no-pager`
- `curl http://127.0.0.1:8000/health`
- `sudo -u left4me /opt/left4me/.venv/bin/l4d2ctl --help`
- helper validation for accepted and rejected server names
- a later gated smoke test for `install`, `initialize`, `start`, `status`, `logs`, `stop`, and `delete`
## Out Of Scope
- Running deployment against a real server as part of this design.
- Replacing production config management.
- Managing overlay file contents through the web UI.
- Adding multi-host SSH transport to the web app.
- Supporting multiple game types beyond the existing L4D2 host workflow.

View file

@ -4,7 +4,7 @@ Python host library and CLI for managing L4D2 instances.
## CLI ## CLI
`l4d2ctl` exposes these write commands in v1: `l4d2ctl` exposes exactly these commands in v1:
- `install` - `install`
- `initialize <name> -f <spec.yaml>` - `initialize <name> -f <spec.yaml>`
@ -12,33 +12,16 @@ Python host library and CLI for managing L4D2 instances.
- `stop <name>` - `stop <name>`
- `delete <name>` - `delete <name>`
It also exposes read commands used by the web app host boundary:
- `status <name> --json`
- `logs <name> --lines <n> --follow/--no-follow`
Subprocess failures are fail-fast. Raw stderr is written to stderr and the command exits with the same subprocess return code. Subprocess failures are fail-fast. Raw stderr is written to stderr and the command exits with the same subprocess return code.
## Runtime Paths ## Runtime Paths
The host library reads `LEFT4ME_ROOT` from the environment. It defaults to `/var/lib/left4me`: The host library uses hard-coded runtime paths under `/opt/l4d2`:
- `${LEFT4ME_ROOT}/installation` - `/opt/l4d2/installation`
- `${LEFT4ME_ROOT}/overlays/<overlay-ref>` - `/opt/l4d2/overlays/<overlay>`
- `${LEFT4ME_ROOT}/instances/<name>` - `/opt/l4d2/instances/<name>`
- `${LEFT4ME_ROOT}/runtime/<name>/{upper,work,merged}` - `/opt/l4d2/runtime/<name>/{upper,work,merged}`
- `${LEFT4ME_ROOT}/tmp`
Overlay specs use relative refs below `${LEFT4ME_ROOT}/overlays`, for example `standard`, `competitive/base`, or `users/42/custom`. Absolute refs, `..`, empty path components, and symlink escapes outside the overlays root are rejected.
## systemd Integration
`l4d2ctl start`, `stop`, `status`, and `logs` use non-interactive sudo helper commands:
- `sudo -n /usr/local/libexec/left4me/left4me-systemctl ...`
- `sudo -n /usr/local/libexec/left4me/left4me-journalctl ...`
Deployment/config management owns the global `left4me-server@.service` unit under `/usr/local/lib/systemd/system`. The host library does not install or manage the unit file directly.
## Host Prerequisites ## Host Prerequisites
@ -52,7 +35,7 @@ Validated on Debian 13 during the `ckn@10.0.4.128` smoke test:
- `fuse-overlayfs` and `fusermount3` for per-instance overlay mounts. - `fuse-overlayfs` and `fusermount3` for per-instance overlay mounts.
- `systemctl --user` and `journalctl --user` available for the runtime user. - `systemctl --user` and `journalctl --user` available for the runtime user.
- User lingering enabled when services must survive SSH sessions: `sudo loginctl enable-linger <user>`. - User lingering enabled when services must survive SSH sessions: `sudo loginctl enable-linger <user>`.
- `/var/lib/left4me` created and writable by the runtime user, unless `LEFT4ME_ROOT` is set to another deployment-managed root. - `/opt/l4d2` created and writable by the runtime user.
Example Debian setup: Example Debian setup:
@ -64,8 +47,8 @@ sudo apt-get install -y \
fuse-overlayfs fuse3 \ fuse-overlayfs fuse3 \
libc6-i386 lib32gcc-s1 lib32stdc++6 libc6-i386 lib32gcc-s1 lib32stdc++6
sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp} sudo mkdir -p /opt/steamcmd /opt/l4d2/{installation,overlays,instances,runtime}
sudo chown -R "$USER:$USER" /opt/steamcmd /var/lib/left4me sudo chown -R "$USER:$USER" /opt/steamcmd /opt/l4d2
sudo loginctl enable-linger "$USER" sudo loginctl enable-linger "$USER"
``` ```
@ -85,9 +68,9 @@ steamcmd +quit
`uv` is optional deployment tooling. Debian 13 did not provide an `uv` package during the smoke test, so install it explicitly if you want to use it for faster virtualenv/dependency setup. `l4d2ctl` does not require `uv` at runtime. `uv` is optional deployment tooling. Debian 13 did not provide an `uv` package during the smoke test, so install it explicitly if you want to use it for faster virtualenv/dependency setup. `l4d2ctl` does not require `uv` at runtime.
## Host-Local Read APIs ## Web App Read APIs
These Python read APIs back the CLI read commands and remain available for host-local callers: These read APIs are provided for web app integration:
- `get_instance_status(name)` - `get_instance_status(name)`
- `stream_instance_logs(name, lines=200, follow=True)` - `stream_instance_logs(name, lines=200, follow=True)`

View file

@ -1,12 +1,9 @@
from pathlib import Path from pathlib import Path
import json
import subprocess import subprocess
import typer import typer
from l4d2host.instances import delete_instance, initialize_instance, start_instance, stop_instance from l4d2host.instances import delete_instance, initialize_instance, start_instance, stop_instance
from l4d2host.logs import stream_instance_logs
from l4d2host.status import get_instance_status
from l4d2host.steam_install import SteamInstaller from l4d2host.steam_install import SteamInstaller
@ -57,33 +54,3 @@ def delete(name: str) -> None:
delete_instance(name, passthrough=True) delete_instance(name, passthrough=True)
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
_exit_from_subprocess_error(exc) _exit_from_subprocess_error(exc)
@app.command()
def status(name: str, json_output: bool = typer.Option(False, "--json")) -> None:
instance_status = get_instance_status(name)
if json_output:
typer.echo(
json.dumps(
{
"state": instance_status.state,
"raw_active_state": instance_status.raw_active_state,
"raw_sub_state": instance_status.raw_sub_state,
}
)
)
return
typer.echo(instance_status.state)
@app.command()
def logs(
name: str,
lines: int = typer.Option(200, "--lines"),
follow: bool = typer.Option(True, "--follow/--no-follow"),
) -> None:
try:
for line in stream_instance_logs(name, lines=lines, follow=follow):
typer.echo(line)
except subprocess.CalledProcessError as exc:
_exit_from_subprocess_error(exc)

View file

@ -2,26 +2,24 @@ from pathlib import Path
import shutil import shutil
from typing import Callable from typing import Callable
from l4d2host.paths import DEFAULT_LEFT4ME_ROOT, get_left4me_root, overlay_path
from l4d2host.process import run_command from l4d2host.process import run_command
from l4d2host.service_control import start_service, stop_service
from l4d2host.spec import load_spec from l4d2host.spec import load_spec
from l4d2host.systemd_user import daemon_reload, ensure_template_unit
DEFAULT_ROOT = DEFAULT_LEFT4ME_ROOT DEFAULT_ROOT = Path("/opt/l4d2")
def initialize_instance( def initialize_instance(
name: str, name: str,
spec_path: Path, spec_path: Path,
*, *,
root: Path | None = None, root: Path = DEFAULT_ROOT,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False, passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> None: ) -> None:
root = get_left4me_root() if root is None else Path(root)
spec = load_spec(spec_path) spec = load_spec(spec_path)
instance_dir = root / "instances" / name instance_dir = root / "instances" / name
@ -31,7 +29,7 @@ def initialize_instance(
(runtime_dir / "merged").mkdir(parents=True, exist_ok=True) (runtime_dir / "merged").mkdir(parents=True, exist_ok=True)
instance_dir.mkdir(parents=True, exist_ok=True) instance_dir.mkdir(parents=True, exist_ok=True)
lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays] lowerdirs = [str(root / "overlays" / overlay) for overlay in spec.overlays]
lowerdirs.append(str(root / "installation")) lowerdirs.append(str(root / "installation"))
instance_env = "\n".join( instance_env = "\n".join(
@ -46,6 +44,10 @@ def initialize_instance(
server_cfg = "\n".join(spec.config) if spec.config else "" server_cfg = "\n".join(spec.config) if spec.config else ""
(instance_dir / "server.cfg").write_text(server_cfg) (instance_dir / "server.cfg").write_text(server_cfg)
if root.resolve() == DEFAULT_ROOT:
ensure_template_unit()
daemon_reload(on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel)
def _load_instance_env(path: Path) -> dict[str, str]: def _load_instance_env(path: Path) -> dict[str, str]:
result: dict[str, str] = {} result: dict[str, str] = {}
@ -60,13 +62,12 @@ def _load_instance_env(path: Path) -> dict[str, str]:
def start_instance( def start_instance(
name: str, name: str,
*, *,
root: Path | None = None, root: Path = DEFAULT_ROOT,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False, passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> None: ) -> None:
root = get_left4me_root() if root is None else Path(root)
instance_dir = root / "instances" / name instance_dir = root / "instances" / name
runtime_dir = root / "runtime" / name runtime_dir = root / "runtime" / name
@ -93,8 +94,8 @@ def start_instance(
target_cfg.parent.mkdir(parents=True, exist_ok=True) target_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(instance_dir / "server.cfg", target_cfg) shutil.copy2(instance_dir / "server.cfg", target_cfg)
start_service( run_command(
name, ["systemctl", "--user", "start", f"l4d2@{name}.service"],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
passthrough=passthrough, passthrough=passthrough,
@ -105,15 +106,14 @@ def start_instance(
def stop_instance( def stop_instance(
name: str, name: str,
*, *,
root: Path | None = None, root: Path = DEFAULT_ROOT,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False, passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> None: ) -> None:
root = get_left4me_root() if root is None else Path(root) run_command(
stop_service( ["systemctl", "--user", "stop", f"l4d2@{name}.service"],
name,
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
passthrough=passthrough, passthrough=passthrough,
@ -131,21 +131,20 @@ def stop_instance(
def delete_instance( def delete_instance(
name: str, name: str,
*, *,
root: Path | None = None, root: Path = DEFAULT_ROOT,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False, passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> None: ) -> None:
root = get_left4me_root() if root is None else Path(root)
instance_dir = root / "instances" / name instance_dir = root / "instances" / name
runtime_dir = root / "runtime" / name runtime_dir = root / "runtime" / name
if not instance_dir.exists() and not runtime_dir.exists(): if not instance_dir.exists() and not runtime_dir.exists():
return return
stop_service( run_command(
name, ["systemctl", "--user", "stop", f"l4d2@{name}.service"],
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
passthrough=passthrough, passthrough=passthrough,

View file

@ -1,7 +1,34 @@
import subprocess
from typing import Iterator from typing import Iterator
from l4d2host.service_control import stream_journal
def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
yield from stream_journal(name, lines=lines, follow=follow) command = [
"journalctl",
"--user",
"-u",
f"l4d2@{name}.service",
"-n",
str(lines),
"-o",
"cat",
]
if follow:
command.append("-f")
proc = subprocess.Popen(
command,
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)

View file

@ -1,50 +0,0 @@
import os
from pathlib import Path
DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me")
def get_left4me_root() -> Path:
raw = os.environ.get("LEFT4ME_ROOT")
if raw is None:
return DEFAULT_LEFT4ME_ROOT
root = raw.strip()
if not root:
raise ValueError("LEFT4ME_ROOT must not be empty")
if root != raw:
raise ValueError("LEFT4ME_ROOT must not contain leading or trailing whitespace")
path = Path(root)
if not path.is_absolute():
raise ValueError("LEFT4ME_ROOT must be absolute")
return path
def validate_overlay_ref(ref: str) -> str:
stripped = ref.strip()
if stripped != ref:
raise ValueError("overlay ref must not contain leading or trailing whitespace")
if stripped in {"", ".", ".."}:
raise ValueError("overlay ref must not be empty or current/parent directory")
if Path(stripped).is_absolute():
raise ValueError("overlay ref must be relative")
components = stripped.split("/")
if any(component in {"", ".", ".."} for component in components):
raise ValueError("overlay ref must not contain empty, current, or parent components")
return stripped
def overlay_path(ref: str, *, root: Path | None = None) -> Path:
safe_ref = validate_overlay_ref(ref)
left4me_root = get_left4me_root() if root is None else Path(root)
overlays_root = left4me_root / "overlays"
candidate = overlays_root / safe_ref
resolved_overlays_root = overlays_root.resolve()
resolved_candidate = candidate.resolve()
if resolved_candidate != resolved_overlays_root and resolved_overlays_root not in resolved_candidate.parents:
raise ValueError("overlay path escapes overlay root")
return candidate

View file

@ -17,7 +17,10 @@ dependencies = [
l4d2ctl = "l4d2host.cli:app" l4d2ctl = "l4d2host.cli:app"
[tool.setuptools] [tool.setuptools]
packages = ["l4d2host", "l4d2host.fs"] packages = ["l4d2host", "l4d2host.fs", "l4d2host.templates"]
[tool.setuptools.package-dir] [tool.setuptools.package-dir]
l4d2host = "." l4d2host = "."
[tool.setuptools.package-data]
"l4d2host.templates" = ["*.service"]

View file

@ -1,85 +0,0 @@
import subprocess
from typing import Callable, Iterator, Sequence
from l4d2host.process import CommandResult, 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]:
follow_arg = "--follow" if follow else "--no-follow"
return ["sudo", "-n", JOURNALCTL_HELPER, name, "--lines", str(lines), follow_arg]
def start_service(
name: str,
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
) -> CommandResult:
return run_command(
systemctl_command("start", name),
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
should_cancel=should_cancel,
)
def stop_service(
name: str,
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
) -> CommandResult:
return run_command(
systemctl_command("stop", name),
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
should_cancel=should_cancel,
)
def show_service(name: str) -> CommandResult:
return run_command(systemctl_command("show", name))
def stream_command(cmd: Sequence[str]) -> Iterator[str]:
command = list(cmd)
proc = subprocess.Popen(
command,
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")
returncode = proc.wait()
if returncode is None:
returncode = proc.poll()
stderr = proc.stderr.read() if proc.stderr is not None else ""
if returncode:
raise subprocess.CalledProcessError(returncode=returncode, cmd=command, stderr=stderr)
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))

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
import subprocess import subprocess
from l4d2host.service_control import show_service from l4d2host.process import run_command
@dataclass(slots=True) @dataclass(slots=True)
@ -21,7 +21,17 @@ def map_active_state(active_state: str) -> str:
def get_instance_status(name: str) -> InstanceStatus: def get_instance_status(name: str) -> InstanceStatus:
try: try:
result = show_service(name) result = run_command(
[
"systemctl",
"--user",
"show",
f"l4d2@{name}.service",
"--property=ActiveState",
"--property=SubState",
"--no-pager",
]
)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
return InstanceStatus( return InstanceStatus(
state="unknown", state="unknown",

View file

@ -1,13 +1,12 @@
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from l4d2host.paths import get_left4me_root
from l4d2host.process import run_command from l4d2host.process import run_command
class SteamInstaller: class SteamInstaller:
def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"): def __init__(self, install_dir: Path = Path("/opt/l4d2/installation"), steamcmd: str = "steamcmd"):
self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir) self.install_dir = install_dir
self.steamcmd = steamcmd self.steamcmd = steamcmd
def install_or_update( def install_or_update(

31
l4d2host/systemd_user.py Normal file
View file

@ -0,0 +1,31 @@
from importlib import resources
from pathlib import Path
from typing import Callable
from l4d2host.process import run_command
def ensure_template_unit(target_dir: Path | None = None) -> Path:
if target_dir is None:
target_dir = Path.home() / ".config/systemd/user"
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / "l4d2@.service"
body = resources.files("l4d2host.templates").joinpath("l4d2@.service").read_text()
target_file.write_text(body)
return target_file
def daemon_reload(
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
) -> None:
run_command(
["systemctl", "--user", "daemon-reload"],
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
should_cancel=should_cancel,
)

View file

@ -0,0 +1,14 @@
[Unit]
Description=L4D2 dedicated server instance %i
After=network.target
[Service]
Type=simple
EnvironmentFile=/opt/l4d2/instances/%i/instance.env
WorkingDirectory=/opt/l4d2/runtime/%i/merged/left4dead2
ExecStart=/opt/l4d2/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} ${L4D2_ARGS}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View file

@ -1,6 +1,4 @@
import subprocess import subprocess
from types import SimpleNamespace
import json
from typer.testing import CliRunner from typer.testing import CliRunner
@ -26,47 +24,3 @@ def test_cli_propagates_subprocess_return_code(monkeypatch) -> None:
assert result.exit_code == 9 assert result.exit_code == 9
assert "boom" in result.stderr assert "boom" in result.stderr
def test_status_command_outputs_json(monkeypatch) -> None:
monkeypatch.setattr(
"l4d2host.cli.get_instance_status",
lambda name: SimpleNamespace(state="running", raw_active_state="active", raw_sub_state="running"),
raising=False,
)
result = CliRunner().invoke(app, ["status", "alpha", "--json"])
assert result.exit_code == 0
assert json.loads(result.output) == {
"state": "running",
"raw_active_state": "active",
"raw_sub_state": "running",
}
def test_logs_command_streams_lines(monkeypatch) -> None:
monkeypatch.setattr(
"l4d2host.cli.stream_instance_logs",
lambda name, *, lines, follow: iter([f"{name}:{lines}:{follow}", "ready"]),
raising=False,
)
result = CliRunner().invoke(app, ["logs", "alpha", "--lines", "25", "--no-follow"])
assert result.exit_code == 0
assert result.output.splitlines() == ["alpha:25:False", "ready"]
def test_logs_command_propagates_subprocess_return_code(monkeypatch) -> None:
def fail_logs(*args, **kwargs):
del args
del kwargs
raise subprocess.CalledProcessError(returncode=7, cmd=["logs"], stderr="sudo denied")
monkeypatch.setattr("l4d2host.cli.stream_instance_logs", fail_logs, raising=False)
result = CliRunner().invoke(app, ["logs", "alpha", "--no-follow"])
assert result.exit_code == 7
assert "sudo denied" in result.stderr

View file

@ -20,14 +20,3 @@ def test_empty_config_writes_empty_server_cfg(tmp_path: Path) -> None:
initialize_instance("alpha", spec, root=tmp_path) initialize_instance("alpha", spec, root=tmp_path)
assert (tmp_path / "instances/alpha/server.cfg").read_text() == "" assert (tmp_path / "instances/alpha/server.cfg").read_text() == ""
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

View file

@ -33,13 +33,3 @@ def test_fail_fast_on_first_failure(monkeypatch: pytest.MonkeyPatch) -> None:
SteamInstaller().install_or_update() SteamInstaller().install_or_update()
assert len(calls) == 1 assert len(calls) == 1
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]

View file

@ -22,12 +22,11 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
(instance_dir / "server.cfg").write_text("sv_consistency 1") (instance_dir / "server.cfg").write_text("sv_consistency 1")
monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command)
monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)
start_instance("alpha", root=tmp_path) start_instance("alpha", root=tmp_path)
assert calls[0][0] == "fuse-overlayfs" assert calls[0][0] == "fuse-overlayfs"
assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"] assert calls[1][:3] == ["systemctl", "--user", "start"]
def test_delete_missing_is_noop(tmp_path: Path) -> None: def test_delete_missing_is_noop(tmp_path: Path) -> None:
@ -45,11 +44,10 @@ def test_delete_stopped_instance_removes_dirs_without_unmounting(tmp_path: Path,
(tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True) (tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True)
monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command)
monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)
delete_instance("alpha", root=tmp_path) delete_instance("alpha", root=tmp_path)
assert not (tmp_path / "instances" / "alpha").exists() assert not (tmp_path / "instances" / "alpha").exists()
assert not (tmp_path / "runtime" / "alpha").exists() assert not (tmp_path / "runtime" / "alpha").exists()
assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "stop", "alpha"] in calls assert ["systemctl", "--user", "stop", "l4d2@alpha.service"] in calls
assert not any(call[0] == "fusermount3" for call in calls) assert not any(call[0] == "fusermount3" for call in calls)

View file

@ -1,5 +1,4 @@
from types import SimpleNamespace from types import SimpleNamespace
import subprocess
import pytest import pytest
@ -9,7 +8,7 @@ from l4d2host.logs import stream_instance_logs
class DummyProcess: class DummyProcess:
def __init__(self, lines: list[str]) -> None: def __init__(self, lines: list[str]) -> None:
self.stdout = SimpleNamespace(readline=self._readline) self.stdout = SimpleNamespace(readline=self._readline)
self.stderr = SimpleNamespace(readline=lambda: "", read=lambda: "") self.stderr = SimpleNamespace(readline=lambda: "")
self._lines = iter(lines) self._lines = iter(lines)
self.terminated = False self.terminated = False
self.waited = False self.waited = False
@ -23,7 +22,7 @@ class DummyProcess:
def terminate(self) -> None: def terminate(self) -> None:
self.terminated = True self.terminated = True
def wait(self, timeout: int | None = None) -> None: def wait(self, timeout: int) -> None:
del timeout del timeout
self.waited = True self.waited = True
@ -36,52 +35,8 @@ def test_stream_instance_logs_yields_lines(monkeypatch: pytest.MonkeyPatch) -> N
del kwargs del kwargs
return proc return proc
monkeypatch.setattr("l4d2host.service_control.subprocess.Popen", fake_popen) monkeypatch.setattr("l4d2host.logs.subprocess.Popen", fake_popen)
lines = list(stream_instance_logs("alpha", lines=10, follow=False)) lines = list(stream_instance_logs("alpha", lines=10, follow=False))
assert lines == ["line1", "line2"] assert lines == ["line1", "line2"]
assert proc.waited is True assert proc.terminated is True
def test_stream_instance_logs_uses_journalctl_helper(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def fake_stream_command(cmd):
calls.append(list(cmd))
return iter(["line1"])
monkeypatch.setattr("l4d2host.service_control.stream_command", fake_stream_command)
assert list(stream_instance_logs("alpha", lines=25, follow=False)) == ["line1"]
assert calls == [
[
"sudo",
"-n",
"/usr/local/libexec/left4me/left4me-journalctl",
"alpha",
"--lines",
"25",
"--no-follow",
]
]
def test_stream_instance_logs_raises_when_helper_fails(monkeypatch: pytest.MonkeyPatch) -> None:
class FailedProcess:
stdout = SimpleNamespace(readline=lambda: "")
stderr = SimpleNamespace(read=lambda: "sudo denied\n")
def poll(self):
return 7
def wait(self, timeout: int | None = None):
del timeout
return 7
monkeypatch.setattr("l4d2host.service_control.subprocess.Popen", lambda cmd, **kwargs: FailedProcess())
with pytest.raises(subprocess.CalledProcessError) as excinfo:
list(stream_instance_logs("alpha", lines=10, follow=False))
assert excinfo.value.returncode == 7
assert excinfo.value.stderr == "sudo denied\n"

View file

@ -1,61 +0,0 @@
from pathlib import Path
import pytest
from l4d2host.paths import get_left4me_root, overlay_path, validate_overlay_ref
def test_get_left4me_root_defaults_to_var_lib_left4me(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LEFT4ME_ROOT", raising=False)
assert get_left4me_root() == Path("/var/lib/left4me")
def test_get_left4me_root_uses_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
assert get_left4me_root() == tmp_path
def test_get_left4me_root_rejects_relative_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LEFT4ME_ROOT", "var/lib/left4me")
with pytest.raises(ValueError):
get_left4me_root()
@pytest.mark.parametrize("raw", ["", " "])
def test_get_left4me_root_rejects_empty_env(monkeypatch: pytest.MonkeyPatch, raw: str) -> None:
monkeypatch.setenv("LEFT4ME_ROOT", raw)
with pytest.raises(ValueError):
get_left4me_root()
@pytest.mark.parametrize("ref", ["standard", "competitive/base", "users/42/custom"])
def test_validate_overlay_ref_accepts_safe_refs(ref: str) -> None:
assert validate_overlay_ref(ref) == ref
@pytest.mark.parametrize(
"ref",
["", "/tmp/bad", "../bad", "bad/../evil", "bad//evil", " bad", "bad ", ".", "bad/./evil"],
)
def test_validate_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"
def test_overlay_path_rejects_symlink_escape(tmp_path: Path) -> None:
outside = tmp_path / "outside"
outside.mkdir()
overlays = tmp_path / "overlays"
overlays.mkdir()
(overlays / "escape").symlink_to(outside)
with pytest.raises(ValueError):
overlay_path("escape", root=tmp_path)

View file

@ -1,8 +1,4 @@
import subprocess from l4d2host.status import map_active_state
import pytest
from l4d2host.status import get_instance_status, map_active_state
def test_status_mapping() -> None: def test_status_mapping() -> None:
@ -10,19 +6,3 @@ def test_status_mapping() -> None:
assert map_active_state("inactive") == "stopped" assert map_active_state("inactive") == "stopped"
assert map_active_state("failed") == "stopped" assert map_active_state("failed") == "stopped"
assert map_active_state("weird") == "unknown" assert map_active_state("weird") == "unknown"
def test_get_instance_status_uses_systemctl_helper(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def fake_run_command(cmd, **kwargs):
del kwargs
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="ActiveState=active\nSubState=running\n", stderr="")
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"

View file

@ -11,7 +11,6 @@ Flask web app for managing L4D2 servers through user-private blueprints.
- Async job model with persisted command logs in `job_logs` - Async job model with persisted command logs in `job_logs`
- Desired vs actual state model - Desired vs actual state model
- Live logs for jobs and servers via SSE endpoints - Live logs for jobs and servers via SSE endpoints
- Host operations go through `l4d2ctl` via a local host command runner, not direct `l4d2host` imports
## Frontend constraints ## Frontend constraints
@ -27,21 +26,3 @@ python3 -m venv .venv
.venv/bin/pip install -e . .venv/bin/pip install -e .
.venv/bin/pytest tests -q .venv/bin/pytest tests -q
``` ```
## Configuration
The web app reads these settings from the environment:
- `DATABASE_URL`: SQLAlchemy database URL, for example `sqlite:////var/lib/left4me/left4me.db`.
- `SECRET_KEY`: Flask secret key used for sessions and CSRF-sensitive state.
- `JOB_WORKER_THREADS`: number of background job worker threads.
In the systemd deployment, environment is loaded from `/etc/left4me/host.env` and `/etc/left4me/web.env`.
## Admin Bootstrap
Create the first admin account with the Flask CLI. Provide the password through `LEFT4ME_ADMIN_PASSWORD`:
```bash
LEFT4ME_ADMIN_PASSWORD='change-me' flask create-user <username> --admin
```

View file

@ -1,12 +1,11 @@
import os import os
import secrets import secrets
import click
from flask import Flask, Response, jsonify, redirect, request, session from flask import Flask, Response, jsonify, redirect, request, session
from l4d2web.auth import current_user, load_current_user from l4d2web.auth import current_user, load_current_user
from l4d2web.cli import register_cli from l4d2web.cli import register_cli
from l4d2web.config import load_config from l4d2web.config import DEFAULT_CONFIG
from l4d2web.db import init_db from l4d2web.db import init_db
from l4d2web.routes.blueprint_routes import bp as blueprint_bp from l4d2web.routes.blueprint_routes import bp as blueprint_bp
from l4d2web.routes.auth_routes import bp as auth_bp from l4d2web.routes.auth_routes import bp as auth_bp
@ -19,13 +18,9 @@ from l4d2web.routes.server_routes import bp as server_bp
from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers
def _in_flask_cli_context() -> bool:
return click.get_current_context(silent=True) is not None
def create_app(test_config: dict[str, object] | None = None) -> Flask: def create_app(test_config: dict[str, object] | None = None) -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.config.from_mapping(load_config()) app.config.from_mapping(DEFAULT_CONFIG)
app.config.update( app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
@ -34,7 +29,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
if test_config is not None: if test_config is not None:
app.config.update(test_config) app.config.update(test_config)
with app.app_context(): os.environ["DATABASE_URL"] = str(app.config["DATABASE_URL"])
init_db() init_db()
@app.before_request @app.before_request
@ -67,13 +62,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
register_cli(app) register_cli(app)
if app.config.get("TESTING"): if app.config.get("TESTING"):
reset_login_rate_limits() reset_login_rate_limits()
should_start_workers = (
app.config.get("JOB_WORKER_ENABLED")
and not app.config.get("TESTING")
and not _in_flask_cli_context()
)
if should_start_workers:
recover_stale_jobs() recover_stale_jobs()
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
start_job_workers(app) start_job_workers(app)
@app.get("/health") @app.get("/health")

View file

@ -1,10 +1,6 @@
import os
import click import click
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select from sqlalchemy import select
from l4d2web.auth import hash_password
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import User from l4d2web.models import User
@ -19,28 +15,5 @@ def promote_admin(username: str) -> None:
user.admin = True user.admin = True
@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.getenv("LEFT4ME_ADMIN_PASSWORD")
if password is None:
password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
if password == "":
raise click.ClickException("password must not be empty")
try:
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))
except IntegrityError as exc:
raise click.ClickException("user already exists") from exc
click.echo(f"created user {username}")
def register_cli(app) -> None: def register_cli(app) -> None:
app.cli.add_command(promote_admin) app.cli.add_command(promote_admin)
app.cli.add_command(create_user)

View file

@ -1,6 +1,3 @@
import os
DEFAULT_CONFIG: dict[str, object] = { DEFAULT_CONFIG: dict[str, object] = {
"SECRET_KEY": "dev", "SECRET_KEY": "dev",
"DATABASE_URL": "sqlite:///l4d2web.db", "DATABASE_URL": "sqlite:///l4d2web.db",
@ -11,20 +8,3 @@ DEFAULT_CONFIG: dict[str, object] = {
"JOB_LOG_REPLAY_LIMIT": 2000, "JOB_LOG_REPLAY_LIMIT": 2000,
"JOB_LOG_LINE_MAX_CHARS": 4096, "JOB_LOG_LINE_MAX_CHARS": 4096,
} }
def _bool_from_env(raw: str) -> bool:
return raw.lower() not in {"0", "false", "no"}
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": _bool_from_env(os.getenv("JOB_WORKER_ENABLED", "true")),
"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")),
}

View file

@ -1,7 +1,6 @@
from contextlib import contextmanager from contextlib import contextmanager
import os import os
from flask import current_app, has_app_context
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@ -12,8 +11,6 @@ _Session = None
def get_database_url() -> str: def get_database_url() -> str:
if has_app_context():
return str(current_app.config["DATABASE_URL"])
return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db") return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db")

View file

@ -13,7 +13,6 @@ dependencies = [
"SQLAlchemy>=2.0", "SQLAlchemy>=2.0",
"alembic>=1.13", "alembic>=1.13",
"PyYAML>=6.0", "PyYAML>=6.0",
"gunicorn>=22.0",
] ]
[tool.setuptools] [tool.setuptools]

View file

@ -3,8 +3,8 @@ from sqlalchemy import select
from l4d2web.auth import require_admin from l4d2web.auth import require_admin
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import BlueprintOverlay, Overlay from l4d2web.models import Overlay
from l4d2web.services.security import validate_overlay_ref from l4d2web.services.security import validate_overlay_path
bp = Blueprint("overlay", __name__) bp = Blueprint("overlay", __name__)
@ -14,12 +14,12 @@ bp = Blueprint("overlay", __name__)
@require_admin @require_admin
def create_overlay() -> Response: def create_overlay() -> Response:
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "") raw_path = request.form.get("path", "").strip()
if not name or not raw_path: if not name or not raw_path:
return Response("missing fields", status=400) return Response("missing fields", status=400)
try: try:
overlay_ref = validate_overlay_ref(raw_path) validated_path = validate_overlay_path(raw_path)
except ValueError as exc: except ValueError as exc:
return Response(str(exc), status=400) return Response(str(exc), status=400)
@ -27,7 +27,7 @@ def create_overlay() -> Response:
existing = db.scalar(select(Overlay).where(Overlay.name == name)) existing = db.scalar(select(Overlay).where(Overlay.name == name))
if existing is not None: if existing is not None:
return Response("overlay already exists", status=409) return Response("overlay already exists", status=409)
db.add(Overlay(name=name, path=overlay_ref)) db.add(Overlay(name=name, path=str(validated_path)))
return redirect("/overlays") return redirect("/overlays")
@ -36,12 +36,12 @@ def create_overlay() -> Response:
@require_admin @require_admin
def update_overlay(overlay_id: int) -> Response: def update_overlay(overlay_id: int) -> Response:
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "") raw_path = request.form.get("path", "").strip()
if not name or not raw_path: if not name or not raw_path:
return Response("missing fields", status=400) return Response("missing fields", status=400)
try: try:
overlay_ref = validate_overlay_ref(raw_path) validated_path = validate_overlay_path(raw_path)
except ValueError as exc: except ValueError as exc:
return Response(str(exc), status=400) return Response(str(exc), status=400)
@ -49,11 +49,8 @@ def update_overlay(overlay_id: int) -> Response:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None: if overlay is None:
return Response(status=404) return Response(status=404)
duplicate = db.scalar(select(Overlay).where(Overlay.name == name, Overlay.id != overlay_id))
if duplicate is not None:
return Response("overlay already exists", status=409)
overlay.name = name overlay.name = name
overlay.path = overlay_ref overlay.path = str(validated_path)
return redirect("/overlays") return redirect("/overlays")
@ -65,8 +62,5 @@ def delete_overlay(overlay_id: int) -> Response:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None: if overlay is None:
return Response(status=404) return Response(status=404)
in_use = db.scalar(select(BlueprintOverlay).where(BlueprintOverlay.overlay_id == overlay_id))
if in_use is not None:
return Response("overlay is in use", status=409)
db.delete(overlay) db.delete(overlay)
return redirect("/overlays") return redirect("/overlays")

View file

@ -1,166 +0,0 @@
from dataclasses import dataclass
import os
import signal
import subprocess
import sys
import threading
import time
from typing import Callable, Iterator, Sequence
@dataclass(slots=True)
class CommandResult:
returncode: int
stdout: str
stderr: str
class HostCommandError(subprocess.CalledProcessError):
pass
class CommandCancelledError(HostCommandError):
pass
def run_command(
cmd: Sequence[str],
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
cancel_poll_seconds: float = 0.2,
cancel_terminate_timeout: float = 2.0,
) -> CommandResult:
stdout_lines: list[str] = []
stderr_lines: list[str] = []
proc = subprocess.Popen(
list(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
start_new_session=should_cancel is not None,
)
def emit_stderr_message(line: str) -> None:
stderr_lines.append(line)
if on_stderr is not None:
on_stderr(line)
if passthrough:
print(line, file=sys.stderr)
def terminate_process() -> None:
emit_stderr_message("cancellation requested; terminating subprocess")
if should_cancel is not None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
else:
proc.terminate()
def kill_process() -> None:
emit_stderr_message("subprocess did not exit after cancellation; killing subprocess")
if should_cancel is not None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
proc.kill()
def pump(
stream,
sink: list[str],
callback: Callable[[str], None] | None,
output_stream,
) -> None:
if stream is None:
return
for raw in iter(stream.readline, ""):
line = raw.rstrip("\n")
sink.append(line)
if callback is not None:
callback(line)
if passthrough:
print(line, file=output_stream)
stream.close()
stdout_thread = threading.Thread(
target=pump,
args=(proc.stdout, stdout_lines, on_stdout, sys.stdout),
daemon=True,
)
stderr_thread = threading.Thread(
target=pump,
args=(proc.stderr, stderr_lines, on_stderr, sys.stderr),
daemon=True,
)
stdout_thread.start()
stderr_thread.start()
cancelled = False
while True:
returncode = proc.poll()
if returncode is not None:
break
if should_cancel is not None and should_cancel():
cancelled = True
terminate_process()
try:
returncode = proc.wait(timeout=cancel_terminate_timeout)
except subprocess.TimeoutExpired:
kill_process()
returncode = proc.wait()
break
time.sleep(cancel_poll_seconds)
stdout_thread.join()
stderr_thread.join()
result = CommandResult(
returncode=returncode,
stdout="\n".join(stdout_lines),
stderr="\n".join(stderr_lines),
)
if cancelled:
raise CommandCancelledError(
returncode=returncode,
cmd=list(cmd),
output=result.stdout,
stderr=result.stderr,
)
if returncode != 0:
raise HostCommandError(
returncode=returncode,
cmd=list(cmd),
output=result.stdout,
stderr=result.stderr,
)
return result
def stream_command(cmd: Sequence[str]) -> Iterator[str]:
proc = subprocess.Popen(
list(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()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()

View file

@ -4,12 +4,12 @@ import subprocess
import threading import threading
import time import time
from l4d2host.process import CommandCancelledError
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Job, JobLog, Server from l4d2web.models import Job, JobLog, Server
from l4d2web.services.host_commands import CommandCancelledError
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"} TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}

View file

@ -1,26 +1,21 @@
from dataclasses import dataclass
import json import json
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from l4d2host.instances import delete_instance, initialize_instance, start_instance, stop_instance
from l4d2host.logs import stream_instance_logs
from l4d2host.status import get_instance_status
from l4d2host.steam_install import SteamInstaller
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server
from l4d2web.services import host_commands
from l4d2web.services.spec_yaml import write_temp_spec from l4d2web.services.spec_yaml import write_temp_spec
@dataclass(slots=True) def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_names: list[str]) -> dict:
class ServerStatus:
state: str
raw_active_state: str
raw_sub_state: str
def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_refs: list[str]) -> dict:
return { return {
"port": server.port, "port": server.port,
"overlays": overlay_refs, "overlays": overlay_names,
"arguments": json.loads(blueprint.arguments), "arguments": json.loads(blueprint.arguments),
"config": json.loads(blueprint.config), "config": json.loads(blueprint.config),
} }
@ -37,79 +32,46 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis
raise ValueError("blueprint not found") raise ValueError("blueprint not found")
rows = db.execute( rows = db.execute(
select(Overlay.path) select(Overlay.name)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id) .where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position) .order_by(BlueprintOverlay.position)
).all() ).all()
overlay_refs = [row[0] for row in rows] overlay_names = [row[0] for row in rows]
return server, blueprint, overlay_refs return server, blueprint, overlay_names
def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
host_commands.run_command( SteamInstaller().install_or_update(on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
["l4d2ctl", "install"],
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id) server, blueprint, overlay_names = load_server_blueprint_bundle(server_id)
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs)) spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_names))
try: try:
host_commands.run_command( initialize_instance(server.name, spec_path, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)],
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
finally: finally:
spec_path.unlink(missing_ok=True) spec_path.unlink(missing_ok=True)
def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( start_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
["l4d2ctl", "start", server.name],
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( stop_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
["l4d2ctl", "stop", server.name],
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( delete_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
["l4d2ctl", "delete", server.name],
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
def server_status(server_name: str) -> ServerStatus: def server_status(server_name: str):
result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"]) return get_instance_status(server_name)
payload = json.loads(result.stdout or "{}")
return ServerStatus(
state=str(payload.get("state", "unknown")),
raw_active_state=str(payload.get("raw_active_state", "unknown")),
raw_sub_state=str(payload.get("raw_sub_state", "unknown")),
)
def stream_server_logs(server_name: str, *, lines: int = 200, follow: bool = True): def stream_server_logs(server_name: str, *, lines: int = 200, follow: bool = True):
command = ["l4d2ctl", "logs", server_name, "--lines", str(lines)] return stream_instance_logs(server_name, lines=lines, follow=follow)
command.append("--follow" if follow else "--no-follow")
return host_commands.stream_command(command)

View file

@ -1,12 +1,11 @@
def validate_overlay_ref(raw: str) -> str: from pathlib import Path
if raw != raw.strip():
raise ValueError("overlay ref must not have leading or trailing whitespace")
if not raw: OVERLAY_ROOT = Path("/opt/l4d2/overlays").resolve()
raise ValueError("overlay ref must not be empty")
if "\\" in raw:
raise ValueError("overlay ref must use forward slashes") def validate_overlay_path(raw: str) -> Path:
if raw.startswith("/"): path = Path(raw).resolve()
raise ValueError("overlay ref must be relative") if OVERLAY_ROOT not in path.parents and path != OVERLAY_ROOT:
if any(component in {"", ".", ".."} for component in raw.split("/")): raise ValueError("overlay path must be under /opt/l4d2/overlays")
raise ValueError("overlay ref must not contain empty, current, or parent components") return path
return raw

View file

@ -116,47 +116,3 @@ def test_login_sets_session(client) -> None:
with client.session_transaction() as sess: with client.session_transaction() as sess:
assert sess.get("user_id") is not None assert sess.get("user_id") is not None
def test_create_user_cli_uses_environment_password(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'create_user.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret")
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])
assert result.exit_code == 0
assert "created user admin" in result.output
with session_scope() as session:
user = session.query(User).filter_by(username="admin").one()
assert user.admin is True
def test_create_user_cli_rejects_empty_environment_password(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'empty_password.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "")
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])
assert result.exit_code != 0
assert "password must not be empty" in result.output
def test_create_user_cli_rejects_duplicate_username(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'duplicate_user.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret")
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
session.add(User(username="admin", password_digest=hash_password("secret"), admin=True))
result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])
assert result.exit_code != 0
assert "user already exists" in result.output

View file

@ -1,38 +0,0 @@
import click
from l4d2web.app import create_app
from l4d2web.config import load_config
def test_load_config_uses_environment(monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", "sqlite:///env.db")
monkeypatch.setenv("SECRET_KEY", "env-secret")
monkeypatch.setenv("JOB_WORKER_THREADS", "2")
config = load_config()
assert config["DATABASE_URL"] == "sqlite:///env.db"
assert config["SECRET_KEY"] == "env-secret"
assert config["JOB_WORKER_THREADS"] == 2
def test_create_app_does_not_overwrite_database_url_env(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", "sqlite:///env.db")
db_url = f"sqlite:///{tmp_path/'app.db'}"
create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
assert load_config()["DATABASE_URL"] == "sqlite:///env.db"
def test_create_app_skips_job_workers_in_cli_context(tmp_path, monkeypatch) -> None:
calls = []
db_url = f"sqlite:///{tmp_path/'cli.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setattr("l4d2web.app.recover_stale_jobs", lambda: calls.append("recover"))
monkeypatch.setattr("l4d2web.app.start_job_workers", lambda app: calls.append("start"))
with click.Context(click.Command("flask")):
create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
assert calls == []

View file

@ -1,55 +0,0 @@
import pytest
def test_run_command_streams_stdout_and_stderr_callbacks() -> None:
from l4d2web.services.host_commands import run_command
stdout: list[str] = []
stderr: list[str] = []
result = run_command(
["python3", "-c", "import sys; print('ok'); print('warn', file=sys.stderr)"],
on_stdout=stdout.append,
on_stderr=stderr.append,
)
assert stdout == ["ok"]
assert stderr == ["warn"]
assert result.returncode == 0
assert result.stdout == "ok"
assert result.stderr == "warn"
def test_run_command_raises_host_error_on_nonzero_exit() -> None:
from l4d2web.services.host_commands import HostCommandError, run_command
with pytest.raises(HostCommandError) as exc_info:
run_command(["python3", "-c", "import sys; print('bad', file=sys.stderr); sys.exit(7)"])
assert exc_info.value.returncode == 7
assert exc_info.value.stderr == "bad"
def test_run_command_raises_cancelled_error_when_cancel_requested() -> None:
from l4d2web.services.host_commands import CommandCancelledError, run_command
stdout: list[str] = []
with pytest.raises(CommandCancelledError):
run_command(
["python3", "-c", "import time; print('ready', flush=True); time.sleep(5)"],
on_stdout=stdout.append,
should_cancel=lambda: bool(stdout),
cancel_poll_seconds=0.01,
cancel_terminate_timeout=0.2,
)
assert stdout == ["ready"]
def test_stream_command_yields_stdout_lines() -> None:
from l4d2web.services.host_commands import stream_command
lines = list(stream_command(["python3", "-c", "print('one'); print('two')"]))
assert lines == ["one", "two"]

View file

@ -6,11 +6,11 @@ import subprocess
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from l4d2host.process import CommandCancelledError
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Job, Server, User from l4d2web.models import Blueprint, Job, Server, User
from l4d2web.services import l4d2_facade from l4d2web.services import l4d2_facade
from l4d2web.services.host_commands import CommandCancelledError
from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs, run_worker_once from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs, run_worker_once

View file

@ -6,7 +6,6 @@ from l4d2web.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server, User from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server, User
from l4d2web.services.host_commands import CommandResult
@pytest.fixture @pytest.fixture
@ -22,7 +21,7 @@ def server_with_blueprint(tmp_path, monkeypatch):
session.add(user) session.add(user)
session.flush() session.flush()
overlay = Overlay(name="Standard Overlay", path="standard") overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard")
session.add(overlay) session.add(overlay)
session.flush() session.flush()
@ -45,117 +44,19 @@ def server_with_blueprint(tmp_path, monkeypatch):
return server_id return server_id
def test_initialize_uses_l4d2ctl_with_latest_blueprint_data( def test_initialize_uses_latest_blueprint_data(monkeypatch: pytest.MonkeyPatch, server_with_blueprint) -> None:
monkeypatch: pytest.MonkeyPatch, called: dict[str, str] = {}
server_with_blueprint,
) -> None:
calls: list[list[str]] = []
def fake_run_command(cmd, **kwargs): def fake_initialize(name, spec_path, **kwargs):
del kwargs del kwargs
calls.append(list(cmd)) called["name"] = name
spec_path = Path(cmd[cmd.index("-f") + 1]) called["spec"] = Path(spec_path).read_text()
spec = spec_path.read_text()
assert "sv_consistency 1" in spec
assert "standard" in spec
assert "Standard Overlay" not in spec
return CommandResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command) monkeypatch.setattr("l4d2web.services.l4d2_facade.initialize_instance", fake_initialize)
monkeypatch.setattr(
"l4d2web.services.l4d2_facade.initialize_instance",
lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.initialize_instance directly"),
raising=False,
)
from l4d2web.services.l4d2_facade import initialize_server from l4d2web.services.l4d2_facade import initialize_server
initialize_server(server_with_blueprint) initialize_server(server_with_blueprint)
assert calls[0][:3] == ["l4d2ctl", "initialize", "alpha"] assert called["name"] == "alpha"
assert calls[0][3] == "-f" assert "sv_consistency 1" in called["spec"]
def test_install_and_lifecycle_commands_use_l4d2ctl(
monkeypatch: pytest.MonkeyPatch,
server_with_blueprint,
) -> None:
calls: list[list[str]] = []
def fake_run_command(cmd, **kwargs):
del kwargs
calls.append(list(cmd))
return CommandResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
for name in ["SteamInstaller", "start_instance", "stop_instance", "delete_instance"]:
monkeypatch.setattr(
f"l4d2web.services.l4d2_facade.{name}",
lambda *args, **kwargs: pytest.fail(f"facade must not call l4d2host {name} directly"),
raising=False,
)
from l4d2web.services.l4d2_facade import delete_server, install_runtime, start_server, stop_server
install_runtime()
start_server(server_with_blueprint)
stop_server(server_with_blueprint)
delete_server(server_with_blueprint)
assert calls == [
["l4d2ctl", "install"],
["l4d2ctl", "start", "alpha"],
["l4d2ctl", "stop", "alpha"],
["l4d2ctl", "delete", "alpha"],
]
def test_server_status_parses_l4d2ctl_json(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def fake_run_command(cmd, **kwargs):
del kwargs
calls.append(list(cmd))
return CommandResult(
returncode=0,
stdout='{"state":"running","raw_active_state":"active","raw_sub_state":"running"}',
stderr="",
)
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
monkeypatch.setattr(
"l4d2web.services.l4d2_facade.get_instance_status",
lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.get_instance_status directly"),
raising=False,
)
from l4d2web.services.l4d2_facade import server_status
status = server_status("alpha")
assert calls == [["l4d2ctl", "status", "alpha", "--json"]]
assert status.state == "running"
assert status.raw_active_state == "active"
assert status.raw_sub_state == "running"
def test_server_logs_stream_l4d2ctl_logs(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def fake_stream_command(cmd):
calls.append(list(cmd))
return iter(["one", "two"])
monkeypatch.setattr("l4d2web.services.host_commands.stream_command", fake_stream_command)
monkeypatch.setattr(
"l4d2web.services.l4d2_facade.stream_instance_logs",
lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.stream_instance_logs directly"),
raising=False,
)
from l4d2web.services.l4d2_facade import stream_server_logs
lines = list(stream_server_logs("alpha", lines=10, follow=False))
assert calls == [["l4d2ctl", "logs", "alpha", "--lines", "10", "--no-follow"]]
assert lines == ["one", "two"]

View file

@ -2,8 +2,7 @@ import pytest
from l4d2web.app import create_app from l4d2web.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, User from l4d2web.models import Overlay, User
from l4d2web.services.security import validate_overlay_ref
@pytest.fixture @pytest.fixture
@ -36,7 +35,7 @@ def user_client_with_overlay(tmp_path, monkeypatch):
with session_scope() as session: with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False) user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user) session.add(user)
session.add(Overlay(name="standard", path="standard")) session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard"))
session.flush() session.flush()
user_id = user.id user_id = user.id
@ -68,14 +67,14 @@ def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
def test_admin_can_create_overlay(admin_client) -> None: def test_admin_can_create_overlay(admin_client) -> None:
response = admin_client.post( response = admin_client.post(
"/overlays", "/overlays",
data={"name": "standard", "path": "standard"}, data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert response.status_code == 302 assert response.status_code == 302
assert response.headers["Location"] == "/overlays" assert response.headers["Location"] == "/overlays"
def test_overlay_ref_must_be_relative(admin_client) -> None: def test_overlay_path_must_be_under_root(admin_client) -> None:
response = admin_client.post( response = admin_client.post(
"/overlays", "/overlays",
data={"name": "bad", "path": "/tmp/bad"}, data={"name": "bad", "path": "/tmp/bad"},
@ -84,25 +83,10 @@ def test_overlay_ref_must_be_relative(admin_client) -> None:
assert response.status_code == 400 assert response.status_code == 400
@pytest.mark.parametrize("overlay_ref", [" standard", "standard ", "a//b", "a/", "./a", "a/.", "."])
def test_overlay_ref_rejects_unsafe_components(overlay_ref: str) -> None:
with pytest.raises(ValueError):
validate_overlay_ref(overlay_ref)
def test_overlay_route_rejects_whitespace_padded_ref(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "bad", "path": " standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 400
def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None: def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post( response = user_client_with_overlay.post(
"/overlays", "/overlays",
data={"name": "bad", "path": "bad"}, data={"name": "bad", "path": "/opt/l4d2/overlays/bad"},
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert response.status_code == 403 assert response.status_code == 403
@ -111,14 +95,14 @@ def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
def test_admin_can_update_and_delete_overlay(admin_client) -> None: def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post( create = admin_client.post(
"/overlays", "/overlays",
data={"name": "standard", "path": "standard"}, data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert create.status_code == 302 assert create.status_code == 302
update = admin_client.post( update = admin_client.post(
"/overlays/1", "/overlays/1",
data={"name": "edited", "path": "edited"}, data={"name": "edited", "path": "/opt/l4d2/overlays/edited"},
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert update.status_code == 302 assert update.status_code == 302
@ -128,44 +112,3 @@ def test_admin_can_update_and_delete_overlay(admin_client) -> None:
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert delete.status_code == 302 assert delete.status_code == 302
def test_update_overlay_rejects_duplicate_name(admin_client) -> None:
for name in ["standard", "competitive"]:
response = admin_client.post(
"/overlays",
data={"name": name, "path": name},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
response = admin_client.post(
"/overlays/2",
data={"name": "standard", "path": "competitive"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409
def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "path": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
user = session.query(User).filter_by(username="admin").one()
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0))
response = admin_client.post(
"/overlays/1/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409