Every managed server now auto-injects log on / mp_logdetail 3 / logaddress_add into its generated server.cfg, streaming HL Log Standard events to a UDP listener bundled with l4d2web. The listener is deliberately capture-only — raw packets land in flat files per source address — so we can observe what L4D2 actually emits on our servers before committing to a schema or event vocabulary. Match/round/event model is a Phase 2 plan informed by that data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.4 KiB
Python
Executable file
166 lines
6.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Local dev server for inspecting the l4d2web UI without a real deploy.
|
|
|
|
Sets the same env vars the production systemd unit gets from
|
|
/etc/left4me/*.env, runs alembic upgrade head, and starts Flask. First
|
|
run also seeds an admin user + a tiny demo content set (one blueprint,
|
|
one script overlay, one files overlay) so the editor is visible at all
|
|
three call sites immediately.
|
|
|
|
Does NOT mock l4d2ctl, so server-deploy actions (initialize/start/stop)
|
|
still fail — they need real systemd + steamcmd. Blueprint + overlay
|
|
CRUD work fine.
|
|
|
|
Usage: scripts/dev-server.py [--port N] (default: 5051)
|
|
Reset: rm -rf .tmp/dev-server (next run reseeds)
|
|
"""
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
DEV_ROOT = REPO_ROOT / ".tmp" / "dev-server"
|
|
DB_FILE = DEV_ROOT / "l4d2web.db"
|
|
|
|
|
|
def seed_demo_content():
|
|
"""Insert one blueprint, one script overlay, one files overlay, one server."""
|
|
overlay_root = DEV_ROOT / "overlays"
|
|
conn = sqlite3.connect(DB_FILE)
|
|
cur = conn.cursor()
|
|
admin_id = cur.execute(
|
|
"SELECT id FROM users WHERE admin = 1 LIMIT 1"
|
|
).fetchone()[0]
|
|
now = datetime.now(UTC).isoformat()
|
|
blueprint_id = None # captured below; needed for the server row
|
|
|
|
config_lines = [
|
|
"// L4D2 dev demo — try the srccfg highlighter + autocomplete",
|
|
"sv_cheats 0",
|
|
"mp_gamemode coop",
|
|
"z_difficulty Normal",
|
|
"// Type sv_ on a new line to see the cvar popup:",
|
|
"",
|
|
]
|
|
cur.execute(
|
|
"INSERT INTO blueprints "
|
|
"(user_id, name, arguments, config, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(admin_id, "demo-srccfg", json.dumps([]),
|
|
json.dumps(config_lines), now, now),
|
|
)
|
|
blueprint_id = cur.lastrowid
|
|
|
|
script_text = (
|
|
"#!/usr/bin/env bash\n"
|
|
"# L4D2 dev demo — try the bash highlighter\n"
|
|
"set -euo pipefail\n"
|
|
"\n"
|
|
"for f in /overlay/*.cfg; do\n"
|
|
' echo "processing $f"\n'
|
|
"done\n"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO overlays (name, path, type, user_id, script, "
|
|
"last_build_status, created_at, updated_at) "
|
|
"VALUES (?, ?, 'script', NULL, ?, '', ?, ?)",
|
|
("demo-bash", "_pending", script_text, now, now),
|
|
)
|
|
script_id = cur.lastrowid
|
|
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
|
|
(str(script_id), script_id))
|
|
(overlay_root / str(script_id)).mkdir(parents=True, exist_ok=True)
|
|
|
|
cur.execute(
|
|
"INSERT INTO overlays (name, path, type, user_id, script, "
|
|
"last_build_status, created_at, updated_at) "
|
|
"VALUES (?, ?, 'files', NULL, '', '', ?, ?)",
|
|
("demo-files", "_pending", now, now),
|
|
)
|
|
files_id = cur.lastrowid
|
|
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
|
|
(str(files_id), files_id))
|
|
files_dir = overlay_root / str(files_id)
|
|
files_dir.mkdir(parents=True, exist_ok=True)
|
|
(files_dir / "test.cfg").write_text(
|
|
"// open this file in the editor modal — flip the language\n"
|
|
"// dropdown to bash to see the runtime language switch.\n"
|
|
"sv_cheats 0\n"
|
|
)
|
|
|
|
# Server: links the blueprint above to a port. desired_state stays
|
|
# "stopped" so the GUI shows it without trying to deploy via l4d2ctl.
|
|
cur.execute(
|
|
"INSERT INTO servers (user_id, blueprint_id, name, port, "
|
|
"desired_state, actual_state, last_error, created_at, updated_at) "
|
|
"VALUES (?, ?, 'demo-server', 27015, 'stopped', 'unknown', '', ?, ?)",
|
|
(admin_id, blueprint_id, now, now),
|
|
)
|
|
server_id = cur.lastrowid
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
print(f" blueprint id={blueprint_id} 'demo-srccfg' -> /blueprints/{blueprint_id}")
|
|
print(f" overlay id={script_id} 'demo-bash' (script) -> /overlays/{script_id}")
|
|
print(f" overlay id={files_id} 'demo-files' (files) -> /overlays/{files_id}")
|
|
print(f" server id={server_id} 'demo-server' (:27015) -> /servers/{server_id}")
|
|
|
|
|
|
def main():
|
|
port = 5051
|
|
if "--port" in sys.argv:
|
|
port = int(sys.argv[sys.argv.index("--port") + 1])
|
|
|
|
DEV_ROOT.mkdir(parents=True, exist_ok=True)
|
|
(DEV_ROOT / "overlays").mkdir(exist_ok=True)
|
|
(DEV_ROOT / "instances").mkdir(exist_ok=True)
|
|
|
|
# Production-equivalent env (systemd gets these from /etc/left4me/*.env).
|
|
os.environ.update({
|
|
"SECRET_KEY": "local-dev-only-not-a-secret",
|
|
"DATABASE_URL": f"sqlite:///{DB_FILE}",
|
|
"LEFT4ME_ROOT": str(DEV_ROOT),
|
|
"SESSION_COOKIE_SECURE": "0", # so http://127.0.0.1 cookies stick
|
|
"JOB_WORKER_ENABLED": "false", # don't fire (sudo-requiring) l4d2ctl
|
|
"LOG_CAPTURE_DIR": str(DEV_ROOT / "captures"),
|
|
})
|
|
|
|
print(f"==> Migrating {DB_FILE} to head", flush=True)
|
|
subprocess.run(["uv", "run", "alembic", "upgrade", "head"],
|
|
cwd=REPO_ROOT / "l4d2web", check=True, stdout=subprocess.DEVNULL)
|
|
|
|
user_count = sqlite3.connect(DB_FILE).execute(
|
|
"SELECT COUNT(*) FROM users").fetchone()[0]
|
|
admin_user = os.environ.get("LEFT4ME_DEV_ADMIN_USERNAME", "dev")
|
|
admin_pw = os.environ.get("LEFT4ME_DEV_ADMIN_PASSWORD", "devdevdev")
|
|
if user_count == 0:
|
|
print(f"==> Seeding admin user '{admin_user}'", flush=True)
|
|
subprocess.run(
|
|
["uv", "run", "flask", "--app", "l4d2web.app:create_app()",
|
|
"create-user", admin_user, "--admin"],
|
|
cwd=REPO_ROOT, check=True,
|
|
env={**os.environ, "LEFT4ME_ADMIN_PASSWORD": admin_pw},
|
|
)
|
|
print("==> Seeding demo content", flush=True)
|
|
seed_demo_content()
|
|
|
|
print(f"\n admin: {admin_user} / {admin_pw}", flush=True)
|
|
print(f" db: {DB_FILE}", flush=True)
|
|
print(f" root: {DEV_ROOT}", flush=True)
|
|
print(f"\n Ctrl-C to stop. Reset all state: rm -rf .tmp/dev-server\n", flush=True)
|
|
print(f"==> Starting Flask on http://127.0.0.1:{port}", flush=True)
|
|
|
|
# --debug enables: Python code reload on save, template auto-reload,
|
|
# verbose error pages, the Werkzeug interactive debugger. Safe here
|
|
# because we bind to 127.0.0.1 only.
|
|
os.execvp("uv", ["uv", "run", "flask",
|
|
"--app", "l4d2web.app:create_app()",
|
|
"--debug", "run", "--port", str(port)])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|