feat(deploy): add production-like test deployment
This commit is contained in:
parent
de86139323
commit
bbfc528354
45 changed files with 2604 additions and 172 deletions
|
|
@ -39,7 +39,8 @@ Do not invent architecture outside these plans unless explicitly requested.
|
||||||
- CLI read commands are allowed for web/host boundary consistency:
|
- CLI read commands are allowed for web/host boundary consistency:
|
||||||
- `status <name> --json`
|
- `status <name> --json`
|
||||||
- `logs <name> --lines <n> --follow/--no-follow`
|
- `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).
|
- 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.
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -7,8 +7,7 @@
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This repository is currently in planning phase.
|
Implementation plans remain the source of truth for architecture and task sequencing:
|
||||||
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`
|
||||||
|
|
@ -27,7 +26,7 @@ Implementation plans are the source of truth:
|
||||||
- `status <name> --json`
|
- `status <name> --json`
|
||||||
- `logs <name> --lines <n> --follow/--no-follow`
|
- `logs <name> --lines <n> --follow/--no-follow`
|
||||||
- The web app calls host operations through `l4d2ctl`, not direct `l4d2host` imports.
|
- 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.
|
- 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.
|
||||||
|
|
@ -44,8 +43,13 @@ Implementation plans are the source of truth:
|
||||||
|
|
||||||
- `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+
|
||||||
|
|
|
||||||
72
deploy/README.md
Normal file
72
deploy/README.md
Normal 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
176
deploy/deploy-test-server.sh
Executable 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
|
||||||
3
deploy/files/etc/sudoers.d/left4me
Normal file
3
deploy/files/etc/sudoers.d/left4me
Normal 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 *
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
53
deploy/files/usr/local/libexec/left4me/left4me-journalctl
Executable file
53
deploy/files/usr/local/libexec/left4me/left4me-journalctl
Executable 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
|
||||||
44
deploy/files/usr/local/libexec/left4me/left4me-systemctl
Executable file
44
deploy/files/usr/local/libexec/left4me/left4me-systemctl
Executable 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
|
||||||
2
deploy/templates/etc/left4me/host.env
Normal file
2
deploy/templates/etc/left4me/host.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes.
|
||||||
|
LEFT4ME_ROOT=/var/lib/left4me
|
||||||
3
deploy/templates/etc/left4me/web.env.template
Normal file
3
deploy/templates/etc/left4me/web.env.template
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
|
||||||
|
SECRET_KEY=replace-with-generated-secret
|
||||||
|
JOB_WORKER_THREADS=4
|
||||||
186
deploy/tests/test_deploy_artifacts.py
Normal file
186
deploy/tests/test_deploy_artifacts.py
Normal 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)
|
||||||
1136
docs/superpowers/plans/2026-05-06-left4me-deployment.md
Normal file
1136
docs/superpowers/plans/2026-05-06-left4me-deployment.md
Normal file
File diff suppressed because it is too large
Load diff
244
docs/superpowers/specs/2026-05-06-left4me-deployment-design.md
Normal file
244
docs/superpowers/specs/2026-05-06-left4me-deployment-design.md
Normal 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.
|
||||||
|
|
@ -21,12 +21,24 @@ Subprocess failures are fail-fast. Raw stderr is written to stderr and the comma
|
||||||
|
|
||||||
## Runtime Paths
|
## 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`
|
- `${LEFT4ME_ROOT}/installation`
|
||||||
- `/opt/l4d2/overlays/<overlay>`
|
- `${LEFT4ME_ROOT}/overlays/<overlay-ref>`
|
||||||
- `/opt/l4d2/instances/<name>`
|
- `${LEFT4ME_ROOT}/instances/<name>`
|
||||||
- `/opt/l4d2/runtime/<name>/{upper,work,merged}`
|
- `${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
|
## 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.
|
- `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>`.
|
||||||
- `/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:
|
Example Debian setup:
|
||||||
|
|
||||||
|
|
@ -52,8 +64,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 /opt/l4d2/{installation,overlays,instances,runtime}
|
sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp}
|
||||||
sudo chown -R "$USER:$USER" /opt/steamcmd /opt/l4d2
|
sudo chown -R "$USER:$USER" /opt/steamcmd /var/lib/left4me
|
||||||
sudo loginctl enable-linger "$USER"
|
sudo loginctl enable-linger "$USER"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,5 +82,8 @@ def logs(
|
||||||
lines: int = typer.Option(200, "--lines"),
|
lines: int = typer.Option(200, "--lines"),
|
||||||
follow: bool = typer.Option(True, "--follow/--no-follow"),
|
follow: bool = typer.Option(True, "--follow/--no-follow"),
|
||||||
) -> None:
|
) -> None:
|
||||||
for line in stream_instance_logs(name, lines=lines, follow=follow):
|
try:
|
||||||
typer.echo(line)
|
for line in stream_instance_logs(name, lines=lines, follow=follow):
|
||||||
|
typer.echo(line)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
_exit_from_subprocess_error(exc)
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,26 @@ 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 = Path("/opt/l4d2")
|
DEFAULT_ROOT = DEFAULT_LEFT4ME_ROOT
|
||||||
|
|
||||||
|
|
||||||
def initialize_instance(
|
def initialize_instance(
|
||||||
name: str,
|
name: str,
|
||||||
spec_path: Path,
|
spec_path: Path,
|
||||||
*,
|
*,
|
||||||
root: Path = DEFAULT_ROOT,
|
root: Path | None = None,
|
||||||
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
|
||||||
|
|
@ -29,7 +31,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(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"))
|
lowerdirs.append(str(root / "installation"))
|
||||||
|
|
||||||
instance_env = "\n".join(
|
instance_env = "\n".join(
|
||||||
|
|
@ -44,10 +46,6 @@ 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] = {}
|
||||||
|
|
@ -62,12 +60,13 @@ def _load_instance_env(path: Path) -> dict[str, str]:
|
||||||
def start_instance(
|
def start_instance(
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
root: Path = DEFAULT_ROOT,
|
root: Path | None = None,
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -94,8 +93,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)
|
||||||
|
|
||||||
run_command(
|
start_service(
|
||||||
["systemctl", "--user", "start", 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,
|
||||||
|
|
@ -106,14 +105,15 @@ def start_instance(
|
||||||
def stop_instance(
|
def stop_instance(
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
root: Path = DEFAULT_ROOT,
|
root: Path | None = None,
|
||||||
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:
|
||||||
run_command(
|
root = get_left4me_root() if root is None else Path(root)
|
||||||
["systemctl", "--user", "stop", f"l4d2@{name}.service"],
|
stop_service(
|
||||||
|
name,
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
passthrough=passthrough,
|
passthrough=passthrough,
|
||||||
|
|
@ -131,20 +131,21 @@ def stop_instance(
|
||||||
def delete_instance(
|
def delete_instance(
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
root: Path = DEFAULT_ROOT,
|
root: Path | None = None,
|
||||||
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
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,7 @@
|
||||||
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]:
|
||||||
command = [
|
yield from stream_journal(name, lines=lines, follow=follow)
|
||||||
"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)
|
|
||||||
|
|
|
||||||
50
l4d2host/paths.py
Normal file
50
l4d2host/paths.py
Normal 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
|
||||||
|
|
@ -17,10 +17,7 @@ dependencies = [
|
||||||
l4d2ctl = "l4d2host.cli:app"
|
l4d2ctl = "l4d2host.cli:app"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["l4d2host", "l4d2host.fs", "l4d2host.templates"]
|
packages = ["l4d2host", "l4d2host.fs"]
|
||||||
|
|
||||||
[tool.setuptools.package-dir]
|
[tool.setuptools.package-dir]
|
||||||
l4d2host = "."
|
l4d2host = "."
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
|
||||||
"l4d2host.templates" = ["*.service"]
|
|
||||||
|
|
|
||||||
85
l4d2host/service_control.py
Normal file
85
l4d2host/service_control.py
Normal 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))
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from l4d2host.process import run_command
|
from l4d2host.service_control import show_service
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -21,17 +21,7 @@ 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 = run_command(
|
result = show_service(name)
|
||||||
[
|
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
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 = Path("/opt/l4d2/installation"), steamcmd: str = "steamcmd"):
|
def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"):
|
||||||
self.install_dir = install_dir
|
self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir)
|
||||||
self.steamcmd = steamcmd
|
self.steamcmd = steamcmd
|
||||||
|
|
||||||
def install_or_update(
|
def install_or_update(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -56,3 +56,17 @@ def test_logs_command_streams_lines(monkeypatch) -> None:
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output.splitlines() == ["alpha:25:False", "ready"]
|
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
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,14 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,13 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,12 @@ 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][: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:
|
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)
|
(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 ["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)
|
assert not any(call[0] == "fusermount3" for call in calls)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -8,7 +9,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: "")
|
self.stderr = SimpleNamespace(readline=lambda: "", read=lambda: "")
|
||||||
self._lines = iter(lines)
|
self._lines = iter(lines)
|
||||||
self.terminated = False
|
self.terminated = False
|
||||||
self.waited = False
|
self.waited = False
|
||||||
|
|
@ -22,7 +23,7 @@ class DummyProcess:
|
||||||
def terminate(self) -> None:
|
def terminate(self) -> None:
|
||||||
self.terminated = True
|
self.terminated = True
|
||||||
|
|
||||||
def wait(self, timeout: int) -> None:
|
def wait(self, timeout: int | None = None) -> None:
|
||||||
del timeout
|
del timeout
|
||||||
self.waited = True
|
self.waited = True
|
||||||
|
|
||||||
|
|
@ -35,8 +36,52 @@ def test_stream_instance_logs_yields_lines(monkeypatch: pytest.MonkeyPatch) -> N
|
||||||
del kwargs
|
del kwargs
|
||||||
return proc
|
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))
|
lines = list(stream_instance_logs("alpha", lines=10, follow=False))
|
||||||
assert lines == ["line1", "line2"]
|
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"
|
||||||
|
|
|
||||||
61
l4d2host/tests/test_paths.py
Normal file
61
l4d2host/tests/test_paths.py
Normal 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)
|
||||||
|
|
@ -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:
|
def test_status_mapping() -> None:
|
||||||
|
|
@ -6,3 +10,19 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,21 @@ 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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
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 DEFAULT_CONFIG
|
from l4d2web.config import load_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
|
||||||
|
|
@ -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
|
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(DEFAULT_CONFIG)
|
app.config.from_mapping(load_config())
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
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:
|
if test_config is not None:
|
||||||
app.config.update(test_config)
|
app.config.update(test_config)
|
||||||
|
|
||||||
os.environ["DATABASE_URL"] = str(app.config["DATABASE_URL"])
|
with app.app_context():
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def csrf_protect() -> Response | None:
|
def csrf_protect() -> Response | None:
|
||||||
|
|
@ -62,8 +67,13 @@ 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()
|
||||||
recover_stale_jobs()
|
should_start_workers = (
|
||||||
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
|
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)
|
start_job_workers(app)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -15,5 +19,28 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
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",
|
||||||
|
|
@ -8,3 +11,20 @@ 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")),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -11,6 +12,8 @@ _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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -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 Overlay
|
from l4d2web.models import BlueprintOverlay, Overlay
|
||||||
from l4d2web.services.security import validate_overlay_path
|
from l4d2web.services.security import validate_overlay_ref
|
||||||
|
|
||||||
|
|
||||||
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", "").strip()
|
raw_path = request.form.get("path", "")
|
||||||
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:
|
||||||
validated_path = validate_overlay_path(raw_path)
|
overlay_ref = validate_overlay_ref(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=str(validated_path)))
|
db.add(Overlay(name=name, path=overlay_ref))
|
||||||
|
|
||||||
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", "").strip()
|
raw_path = request.form.get("path", "")
|
||||||
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:
|
||||||
validated_path = validate_overlay_path(raw_path)
|
overlay_ref = validate_overlay_ref(raw_path)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response(str(exc), status=400)
|
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))
|
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 = str(validated_path)
|
overlay.path = overlay_ref
|
||||||
|
|
||||||
return redirect("/overlays")
|
return redirect("/overlays")
|
||||||
|
|
||||||
|
|
@ -62,5 +65,8 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ class ServerStatus:
|
||||||
raw_sub_state: str
|
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 {
|
return {
|
||||||
"port": server.port,
|
"port": server.port,
|
||||||
"overlays": overlay_names,
|
"overlays": overlay_refs,
|
||||||
"arguments": json.loads(blueprint.arguments),
|
"arguments": json.loads(blueprint.arguments),
|
||||||
"config": json.loads(blueprint.config),
|
"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")
|
raise ValueError("blueprint not found")
|
||||||
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(Overlay.name)
|
select(Overlay.path)
|
||||||
.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_names = [row[0] for row in rows]
|
overlay_refs = [row[0] for row in rows]
|
||||||
return server, blueprint, overlay_names
|
return server, blueprint, overlay_refs
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -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:
|
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)
|
server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id)
|
||||||
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_names))
|
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
|
||||||
try:
|
try:
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)],
|
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
from pathlib import Path
|
def validate_overlay_ref(raw: str) -> str:
|
||||||
|
if raw != raw.strip():
|
||||||
|
raise ValueError("overlay ref must not have leading or trailing whitespace")
|
||||||
OVERLAY_ROOT = Path("/opt/l4d2/overlays").resolve()
|
if not raw:
|
||||||
|
raise ValueError("overlay ref must not be empty")
|
||||||
|
if "\\" in raw:
|
||||||
def validate_overlay_path(raw: str) -> Path:
|
raise ValueError("overlay ref must use forward slashes")
|
||||||
path = Path(raw).resolve()
|
if raw.startswith("/"):
|
||||||
if OVERLAY_ROOT not in path.parents and path != OVERLAY_ROOT:
|
raise ValueError("overlay ref must be relative")
|
||||||
raise ValueError("overlay path must be under /opt/l4d2/overlays")
|
if any(component in {"", ".", ".."} for component in raw.split("/")):
|
||||||
return path
|
raise ValueError("overlay ref must not contain empty, current, or parent components")
|
||||||
|
return raw
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,47 @@ 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
|
||||||
|
|
|
||||||
38
l4d2web/tests/test_config.py
Normal file
38
l4d2web/tests/test_config.py
Normal 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 == []
|
||||||
|
|
@ -22,7 +22,7 @@ def server_with_blueprint(tmp_path, monkeypatch):
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard")
|
overlay = Overlay(name="Standard Overlay", path="standard")
|
||||||
session.add(overlay)
|
session.add(overlay)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
@ -55,7 +55,10 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
|
||||||
del kwargs
|
del kwargs
|
||||||
calls.append(list(cmd))
|
calls.append(list(cmd))
|
||||||
spec_path = Path(cmd[cmd.index("-f") + 1])
|
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="")
|
return CommandResult(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
|
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ 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 Overlay, User
|
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, User
|
||||||
|
from l4d2web.services.security import validate_overlay_ref
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -35,7 +36,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="/opt/l4d2/overlays/standard"))
|
session.add(Overlay(name="standard", path="standard"))
|
||||||
session.flush()
|
session.flush()
|
||||||
user_id = user.id
|
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:
|
def test_admin_can_create_overlay(admin_client) -> None:
|
||||||
response = admin_client.post(
|
response = admin_client.post(
|
||||||
"/overlays",
|
"/overlays",
|
||||||
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
|
data={"name": "standard", "path": "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_path_must_be_under_root(admin_client) -> None:
|
def test_overlay_ref_must_be_relative(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"},
|
||||||
|
|
@ -83,10 +84,25 @@ def test_overlay_path_must_be_under_root(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": "/opt/l4d2/overlays/bad"},
|
data={"name": "bad", "path": "bad"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
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:
|
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": "/opt/l4d2/overlays/standard"},
|
data={"name": "standard", "path": "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": "/opt/l4d2/overlays/edited"},
|
data={"name": "edited", "path": "edited"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert update.status_code == 302
|
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"},
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue