1136 lines
38 KiB
Markdown
1136 lines
38 KiB
Markdown
# Left4me Deployment Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add a production-like test deployment path that installs the current working tree on a Linux host as `left4me`, with configurable runtime state, root-owned systemd units, and constrained sudo helpers for game-server control.
|
|
|
|
**Architecture:** Runtime state is rooted at `LEFT4ME_ROOT`, defaulting to `/var/lib/left4me`, while deployed code lives directly in `/opt/left4me` with its virtualenv at `/opt/left4me/.venv`. The web app runs as a global system service, and each game server runs as a root-owned global template unit `left4me-server@.service` under `/usr/local/lib/systemd/system/`. `l4d2ctl` manages server units through allowlisted helper commands invoked with `sudo -n`, not by writing user units.
|
|
|
|
**Tech Stack:** Python 3.12+, Typer, Flask, SQLAlchemy, pytest, POSIX shell, systemd, sudo, SteamCMD, fuse-overlayfs.
|
|
|
|
---
|
|
|
|
> **Approval gate:** This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation.
|
|
|
|
## Source Design
|
|
|
|
- `docs/superpowers/specs/2026-05-06-left4me-deployment-design.md`
|
|
|
|
## File Map
|
|
|
|
- `l4d2host/paths.py`: central `LEFT4ME_ROOT` and safe overlay-ref resolution.
|
|
- `l4d2host/instances.py`: derive installation, overlays, instances, and runtime directories from `LEFT4ME_ROOT`; stop installing user units.
|
|
- `l4d2host/steam_install.py`: install SteamCMD app content into `${LEFT4ME_ROOT}/installation` by default.
|
|
- `l4d2host/service_control.py`: call constrained sudo helpers for start, stop, status, and logs.
|
|
- `l4d2host/status.py`: parse status from `left4me-systemctl show` helper output.
|
|
- `l4d2host/logs.py`: stream logs through `left4me-journalctl` helper.
|
|
- `l4d2host/pyproject.toml`: remove the user-unit template package data if the template file is removed.
|
|
- `l4d2host/tests/test_paths.py`: cover root config and overlay-ref validation.
|
|
- `l4d2host/tests/test_initialize.py`: cover initialized paths under the configured root.
|
|
- `l4d2host/tests/test_lifecycle.py`: cover helper-based start/stop/delete commands.
|
|
- `l4d2host/tests/test_status.py`: cover helper-based status parsing.
|
|
- `l4d2host/tests/test_logs.py`: cover helper-based log streaming command construction.
|
|
- `l4d2web/config.py`: read deployment config from environment.
|
|
- `l4d2web/cli.py`: add non-public user/admin bootstrap command.
|
|
- `l4d2web/services/security.py`: validate overlay refs rather than absolute overlay paths.
|
|
- `l4d2web/routes/overlay_routes.py`: store validated overlay refs in the existing `Overlay.path` column.
|
|
- `l4d2web/services/l4d2_facade.py`: emit overlay refs from `Overlay.path` into generated host specs.
|
|
- `l4d2web/pyproject.toml`: include `gunicorn` for the systemd web service.
|
|
- `l4d2web/tests/test_config.py`: cover environment-derived config.
|
|
- `l4d2web/tests/test_auth.py`: cover user/admin bootstrap CLI.
|
|
- `l4d2web/tests/test_overlays.py`: cover safe overlay refs.
|
|
- `l4d2web/tests/test_l4d2_facade.py`: cover generated specs using overlay refs.
|
|
- `deploy/files/usr/local/lib/systemd/system/left4me-web.service`: global web systemd service.
|
|
- `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`: global per-server systemd template.
|
|
- `deploy/files/usr/local/libexec/left4me/left4me-systemctl`: root-run helper for validated service actions.
|
|
- `deploy/files/usr/local/libexec/left4me/left4me-journalctl`: root-run helper for validated journal reads.
|
|
- `deploy/files/etc/sudoers.d/left4me`: allowlist for the helper commands.
|
|
- `deploy/templates/etc/left4me/host.env`: default host env file.
|
|
- `deploy/templates/etc/left4me/web.env.template`: web env template without committed secrets.
|
|
- `deploy/deploy-test-server.sh`: idempotent SSH deployment script for a sudo-capable deploy user.
|
|
- `deploy/tests/test_deploy_artifacts.py`: static checks for deployment artifacts.
|
|
- `deploy/README.md`: operator documentation for the test deployment.
|
|
- `README.md`, `l4d2host/README.md`, `l4d2web/README.md`, `AGENTS.md`: document the deployment contract changes.
|
|
|
|
## Locked Deployment Layout
|
|
|
|
Remote runtime user:
|
|
|
|
```text
|
|
user: left4me
|
|
home: /var/lib/left4me
|
|
shell: /usr/sbin/nologin
|
|
```
|
|
|
|
Remote paths:
|
|
|
|
```text
|
|
/etc/left4me/host.env
|
|
/etc/left4me/web.env
|
|
/opt/left4me/.venv
|
|
/opt/left4me/<repository contents>
|
|
/var/lib/left4me/left4me.db
|
|
/var/lib/left4me/installation
|
|
/var/lib/left4me/overlays
|
|
/var/lib/left4me/instances
|
|
/var/lib/left4me/runtime
|
|
/var/lib/left4me/tmp
|
|
/usr/local/lib/systemd/system/left4me-web.service
|
|
/usr/local/lib/systemd/system/left4me-server@.service
|
|
/usr/local/libexec/left4me/left4me-systemctl
|
|
/usr/local/libexec/left4me/left4me-journalctl
|
|
/etc/sudoers.d/left4me
|
|
```
|
|
|
|
Overlay refs are relative refs under `${LEFT4ME_ROOT}/overlays`. Valid examples are `standard`, `competitive/base`, and `users/42/custom`. Absolute paths, `..`, empty components, and symlink escapes are rejected.
|
|
|
|
### Task 1: Add Configurable Host Root And Overlay Refs
|
|
|
|
**Files:**
|
|
- Create: `l4d2host/paths.py`
|
|
- Modify: `l4d2host/instances.py`
|
|
- Modify: `l4d2host/steam_install.py`
|
|
- Create: `l4d2host/tests/test_paths.py`
|
|
- Modify: `l4d2host/tests/test_initialize.py`
|
|
- Modify: `l4d2host/tests/test_install.py`
|
|
|
|
- [ ] **Step 1: Write failing host path tests**
|
|
|
|
Create `l4d2host/tests/test_paths.py`:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from l4d2host.paths import get_left4me_root, overlay_path, validate_overlay_ref
|
|
|
|
|
|
def test_left4me_root_defaults_to_var_lib(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("LEFT4ME_ROOT", raising=False)
|
|
|
|
assert get_left4me_root() == Path("/var/lib/left4me")
|
|
|
|
|
|
def test_left4me_root_can_be_set_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("LEFT4ME_ROOT", "/srv/left4me")
|
|
|
|
assert get_left4me_root() == Path("/srv/left4me")
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["standard", "competitive/base", "users/42/custom"])
|
|
def test_overlay_ref_accepts_safe_relative_refs(ref: str) -> None:
|
|
assert validate_overlay_ref(ref) == ref
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["", "/tmp/bad", "../bad", "bad/../evil", "bad//evil", " bad"])
|
|
def test_overlay_ref_rejects_unsafe_refs(ref: str) -> None:
|
|
with pytest.raises(ValueError):
|
|
validate_overlay_ref(ref)
|
|
|
|
|
|
def test_overlay_path_resolves_under_root(tmp_path: Path) -> None:
|
|
assert overlay_path("standard", root=tmp_path) == tmp_path / "overlays" / "standard"
|
|
```
|
|
|
|
- [ ] **Step 2: Run path tests and verify failure**
|
|
|
|
Run: `pytest l4d2host/tests/test_paths.py -q`
|
|
|
|
Expected: FAIL because `l4d2host.paths` does not exist.
|
|
|
|
- [ ] **Step 3: Implement `l4d2host.paths`**
|
|
|
|
Create `l4d2host/paths.py`:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
import os
|
|
|
|
|
|
DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me")
|
|
|
|
|
|
def get_left4me_root() -> Path:
|
|
raw = os.environ.get("LEFT4ME_ROOT", str(DEFAULT_LEFT4ME_ROOT)).strip()
|
|
if not raw:
|
|
raise ValueError("LEFT4ME_ROOT must not be empty")
|
|
return Path(raw)
|
|
|
|
|
|
def validate_overlay_ref(raw: str) -> str:
|
|
ref = raw.strip()
|
|
if not ref:
|
|
raise ValueError("overlay ref must not be empty")
|
|
path = Path(ref)
|
|
if path.is_absolute():
|
|
raise ValueError("overlay ref must be relative")
|
|
if any(part in {"", ".", ".."} for part in path.parts):
|
|
raise ValueError("overlay ref must not contain empty, current, or parent components")
|
|
if ref != path.as_posix():
|
|
raise ValueError("overlay ref must use forward-slash path separators")
|
|
return ref
|
|
|
|
|
|
def overlay_path(ref: str, *, root: Path | None = None) -> Path:
|
|
base = root if root is not None else get_left4me_root()
|
|
safe_ref = validate_overlay_ref(ref)
|
|
candidate = (base / "overlays" / safe_ref).resolve(strict=False)
|
|
overlay_root = (base / "overlays").resolve(strict=False)
|
|
if candidate != overlay_root and overlay_root not in candidate.parents:
|
|
raise ValueError("overlay ref escapes overlay root")
|
|
return candidate
|
|
```
|
|
|
|
- [ ] **Step 4: Update host instance and install defaults**
|
|
|
|
Modify `l4d2host/instances.py` so functions take `root: Path | None = None`, resolve `root = get_left4me_root()` when omitted, and build lowerdirs with `overlay_path`:
|
|
|
|
```python
|
|
from l4d2host.paths import get_left4me_root, overlay_path
|
|
|
|
|
|
def _root_or_default(root: Path | None) -> Path:
|
|
return root if root is not None else get_left4me_root()
|
|
```
|
|
|
|
Use `_root_or_default(root)` at the top of `initialize_instance`, `start_instance`, `stop_instance`, and `delete_instance`. In `initialize_instance`, replace direct overlay path construction with:
|
|
|
|
```python
|
|
lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays]
|
|
lowerdirs.append(str(root / "installation"))
|
|
```
|
|
|
|
Modify `l4d2host/steam_install.py` so `SteamInstaller()` defaults to `${LEFT4ME_ROOT}/installation`:
|
|
|
|
```python
|
|
from l4d2host.paths import get_left4me_root
|
|
|
|
|
|
class SteamInstaller:
|
|
def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"):
|
|
self.install_dir = install_dir if install_dir is not None else get_left4me_root() / "installation"
|
|
self.steamcmd = steamcmd
|
|
```
|
|
|
|
- [ ] **Step 5: Update initialize and install tests**
|
|
|
|
In `l4d2host/tests/test_initialize.py`, add:
|
|
|
|
```python
|
|
def test_initialize_uses_configured_left4me_root(tmp_path: Path, monkeypatch) -> None:
|
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
|
spec = tmp_path / "spec.yaml"
|
|
spec.write_text("port: 27015\noverlays: [standard]\n")
|
|
|
|
initialize_instance("alpha", spec)
|
|
|
|
env = (tmp_path / "instances/alpha/instance.env").read_text()
|
|
assert f"L4D2_LOWERDIRS={tmp_path}/overlays/standard:{tmp_path}/installation" in env
|
|
```
|
|
|
|
In `l4d2host/tests/test_install.py`, add:
|
|
|
|
```python
|
|
def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
|
calls = []
|
|
monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: calls.append(cmd))
|
|
|
|
SteamInstaller().install_or_update()
|
|
|
|
assert str(tmp_path / "installation") in calls[0]
|
|
```
|
|
|
|
- [ ] **Step 6: Run host path tests and verify pass**
|
|
|
|
Run: `pytest l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py -q`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit configurable host root**
|
|
|
|
```bash
|
|
git add l4d2host/paths.py l4d2host/instances.py l4d2host/steam_install.py l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py
|
|
git commit -m "feat(l4d2): configure host state root"
|
|
```
|
|
|
|
### Task 2: Switch Host Control To Global Unit Helpers
|
|
|
|
**Files:**
|
|
- Create: `l4d2host/service_control.py`
|
|
- Modify: `l4d2host/instances.py`
|
|
- Modify: `l4d2host/status.py`
|
|
- Modify: `l4d2host/logs.py`
|
|
- Modify: `l4d2host/pyproject.toml`
|
|
- Delete: `l4d2host/systemd_user.py`
|
|
- Delete: `l4d2host/templates/l4d2@.service`
|
|
- Modify: `l4d2host/tests/test_lifecycle.py`
|
|
- Modify: `l4d2host/tests/test_status.py`
|
|
- Modify: `l4d2host/tests/test_logs.py`
|
|
|
|
- [ ] **Step 1: Write failing service-control tests**
|
|
|
|
Create or extend `l4d2host/tests/test_status.py` with:
|
|
|
|
```python
|
|
from l4d2host.status import get_instance_status
|
|
|
|
|
|
def test_status_uses_left4me_systemctl_helper(monkeypatch) -> None:
|
|
calls = []
|
|
|
|
def fake_run_command(cmd, **kwargs):
|
|
del kwargs
|
|
calls.append(cmd)
|
|
return type("Result", (), {"stdout": "ActiveState=active\nSubState=running\n"})()
|
|
|
|
monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)
|
|
|
|
status = get_instance_status("alpha")
|
|
|
|
assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "show", "alpha"]]
|
|
assert status.state == "running"
|
|
```
|
|
|
|
Extend `l4d2host/tests/test_logs.py` with:
|
|
|
|
```python
|
|
def test_logs_use_left4me_journalctl_helper(monkeypatch) -> None:
|
|
calls = []
|
|
|
|
def fake_stream(cmd):
|
|
calls.append(cmd)
|
|
return iter(["one"])
|
|
|
|
monkeypatch.setattr("l4d2host.service_control.stream_command", fake_stream)
|
|
|
|
from l4d2host.logs import stream_instance_logs
|
|
|
|
assert list(stream_instance_logs("alpha", lines=25, follow=False)) == ["one"]
|
|
assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-journalctl", "alpha", "--lines", "25", "--no-follow"]]
|
|
```
|
|
|
|
Update `l4d2host/tests/test_lifecycle.py` start/stop expectations to use `left4me-systemctl` helper instead of `systemctl --user`.
|
|
|
|
- [ ] **Step 2: Run service-control tests and verify failure**
|
|
|
|
Run: `pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q`
|
|
|
|
Expected: FAIL because `l4d2host.service_control` does not exist and lifecycle code still uses user units.
|
|
|
|
- [ ] **Step 3: Implement service-control module**
|
|
|
|
Create `l4d2host/service_control.py`:
|
|
|
|
```python
|
|
import subprocess
|
|
from typing import Iterator
|
|
|
|
from l4d2host.process import run_command
|
|
|
|
|
|
SYSTEMCTL_HELPER = "/usr/local/libexec/left4me/left4me-systemctl"
|
|
JOURNALCTL_HELPER = "/usr/local/libexec/left4me/left4me-journalctl"
|
|
|
|
|
|
def systemctl_command(action: str, name: str) -> list[str]:
|
|
return ["sudo", "-n", SYSTEMCTL_HELPER, action, name]
|
|
|
|
|
|
def journalctl_command(name: str, *, lines: int = 200, follow: bool = True) -> list[str]:
|
|
return [
|
|
"sudo",
|
|
"-n",
|
|
JOURNALCTL_HELPER,
|
|
name,
|
|
"--lines",
|
|
str(lines),
|
|
"--follow" if follow else "--no-follow",
|
|
]
|
|
|
|
|
|
def start_service(name: str, **kwargs) -> None:
|
|
run_command(systemctl_command("start", name), **kwargs)
|
|
|
|
|
|
def stop_service(name: str, **kwargs) -> None:
|
|
run_command(systemctl_command("stop", name), **kwargs)
|
|
|
|
|
|
def show_service(name: str):
|
|
return run_command(systemctl_command("show", name))
|
|
|
|
|
|
def stream_command(cmd: list[str]) -> Iterator[str]:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
|
|
try:
|
|
if proc.stdout is None:
|
|
return
|
|
for raw in iter(proc.stdout.readline, ""):
|
|
yield raw.rstrip("\n")
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
|
|
|
|
def stream_journal(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
|
|
return stream_command(journalctl_command(name, lines=lines, follow=follow))
|
|
```
|
|
|
|
- [ ] **Step 4: Update host lifecycle/status/log implementations**
|
|
|
|
In `l4d2host/instances.py`, remove `ensure_template_unit` and `daemon_reload` imports and calls. Replace service operations:
|
|
|
|
```python
|
|
from l4d2host.service_control import start_service, stop_service
|
|
```
|
|
|
|
Use:
|
|
|
|
```python
|
|
start_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel)
|
|
```
|
|
|
|
and:
|
|
|
|
```python
|
|
stop_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel)
|
|
```
|
|
|
|
In `delete_instance`, call `stop_service(...)` before unmount/removal when instance or runtime exists.
|
|
|
|
In `l4d2host/status.py`, replace direct `systemctl --user show` with:
|
|
|
|
```python
|
|
from l4d2host.service_control import show_service
|
|
```
|
|
|
|
and call `result = show_service(name)`.
|
|
|
|
In `l4d2host/logs.py`, replace direct `journalctl --user` with:
|
|
|
|
```python
|
|
from l4d2host.service_control import stream_journal
|
|
|
|
|
|
def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
|
|
return stream_journal(name, lines=lines, follow=follow)
|
|
```
|
|
|
|
Delete `l4d2host/systemd_user.py` and `l4d2host/templates/l4d2@.service`. In `l4d2host/pyproject.toml`, remove `"l4d2host.templates"` from `packages` and remove the `tool.setuptools.package-data` entry for templates.
|
|
|
|
- [ ] **Step 5: Run host service-control tests and verify pass**
|
|
|
|
Run: `pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit global helper service boundary**
|
|
|
|
```bash
|
|
git add l4d2host/service_control.py l4d2host/instances.py l4d2host/status.py l4d2host/logs.py l4d2host/pyproject.toml l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py
|
|
git rm l4d2host/systemd_user.py l4d2host/templates/l4d2@.service
|
|
git commit -m "feat(l4d2): manage servers through global unit helpers"
|
|
```
|
|
|
|
### Task 3: Add Deployment Artifacts
|
|
|
|
**Files:**
|
|
- Create: `deploy/files/usr/local/lib/systemd/system/left4me-web.service`
|
|
- Create: `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`
|
|
- Create: `deploy/files/usr/local/libexec/left4me/left4me-systemctl`
|
|
- Create: `deploy/files/usr/local/libexec/left4me/left4me-journalctl`
|
|
- Create: `deploy/files/etc/sudoers.d/left4me`
|
|
- Create: `deploy/templates/etc/left4me/host.env`
|
|
- Create: `deploy/templates/etc/left4me/web.env.template`
|
|
- Create: `deploy/tests/test_deploy_artifacts.py`
|
|
|
|
- [ ] **Step 1: Write failing deployment artifact tests**
|
|
|
|
Create `deploy/tests/test_deploy_artifacts.py`:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def read(path: str) -> str:
|
|
return (ROOT / path).read_text()
|
|
|
|
|
|
def test_global_unit_names_are_product_level() -> None:
|
|
assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-web.service").exists()
|
|
assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-server@.service").exists()
|
|
|
|
|
|
def test_web_unit_uses_left4me_paths_and_env_files() -> None:
|
|
body = read("deploy/files/usr/local/lib/systemd/system/left4me-web.service")
|
|
assert "User=left4me" in body
|
|
assert "WorkingDirectory=/opt/left4me" in body
|
|
assert "EnvironmentFile=/etc/left4me/host.env" in body
|
|
assert "EnvironmentFile=/etc/left4me/web.env" in body
|
|
assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in body
|
|
|
|
|
|
def test_server_unit_runs_as_left4me_and_has_hardening() -> None:
|
|
body = read("deploy/files/usr/local/lib/systemd/system/left4me-server@.service")
|
|
assert "User=left4me" in body
|
|
assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in body
|
|
assert "WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2" in body
|
|
assert "NoNewPrivileges=true" in body
|
|
assert "ProtectSystem=strict" in body
|
|
assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in body
|
|
|
|
|
|
def test_helper_scripts_reject_bad_arguments_without_systemctl() -> None:
|
|
for helper in [
|
|
ROOT / "deploy/files/usr/local/libexec/left4me/left4me-systemctl",
|
|
ROOT / "deploy/files/usr/local/libexec/left4me/left4me-journalctl",
|
|
]:
|
|
subprocess.run(["sh", "-n", str(helper)], check=True)
|
|
result = subprocess.run(["sh", str(helper), "bad/action", "../evil"], capture_output=True, text=True)
|
|
assert result.returncode != 0
|
|
|
|
|
|
def test_sudoers_allows_only_left4me_helpers() -> None:
|
|
body = read("deploy/files/etc/sudoers.d/left4me")
|
|
assert "/usr/local/libexec/left4me/left4me-systemctl *" in body
|
|
assert "/usr/local/libexec/left4me/left4me-journalctl *" in body
|
|
assert "/bin/systemctl" not in body
|
|
assert "/bin/journalctl" not in body
|
|
```
|
|
|
|
- [ ] **Step 2: Run deployment artifact tests and verify failure**
|
|
|
|
Run: `pytest deploy/tests/test_deploy_artifacts.py -q`
|
|
|
|
Expected: FAIL because deployment artifact files do not exist.
|
|
|
|
- [ ] **Step 3: Add web service unit**
|
|
|
|
Create `deploy/files/usr/local/lib/systemd/system/left4me-web.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=left4me web application
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=left4me
|
|
Group=left4me
|
|
WorkingDirectory=/opt/left4me
|
|
Environment=HOME=/var/lib/left4me
|
|
EnvironmentFile=/etc/left4me/host.env
|
|
EnvironmentFile=/etc/left4me/web.env
|
|
ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 1 --threads 8 --bind 0.0.0.0:8000 'l4d2web.app:create_app()'
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectSystem=full
|
|
ReadWritePaths=/var/lib/left4me
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
- [ ] **Step 4: Add server service unit**
|
|
|
|
Create `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=left4me server instance %i
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=left4me
|
|
Group=left4me
|
|
EnvironmentFile=/etc/left4me/host.env
|
|
EnvironmentFile=/var/lib/left4me/instances/%i/instance.env
|
|
WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2
|
|
ExecStart=/var/lib/left4me/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} ${L4D2_ARGS}
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
ProtectHome=true
|
|
ProtectSystem=strict
|
|
ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays
|
|
ReadWritePaths=/var/lib/left4me/runtime/%i
|
|
RestrictSUIDSGID=true
|
|
LockPersonality=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
- [ ] **Step 5: Add sudo helper scripts**
|
|
|
|
Create `deploy/files/usr/local/libexec/left4me/left4me-systemctl`:
|
|
|
|
```sh
|
|
#!/bin/sh
|
|
set -eu
|
|
|
|
usage() {
|
|
printf '%s\n' 'usage: left4me-systemctl start|stop|show <server-name>' >&2
|
|
exit 64
|
|
}
|
|
|
|
validate_name() {
|
|
name=$1
|
|
case "$name" in
|
|
''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;;
|
|
esac
|
|
}
|
|
|
|
[ "$#" -eq 2 ] || usage
|
|
action=$1
|
|
name=$2
|
|
validate_name "$name"
|
|
unit="left4me-server@${name}.service"
|
|
|
|
case "$action" in
|
|
start) exec /bin/systemctl start "$unit" ;;
|
|
stop) exec /bin/systemctl stop "$unit" ;;
|
|
show) exec /bin/systemctl show "$unit" --property=ActiveState --property=SubState --no-pager ;;
|
|
*) usage ;;
|
|
esac
|
|
```
|
|
|
|
Create `deploy/files/usr/local/libexec/left4me/left4me-journalctl`:
|
|
|
|
```sh
|
|
#!/bin/sh
|
|
set -eu
|
|
|
|
usage() {
|
|
printf '%s\n' 'usage: left4me-journalctl <server-name> --lines <n> --follow|--no-follow' >&2
|
|
exit 64
|
|
}
|
|
|
|
validate_name() {
|
|
name=$1
|
|
case "$name" in
|
|
''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;;
|
|
esac
|
|
}
|
|
|
|
[ "$#" -eq 4 ] || usage
|
|
name=$1
|
|
lines_flag=$2
|
|
lines=$3
|
|
follow_flag=$4
|
|
validate_name "$name"
|
|
[ "$lines_flag" = '--lines' ] || usage
|
|
case "$lines" in ''|*[!0-9]*) usage ;; esac
|
|
|
|
unit="left4me-server@${name}.service"
|
|
|
|
case "$follow_flag" in
|
|
--follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat -f ;;
|
|
--no-follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat ;;
|
|
*) usage ;;
|
|
esac
|
|
```
|
|
|
|
- [ ] **Step 6: Add sudoers and env templates**
|
|
|
|
Create `deploy/files/etc/sudoers.d/left4me`:
|
|
|
|
```sudoers
|
|
Defaults:left4me !requiretty
|
|
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl *
|
|
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl *
|
|
```
|
|
|
|
Create `deploy/templates/etc/left4me/host.env`:
|
|
|
|
```sh
|
|
LEFT4ME_ROOT=/var/lib/left4me
|
|
```
|
|
|
|
Create `deploy/templates/etc/left4me/web.env.template`:
|
|
|
|
```sh
|
|
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
|
|
SECRET_KEY=replace-with-generated-secret
|
|
JOB_WORKER_THREADS=4
|
|
```
|
|
|
|
- [ ] **Step 7: Run deployment artifact tests and verify pass**
|
|
|
|
Run: `pytest deploy/tests/test_deploy_artifacts.py -q`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit deployment artifacts**
|
|
|
|
```bash
|
|
git add deploy/files deploy/templates deploy/tests/test_deploy_artifacts.py
|
|
git commit -m "feat(deploy): add left4me systemd and sudo artifacts"
|
|
```
|
|
|
|
### Task 4: Add Web Deployment Config And Bootstrap CLI
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/config.py`
|
|
- Modify: `l4d2web/cli.py`
|
|
- Modify: `l4d2web/services/security.py`
|
|
- Modify: `l4d2web/routes/overlay_routes.py`
|
|
- Modify: `l4d2web/services/l4d2_facade.py`
|
|
- Modify: `l4d2web/pyproject.toml`
|
|
- Create: `l4d2web/tests/test_config.py`
|
|
- Modify: `l4d2web/tests/test_auth.py`
|
|
- Modify: `l4d2web/tests/test_overlays.py`
|
|
- Modify: `l4d2web/tests/test_l4d2_facade.py`
|
|
|
|
- [ ] **Step 1: Write failing web config and bootstrap tests**
|
|
|
|
Create `l4d2web/tests/test_config.py`:
|
|
|
|
```python
|
|
from l4d2web.config import load_config
|
|
|
|
|
|
def test_load_config_uses_environment(monkeypatch) -> None:
|
|
monkeypatch.setenv("DATABASE_URL", "sqlite:////var/lib/left4me/left4me.db")
|
|
monkeypatch.setenv("SECRET_KEY", "secret-from-env")
|
|
monkeypatch.setenv("JOB_WORKER_THREADS", "2")
|
|
|
|
config = load_config()
|
|
|
|
assert config["DATABASE_URL"] == "sqlite:////var/lib/left4me/left4me.db"
|
|
assert config["SECRET_KEY"] == "secret-from-env"
|
|
assert config["JOB_WORKER_THREADS"] == 2
|
|
```
|
|
|
|
Extend `l4d2web/tests/test_auth.py`:
|
|
|
|
```python
|
|
def test_create_user_cli_creates_admin(app, monkeypatch):
|
|
monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret")
|
|
result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "created user admin" in result.output
|
|
```
|
|
|
|
Extend `l4d2web/tests/test_overlays.py`:
|
|
|
|
```python
|
|
def test_overlay_ref_must_be_relative(admin_client):
|
|
r = admin_client.post("/overlays", data={"name": "bad", "path": "/tmp/bad"})
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_admin_can_create_overlay_with_relative_ref(admin_client):
|
|
r = admin_client.post("/overlays", data={"name": "standard", "path": "standard"})
|
|
|
|
assert r.status_code == 302
|
|
```
|
|
|
|
Update `l4d2web/tests/test_l4d2_facade.py` seed data so `Overlay.path` is a relative ref, and assert generated specs contain the path ref rather than the display name.
|
|
|
|
- [ ] **Step 2: Run web config tests and verify failure**
|
|
|
|
Run: `pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q`
|
|
|
|
Expected: FAIL because `load_config`, `create-user`, and overlay-ref validation are not implemented.
|
|
|
|
- [ ] **Step 3: Implement environment config**
|
|
|
|
Modify `l4d2web/config.py`:
|
|
|
|
```python
|
|
import os
|
|
|
|
|
|
def load_config() -> dict[str, object]:
|
|
return {
|
|
"SECRET_KEY": os.getenv("SECRET_KEY", "dev"),
|
|
"DATABASE_URL": os.getenv("DATABASE_URL", "sqlite:///l4d2web.db"),
|
|
"STATUS_REFRESH_SECONDS": int(os.getenv("STATUS_REFRESH_SECONDS", "8")),
|
|
"JOB_WORKER_THREADS": int(os.getenv("JOB_WORKER_THREADS", "4")),
|
|
"JOB_WORKER_ENABLED": os.getenv("JOB_WORKER_ENABLED", "true").lower() not in {"0", "false", "no"},
|
|
"JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")),
|
|
"JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")),
|
|
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
|
|
}
|
|
|
|
|
|
DEFAULT_CONFIG = load_config()
|
|
```
|
|
|
|
Modify `l4d2web/app.py` to call `load_config()` inside `create_app()` instead of reusing import-time `DEFAULT_CONFIG`:
|
|
|
|
```python
|
|
from l4d2web.config import load_config
|
|
|
|
|
|
app.config.from_mapping(load_config())
|
|
```
|
|
|
|
- [ ] **Step 4: Implement create-user CLI**
|
|
|
|
Modify `l4d2web/cli.py`:
|
|
|
|
```python
|
|
import os
|
|
|
|
from l4d2web.auth import hash_password
|
|
|
|
|
|
@click.command("create-user")
|
|
@click.argument("username")
|
|
@click.option("--admin", is_flag=True, default=False)
|
|
def create_user(username: str, admin: bool) -> None:
|
|
password = os.environ.get("LEFT4ME_ADMIN_PASSWORD")
|
|
if password is None:
|
|
password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
with session_scope() as db:
|
|
existing = db.scalar(select(User).where(User.username == username))
|
|
if existing is not None:
|
|
raise click.ClickException("user already exists")
|
|
db.add(User(username=username, password_digest=hash_password(password), admin=admin))
|
|
click.echo(f"created user {username}")
|
|
```
|
|
|
|
Register it in `register_cli(app)`:
|
|
|
|
```python
|
|
app.cli.add_command(create_user)
|
|
```
|
|
|
|
- [ ] **Step 5: Implement web overlay refs**
|
|
|
|
Modify `l4d2web/services/security.py`:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
|
|
def validate_overlay_ref(raw: str) -> str:
|
|
ref = raw.strip()
|
|
if not ref:
|
|
raise ValueError("overlay ref must not be empty")
|
|
path = Path(ref)
|
|
if path.is_absolute():
|
|
raise ValueError("overlay ref must be relative")
|
|
if any(part in {"", ".", ".."} for part in path.parts):
|
|
raise ValueError("overlay ref must not contain empty, current, or parent components")
|
|
if ref != path.as_posix():
|
|
raise ValueError("overlay ref must use forward-slash path separators")
|
|
return ref
|
|
```
|
|
|
|
Modify `l4d2web/routes/overlay_routes.py` to import `validate_overlay_ref` and store the returned ref in `Overlay.path`:
|
|
|
|
```python
|
|
from l4d2web.services.security import validate_overlay_ref
|
|
|
|
|
|
validated_ref = validate_overlay_ref(raw_path)
|
|
db.add(Overlay(name=name, path=validated_ref))
|
|
```
|
|
|
|
Apply the same assignment in `update_overlay`.
|
|
|
|
Modify `l4d2web/services/l4d2_facade.py` so `load_server_blueprint_bundle` selects `Overlay.path` instead of `Overlay.name`:
|
|
|
|
```python
|
|
select(Overlay.path)
|
|
```
|
|
|
|
- [ ] **Step 6: Add gunicorn dependency**
|
|
|
|
Modify `l4d2web/pyproject.toml` dependencies:
|
|
|
|
```toml
|
|
dependencies = [
|
|
"Flask>=3.0",
|
|
"SQLAlchemy>=2.0",
|
|
"alembic>=1.13",
|
|
"PyYAML>=6.0",
|
|
"gunicorn>=22.0",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 7: Run web config/bootstrap tests and verify pass**
|
|
|
|
Run: `pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit web deployment config**
|
|
|
|
```bash
|
|
git add l4d2web/config.py l4d2web/app.py l4d2web/cli.py l4d2web/services/security.py l4d2web/routes/overlay_routes.py l4d2web/services/l4d2_facade.py l4d2web/pyproject.toml l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py
|
|
git commit -m "feat(l4d2-web): add deployment config and bootstrap cli"
|
|
```
|
|
|
|
### Task 5: Add Test-Server Deployment Script
|
|
|
|
**Files:**
|
|
- Create: `deploy/deploy-test-server.sh`
|
|
- Modify: `deploy/tests/test_deploy_artifacts.py`
|
|
|
|
- [ ] **Step 1: Write failing deployment script tests**
|
|
|
|
Extend `deploy/tests/test_deploy_artifacts.py`:
|
|
|
|
```python
|
|
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
|
|
script = read("deploy/deploy-test-server.sh")
|
|
assert "useradd --system --home-dir /var/lib/left4me" in script
|
|
assert "/var/lib/left4me/installation" in script
|
|
assert "/var/lib/left4me/overlays" in script
|
|
assert "/var/lib/left4me/instances" in script
|
|
assert "/var/lib/left4me/runtime" in script
|
|
assert "tar" in script
|
|
assert "pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web" in script
|
|
assert "systemctl enable --now left4me-web.service" in script
|
|
|
|
|
|
def test_deploy_script_shell_syntax() -> None:
|
|
subprocess.run(["sh", "-n", str(ROOT / "deploy/deploy-test-server.sh")], check=True)
|
|
```
|
|
|
|
- [ ] **Step 2: Run deployment script tests and verify failure**
|
|
|
|
Run: `pytest deploy/tests/test_deploy_artifacts.py -q`
|
|
|
|
Expected: FAIL because `deploy/deploy-test-server.sh` does not exist.
|
|
|
|
- [ ] **Step 3: Implement test deployment script**
|
|
|
|
Create `deploy/deploy-test-server.sh`:
|
|
|
|
```sh
|
|
#!/bin/sh
|
|
set -eu
|
|
|
|
usage() {
|
|
printf '%s\n' 'usage: deploy/deploy-test-server.sh <ssh-user@host>' >&2
|
|
exit 64
|
|
}
|
|
|
|
[ "$#" -eq 1 ] || usage
|
|
target=$1
|
|
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
archive=$(mktemp -t left4me-deploy.XXXXXX.tar.gz)
|
|
remote_archive=/tmp/left4me-deploy.tar.gz
|
|
|
|
cleanup() {
|
|
rm -f "$archive"
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
tar \
|
|
--exclude='.git' \
|
|
--exclude='__pycache__' \
|
|
--exclude='.pytest_cache' \
|
|
--exclude='*.egg-info' \
|
|
--exclude='l4d2web.db' \
|
|
--exclude='l4d2web.db-shm' \
|
|
--exclude='l4d2web.db-wal' \
|
|
-C "$repo_root" \
|
|
-czf "$archive" \
|
|
.
|
|
|
|
scp "$archive" "$target:$remote_archive"
|
|
|
|
ssh "$target" 'set -eu
|
|
if ! id left4me >/dev/null 2>&1; then
|
|
sudo useradd --system --home-dir /var/lib/left4me --create-home --shell /usr/sbin/nologin left4me
|
|
fi
|
|
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
sudo apt-get update
|
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
sudo dnf install -y python3 python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo
|
|
else
|
|
printf "%s\n" "unsupported package manager: install python3, venv tooling, fuse-overlayfs, fuse3, sudo, curl, tar, gzip" >&2
|
|
exit 1
|
|
fi
|
|
|
|
sudo mkdir -p /etc/left4me /opt/left4me /usr/local/lib/systemd/system /usr/local/libexec/left4me /var/lib/left4me/installation /var/lib/left4me/overlays /var/lib/left4me/instances /var/lib/left4me/runtime /var/lib/left4me/tmp
|
|
sudo chown -R left4me:left4me /var/lib/left4me /opt/left4me
|
|
sudo chmod 0750 /var/lib/left4me
|
|
|
|
sudo find /opt/left4me -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} +
|
|
sudo tar -xzf /tmp/left4me-deploy.tar.gz -C /opt/left4me
|
|
sudo chown -R left4me:left4me /opt/left4me
|
|
|
|
sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service
|
|
sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
|
|
sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl
|
|
sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
|
|
sudo install -m 0440 /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
|
|
sudo visudo -cf /etc/sudoers.d/left4me
|
|
|
|
sudo install -m 0644 /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env
|
|
if ! sudo test -f /etc/left4me/web.env; then
|
|
secret=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
|
printf "DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\nSECRET_KEY=%s\nJOB_WORKER_THREADS=4\n" "$secret" | sudo tee /etc/left4me/web.env >/dev/null
|
|
sudo chmod 0640 /etc/left4me/web.env
|
|
sudo chown root:left4me /etc/left4me/web.env
|
|
fi
|
|
|
|
sudo -u left4me python3 -m venv /opt/left4me/.venv
|
|
sudo -u left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip
|
|
sudo -u left4me /opt/left4me/.venv/bin/python -m pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web
|
|
|
|
sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; /opt/left4me/.venv/bin/python -c '"'"'from l4d2web.app import create_app; create_app()'"'"'"
|
|
|
|
if [ -n "${LEFT4ME_ADMIN_USERNAME:-}" ] && [ -n "${LEFT4ME_ADMIN_PASSWORD:-}" ]; then
|
|
sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; LEFT4ME_ADMIN_PASSWORD=\"$LEFT4ME_ADMIN_PASSWORD\" /opt/left4me/.venv/bin/flask --app '"'"'l4d2web.app:create_app'"'"' create-user \"$LEFT4ME_ADMIN_USERNAME\" --admin || true"
|
|
fi
|
|
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now left4me-web.service
|
|
sudo systemctl restart left4me-web.service
|
|
curl -fsS http://127.0.0.1:8000/health
|
|
'
|
|
```
|
|
|
|
- [ ] **Step 4: Run deployment script tests and verify pass**
|
|
|
|
Run: `pytest deploy/tests/test_deploy_artifacts.py -q`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit deployment script**
|
|
|
|
```bash
|
|
git add deploy/deploy-test-server.sh deploy/tests/test_deploy_artifacts.py
|
|
git commit -m "feat(deploy): add ssh test deployment script"
|
|
```
|
|
|
|
### Task 6: Document Deployment Contract And Verify
|
|
|
|
**Files:**
|
|
- Create: `deploy/README.md`
|
|
- Modify: `README.md`
|
|
- Modify: `l4d2host/README.md`
|
|
- Modify: `l4d2web/README.md`
|
|
- Modify: `AGENTS.md`
|
|
|
|
- [ ] **Step 1: Write deployment README**
|
|
|
|
Create `deploy/README.md`:
|
|
|
|
```markdown
|
|
# left4me Deployment
|
|
|
|
This directory contains the production-like test deployment for a Linux server.
|
|
|
|
## Target Layout
|
|
|
|
- `/etc/left4me/host.env`
|
|
- `/etc/left4me/web.env`
|
|
- `/opt/left4me/.venv`
|
|
- `/opt/left4me/<repository contents>`
|
|
- `/var/lib/left4me/left4me.db`
|
|
- `/var/lib/left4me/installation`
|
|
- `/var/lib/left4me/overlays`
|
|
- `/var/lib/left4me/instances`
|
|
- `/var/lib/left4me/runtime`
|
|
- `/var/lib/left4me/tmp`
|
|
- `/usr/local/lib/systemd/system/left4me-web.service`
|
|
- `/usr/local/lib/systemd/system/left4me-server@.service`
|
|
- `/usr/local/libexec/left4me/left4me-systemctl`
|
|
- `/usr/local/libexec/left4me/left4me-journalctl`
|
|
- `/etc/sudoers.d/left4me`
|
|
|
|
## Runtime User
|
|
|
|
The deployment creates a system user named `left4me` with home directory `/var/lib/left4me` and shell `/usr/sbin/nologin`.
|
|
|
|
## Running A Test Deployment
|
|
|
|
Run from the repository root:
|
|
|
|
```bash
|
|
deploy/deploy-test-server.sh deploy-user@example-host
|
|
```
|
|
|
|
The SSH user must be able to run `sudo`. The script deploys the current local working tree, installs or updates the virtualenv, preserves `/var/lib/left4me`, installs systemd units and sudo helpers, and restarts `left4me-web.service`.
|
|
|
|
To bootstrap an admin user during deployment:
|
|
|
|
```bash
|
|
LEFT4ME_ADMIN_USERNAME=admin LEFT4ME_ADMIN_PASSWORD='change-me' deploy/deploy-test-server.sh deploy-user@example-host
|
|
```
|
|
|
|
## Overlay Refs
|
|
|
|
Overlay refs are relative paths under `${LEFT4ME_ROOT}/overlays`, for example `standard`, `competitive/base`, and `users/42/custom`. Absolute paths, `..`, empty components, and symlink escapes are rejected.
|
|
```
|
|
|
|
- [ ] **Step 2: Update component docs**
|
|
|
|
Update `README.md` with a link to `deploy/README.md` and state that production-like test deployment uses `/var/lib/left4me`, `/opt/left4me`, `/etc/left4me`, and global units under `/usr/local/lib/systemd/system`.
|
|
|
|
Update `l4d2host/README.md` to document:
|
|
|
|
```text
|
|
LEFT4ME_ROOT defaults to /var/lib/left4me.
|
|
l4d2ctl start/stop/status/logs use sudo -n /usr/local/libexec/left4me/left4me-systemctl and left4me-journalctl.
|
|
Deployment or config management owns left4me-server@.service.
|
|
```
|
|
|
|
Update `l4d2web/README.md` to document:
|
|
|
|
```text
|
|
DATABASE_URL, SECRET_KEY, and JOB_WORKER_THREADS are read from environment.
|
|
The systemd deployment loads /etc/left4me/host.env and /etc/left4me/web.env.
|
|
Use flask create-user <username> --admin with LEFT4ME_ADMIN_PASSWORD for bootstrap.
|
|
```
|
|
|
|
Update `AGENTS.md` so the host path rule references `LEFT4ME_ROOT` instead of hard-coded `/opt/l4d2`, and so global unit/helper ownership is recorded.
|
|
|
|
- [ ] **Step 3: Run focused verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pytest l4d2host/tests -q
|
|
pytest l4d2web/tests -q
|
|
pytest deploy/tests -q
|
|
```
|
|
|
|
Expected: PASS for all three commands.
|
|
|
|
- [ ] **Step 4: Run code index refresh**
|
|
|
|
Run: `ccc index`
|
|
|
|
Expected: command exits 0, or reports that `ccc` is unavailable. If `ccc` is unavailable, report that the index refresh was skipped because the command is missing.
|
|
|
|
- [ ] **Step 5: Commit docs and verification updates**
|
|
|
|
```bash
|
|
git add deploy/README.md README.md l4d2host/README.md l4d2web/README.md AGENTS.md
|
|
git commit -m "docs(deploy): document left4me deployment contract"
|
|
```
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: the plan covers `left4me` user setup, `/var/lib/left4me` home/state, `/opt/left4me` source/venv, `/etc/left4me` env files, `LEFT4ME_ROOT`, global units under `/usr/local/lib/systemd/system`, product-level server unit/helper names, constrained sudo helpers, relative overlay refs, deployment from local working tree, admin bootstrap, docs, and verification.
|
|
- Placeholder scan: no task uses unresolved placeholders, unspecified error handling, or references to undefined helper names.
|
|
- Type consistency: host root is consistently `LEFT4ME_ROOT`; server systemd unit is consistently `left4me-server@.service`; helpers are consistently `left4me-systemctl` and `left4me-journalctl`; web overlay refs are stored in existing `Overlay.path` and emitted to host specs from that column.
|
|
- Scope: the plan does not execute deployment on a real server; it adds local deploy assets and code changes needed before a separate approved deployment run.
|