feat(deploy): add production-like test deployment

This commit is contained in:
mwiegand 2026-05-06 19:30:10 +02:00
parent de86139323
commit bbfc528354
No known key found for this signature in database
45 changed files with 2604 additions and 172 deletions

View file

@ -39,7 +39,8 @@ Do not invent architecture outside these plans unless explicitly requested.
- CLI read commands are allowed for web/host boundary consistency:
- `status <name> --json`
- `logs <name> --lines <n> --follow/--no-follow`
- Hard-coded paths under `/opt/l4d2`.
- 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).
- Fail-fast subprocess behavior; pass raw stderr; propagate return code.
- No lock manager, no rollback, no preflight runtime checks.

View file

@ -7,8 +7,7 @@
## Status
This repository is currently in planning phase.
Implementation plans are the source of truth:
Implementation plans remain the source of truth for architecture and task sequencing:
- `docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md`
- `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`
@ -27,7 +26,7 @@ Implementation plans are the source of truth:
- `status <name> --json`
- `logs <name> --lines <n> --follow/--no-follow`
- The web app calls host operations through `l4d2ctl`, not direct `l4d2host` imports.
- Runtime paths are hard-coded under `/opt/l4d2`.
- 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.
- No lock manager, no rollback, no preflight checks in host library.
- CLI propagates subprocess failures via stderr and return code.
@ -44,8 +43,13 @@ Implementation plans are the source of truth:
- `l4d2host/`
- `l4d2web/`
- `deploy/`
- `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)
- Python 3.12+

72
deploy/README.md Normal file
View file

@ -0,0 +1,72 @@
# 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.

176
deploy/deploy-test-server.sh Executable file
View file

@ -0,0 +1,176 @@
#!/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

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

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

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

@ -0,0 +1,53 @@
#!/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

@ -0,0 +1,44 @@
#!/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

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

View file

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

View file

@ -0,0 +1,186 @@
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)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,244 @@
# 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

@ -21,12 +21,24 @@ Subprocess failures are fail-fast. Raw stderr is written to stderr and the comma
## Runtime Paths
The host library uses hard-coded runtime paths under `/opt/l4d2`:
The host library reads `LEFT4ME_ROOT` from the environment. It defaults to `/var/lib/left4me`:
- `/opt/l4d2/installation`
- `/opt/l4d2/overlays/<overlay>`
- `/opt/l4d2/instances/<name>`
- `/opt/l4d2/runtime/<name>/{upper,work,merged}`
- `${LEFT4ME_ROOT}/installation`
- `${LEFT4ME_ROOT}/overlays/<overlay-ref>`
- `${LEFT4ME_ROOT}/instances/<name>`
- `${LEFT4ME_ROOT}/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
@ -40,7 +52,7 @@ Validated on Debian 13 during the `ckn@10.0.4.128` smoke test:
- `fuse-overlayfs` and `fusermount3` for per-instance overlay mounts.
- `systemctl --user` and `journalctl --user` available for the runtime user.
- User lingering enabled when services must survive SSH sessions: `sudo loginctl enable-linger <user>`.
- `/opt/l4d2` created and writable by the runtime user.
- `/var/lib/left4me` created and writable by the runtime user, unless `LEFT4ME_ROOT` is set to another deployment-managed root.
Example Debian setup:
@ -52,8 +64,8 @@ sudo apt-get install -y \
fuse-overlayfs fuse3 \
libc6-i386 lib32gcc-s1 lib32stdc++6
sudo mkdir -p /opt/steamcmd /opt/l4d2/{installation,overlays,instances,runtime}
sudo chown -R "$USER:$USER" /opt/steamcmd /opt/l4d2
sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp}
sudo chown -R "$USER:$USER" /opt/steamcmd /var/lib/left4me
sudo loginctl enable-linger "$USER"
```

View file

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

View file

@ -1,34 +1,7 @@
import subprocess
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]:
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)
yield from stream_journal(name, lines=lines, follow=follow)

50
l4d2host/paths.py Normal file
View file

@ -0,0 +1,50 @@
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,10 +17,7 @@ dependencies = [
l4d2ctl = "l4d2host.cli:app"
[tool.setuptools]
packages = ["l4d2host", "l4d2host.fs", "l4d2host.templates"]
packages = ["l4d2host", "l4d2host.fs"]
[tool.setuptools.package-dir]
l4d2host = "."
[tool.setuptools.package-data]
"l4d2host.templates" = ["*.service"]

View file

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

View file

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

View file

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

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

@ -56,3 +56,17 @@ def test_logs_command_streams_lines(monkeypatch) -> None:
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,3 +20,14 @@ def test_empty_config_writes_empty_server_cfg(tmp_path: Path) -> None:
initialize_instance("alpha", spec, root=tmp_path)
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,3 +33,13 @@ def test_fail_fast_on_first_failure(monkeypatch: pytest.MonkeyPatch) -> None:
SteamInstaller().install_or_update()
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,11 +22,12 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
(instance_dir / "server.cfg").write_text("sv_consistency 1")
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)
assert calls[0][0] == "fuse-overlayfs"
assert calls[1][:3] == ["systemctl", "--user", "start"]
assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"]
def test_delete_missing_is_noop(tmp_path: Path) -> None:
@ -44,10 +45,11 @@ def test_delete_stopped_instance_removes_dirs_without_unmounting(tmp_path: Path,
(tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True)
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)
assert not (tmp_path / "instances" / "alpha").exists()
assert not (tmp_path / "runtime" / "alpha").exists()
assert ["systemctl", "--user", "stop", "l4d2@alpha.service"] in calls
assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "stop", "alpha"] in calls
assert not any(call[0] == "fusermount3" for call in calls)

View file

@ -1,4 +1,5 @@
from types import SimpleNamespace
import subprocess
import pytest
@ -8,7 +9,7 @@ from l4d2host.logs import stream_instance_logs
class DummyProcess:
def __init__(self, lines: list[str]) -> None:
self.stdout = SimpleNamespace(readline=self._readline)
self.stderr = SimpleNamespace(readline=lambda: "")
self.stderr = SimpleNamespace(readline=lambda: "", read=lambda: "")
self._lines = iter(lines)
self.terminated = False
self.waited = False
@ -22,7 +23,7 @@ class DummyProcess:
def terminate(self) -> None:
self.terminated = True
def wait(self, timeout: int) -> None:
def wait(self, timeout: int | None = None) -> None:
del timeout
self.waited = True
@ -35,8 +36,52 @@ def test_stream_instance_logs_yields_lines(monkeypatch: pytest.MonkeyPatch) -> N
del kwargs
return proc
monkeypatch.setattr("l4d2host.logs.subprocess.Popen", fake_popen)
monkeypatch.setattr("l4d2host.service_control.subprocess.Popen", fake_popen)
lines = list(stream_instance_logs("alpha", lines=10, follow=False))
assert lines == ["line1", "line2"]
assert proc.terminated is True
assert proc.waited 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

@ -0,0 +1,61 @@
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,4 +1,8 @@
from l4d2host.status import map_active_state
import subprocess
import pytest
from l4d2host.status import get_instance_status, map_active_state
def test_status_mapping() -> None:
@ -6,3 +10,19 @@ def test_status_mapping() -> None:
assert map_active_state("inactive") == "stopped"
assert map_active_state("failed") == "stopped"
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

@ -27,3 +27,21 @@ python3 -m venv .venv
.venv/bin/pip install -e .
.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,11 +1,12 @@
import os
import secrets
import click
from flask import Flask, Response, jsonify, redirect, request, session
from l4d2web.auth import current_user, load_current_user
from l4d2web.cli import register_cli
from l4d2web.config import DEFAULT_CONFIG
from l4d2web.config import load_config
from l4d2web.db import init_db
from l4d2web.routes.blueprint_routes import bp as blueprint_bp
from l4d2web.routes.auth_routes import bp as auth_bp
@ -18,9 +19,13 @@ from l4d2web.routes.server_routes import bp as server_bp
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:
app = Flask(__name__)
app.config.from_mapping(DEFAULT_CONFIG)
app.config.from_mapping(load_config())
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
@ -29,8 +34,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
if test_config is not None:
app.config.update(test_config)
os.environ["DATABASE_URL"] = str(app.config["DATABASE_URL"])
init_db()
with app.app_context():
init_db()
@app.before_request
def csrf_protect() -> Response | None:
@ -62,8 +67,13 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
register_cli(app)
if app.config.get("TESTING"):
reset_login_rate_limits()
recover_stale_jobs()
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
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()
start_job_workers(app)
@app.get("/health")

View file

@ -1,6 +1,10 @@
import os
import click
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select
from l4d2web.auth import hash_password
from l4d2web.db import session_scope
from l4d2web.models import User
@ -15,5 +19,28 @@ def promote_admin(username: str) -> None:
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:
app.cli.add_command(promote_admin)
app.cli.add_command(create_user)

View file

@ -1,3 +1,6 @@
import os
DEFAULT_CONFIG: dict[str, object] = {
"SECRET_KEY": "dev",
"DATABASE_URL": "sqlite:///l4d2web.db",
@ -8,3 +11,20 @@ DEFAULT_CONFIG: dict[str, object] = {
"JOB_LOG_REPLAY_LIMIT": 2000,
"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,6 +1,7 @@
from contextlib import contextmanager
import os
from flask import current_app, has_app_context
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@ -11,6 +12,8 @@ _Session = None
def get_database_url() -> str:
if has_app_context():
return str(current_app.config["DATABASE_URL"])
return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db")

View file

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

View file

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

View file

@ -17,10 +17,10 @@ class ServerStatus:
raw_sub_state: str
def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_names: list[str]) -> dict:
def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_refs: list[str]) -> dict:
return {
"port": server.port,
"overlays": overlay_names,
"overlays": overlay_refs,
"arguments": json.loads(blueprint.arguments),
"config": json.loads(blueprint.config),
}
@ -37,13 +37,13 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis
raise ValueError("blueprint not found")
rows = db.execute(
select(Overlay.name)
select(Overlay.path)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
overlay_names = [row[0] for row in rows]
return server, blueprint, overlay_names
overlay_refs = [row[0] for row in rows]
return server, blueprint, overlay_refs
def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
@ -56,8 +56,8 @@ def install_runtime(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_names = load_server_blueprint_bundle(server_id)
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_names))
server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id)
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
try:
host_commands.run_command(
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)],

View file

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

View file

@ -116,3 +116,47 @@ def test_login_sets_session(client) -> None:
with client.session_transaction() as sess:
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

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

@ -22,7 +22,7 @@ def server_with_blueprint(tmp_path, monkeypatch):
session.add(user)
session.flush()
overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard")
overlay = Overlay(name="Standard Overlay", path="standard")
session.add(overlay)
session.flush()
@ -55,7 +55,10 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
del kwargs
calls.append(list(cmd))
spec_path = Path(cmd[cmd.index("-f") + 1])
assert "sv_consistency 1" in 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)

View file

@ -2,7 +2,8 @@ import pytest
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Overlay, User
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, User
from l4d2web.services.security import validate_overlay_ref
@pytest.fixture
@ -35,7 +36,7 @@ def user_client_with_overlay(tmp_path, monkeypatch):
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard"))
session.add(Overlay(name="standard", path="standard"))
session.flush()
user_id = user.id
@ -67,14 +68,14 @@ def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
def test_admin_can_create_overlay(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
data={"name": "standard", "path": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/overlays"
def test_overlay_path_must_be_under_root(admin_client) -> None:
def test_overlay_ref_must_be_relative(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "bad", "path": "/tmp/bad"},
@ -83,10 +84,25 @@ def test_overlay_path_must_be_under_root(admin_client) -> None:
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:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "bad", "path": "/opt/l4d2/overlays/bad"},
data={"name": "bad", "path": "bad"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
@ -95,14 +111,14 @@ def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
data={"name": "standard", "path": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
update = admin_client.post(
"/overlays/1",
data={"name": "edited", "path": "/opt/l4d2/overlays/edited"},
data={"name": "edited", "path": "edited"},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
@ -112,3 +128,44 @@ def test_admin_can_update_and_delete_overlay(admin_client) -> None:
headers={"X-CSRF-Token": "test-token"},
)
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