Compare commits
35 commits
9c01d4b702
...
674c4df360
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674c4df360 | ||
|
|
37a9ad68a2 | ||
|
|
9aaa26d9a9 | ||
|
|
b00a3cceea | ||
|
|
072d9f78e7 | ||
|
|
0dc61d5de4 | ||
|
|
be476112ee | ||
|
|
33899f8c17 | ||
|
|
c9cd2557fd | ||
|
|
f48d624dcc | ||
|
|
f88d07a473 | ||
|
|
465a103c3a | ||
|
|
2a440dae45 | ||
|
|
83d2a9932c | ||
|
|
b95a82b8a4 | ||
|
|
e25e7098f6 | ||
|
|
0f825686c6 | ||
|
|
a5f7b736a2 | ||
|
|
202026e11a | ||
|
|
e52219b1e9 | ||
|
|
429fee3868 | ||
|
|
8cc7f84801 | ||
|
|
f614ac05f0 | ||
|
|
0ab54b4a7d | ||
|
|
653e3212b9 | ||
|
|
25b38e633d | ||
|
|
e1b189ad3c | ||
|
|
f5094c2d9d | ||
|
|
81c6863cca | ||
|
|
16adc5c1fe | ||
|
|
6fc7f87943 | ||
|
|
13bd2e48f6 | ||
|
|
532b4c4469 | ||
|
|
fef8cc4ea6 | ||
|
|
c5758487a9 |
38 changed files with 7534 additions and 67 deletions
|
|
@ -27,12 +27,8 @@ Do not invent architecture outside these plans unless explicitly requested.
|
||||||
|
|
||||||
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
|
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
|
||||||
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
|
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
|
||||||
- **Every spec and every plan must be committed to this repo.** No exceptions. As soon as the user approves a spec or a plan, the next action is `git add` + `git commit` of that file under `docs/superpowers/`.
|
- Commit both to git as soon as the user approves them.
|
||||||
- The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable *only* while plan mode is open. The moment plan mode exits with an approved design, copy the content into `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and commit it — do not leave the design only in the scratch file.
|
- Do not leave specs or plans outside this repo. The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up under `docs/superpowers/` and be committed.
|
||||||
- Before claiming a feature is "shipped", verify both files exist in-tree:
|
|
||||||
- `git ls-files docs/superpowers/specs/ | grep <topic>` returns the spec.
|
|
||||||
- `git ls-files docs/superpowers/plans/ | grep <topic>` returns the plan.
|
|
||||||
- Push to the remote when the user asks, not automatically.
|
|
||||||
|
|
||||||
### Naming and boundaries
|
### Naming and boundaries
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-game.sl
|
||||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice
|
||||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
|
||||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
|
||||||
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service /usr/local/lib/systemd/system/left4me-workshop-refresh.service
|
||||||
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.timer /usr/local/lib/systemd/system/left4me-workshop-refresh.timer
|
||||||
|
|
||||||
# CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that
|
# CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that
|
||||||
# isn't a live game server to core 0; give game servers cores 1..N-1.
|
# isn't a live game server to core 0; give game servers cores 1..N-1.
|
||||||
|
|
@ -337,6 +339,7 @@ $sudo_cmd systemctl enable --now left4me-nft-mark.service
|
||||||
$sudo_cmd systemctl enable --now left4me-cake.service
|
$sudo_cmd systemctl enable --now left4me-cake.service
|
||||||
$sudo_cmd systemctl enable --now left4me-web.service
|
$sudo_cmd systemctl enable --now left4me-web.service
|
||||||
$sudo_cmd systemctl restart left4me-web.service
|
$sudo_cmd systemctl restart left4me-web.service
|
||||||
|
$sudo_cmd systemctl enable --now left4me-workshop-refresh.timer
|
||||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
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
|
if curl -fsS http://127.0.0.1:8000/health; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
[Unit]
|
||||||
|
Description=left4me daily workshop refresh (enqueue job)
|
||||||
|
After=network-online.target left4me-web.service
|
||||||
|
Wants=left4me-web.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=left4me
|
||||||
|
Group=left4me
|
||||||
|
WorkingDirectory=/opt/left4me
|
||||||
|
Environment=HOME=/var/lib/left4me
|
||||||
|
Environment=PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
EnvironmentFile=/etc/left4me/host.env
|
||||||
|
EnvironmentFile=/etc/left4me/web.env
|
||||||
|
ExecStart=/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=left4me daily workshop refresh
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 04:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=15min
|
||||||
|
Unit=left4me-workshop-refresh.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
|
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
|
||||||
SECRET_KEY=replace-with-generated-secret
|
SECRET_KEY=replace-with-generated-secret
|
||||||
JOB_WORKER_THREADS=4
|
JOB_WORKER_THREADS=4
|
||||||
|
|
||||||
|
# Steam Web API key for ISteamUser/GetPlayerSummaries — used by the
|
||||||
|
# live-state poller to resolve player Steam IDs to persona names + avatars
|
||||||
|
# in the server detail panel. Free at https://steamcommunity.com/dev/apikey.
|
||||||
|
# Optional: if empty, the live-state panel still shows counts/map and the
|
||||||
|
# in-game name from RCON, just with placeholder avatars.
|
||||||
|
STEAM_WEB_API_KEY=
|
||||||
|
|
|
||||||
|
|
@ -428,11 +428,13 @@ def test_env_templates_contain_required_defaults():
|
||||||
host_env = HOST_ENV.read_text()
|
host_env = HOST_ENV.read_text()
|
||||||
assert "Deployment units use fixed /var/lib/left4me paths" in host_env
|
assert "Deployment units use fixed /var/lib/left4me paths" in host_env
|
||||||
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
|
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
|
||||||
assert WEB_ENV_TEMPLATE.read_text() == (
|
web_env = WEB_ENV_TEMPLATE.read_text()
|
||||||
|
assert web_env.startswith(
|
||||||
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
|
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
|
||||||
"SECRET_KEY=replace-with-generated-secret\n"
|
"SECRET_KEY=replace-with-generated-secret\n"
|
||||||
"JOB_WORKER_THREADS=4\n"
|
"JOB_WORKER_THREADS=4\n"
|
||||||
)
|
)
|
||||||
|
assert web_env.rstrip().endswith("STEAM_WEB_API_KEY=")
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
|
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
|
||||||
|
|
|
||||||
1429
docs/superpowers/plans/2026-05-11-workshop-auto-download.md
Normal file
1429
docs/superpowers/plans/2026-05-11-workshop-auto-download.md
Normal file
File diff suppressed because it is too large
Load diff
2597
docs/superpowers/plans/2026-05-12-server-live-state-display.md
Normal file
2597
docs/superpowers/plans/2026-05-12-server-live-state-display.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,326 @@
|
||||||
|
# Workshop Auto-Download — Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a user adds workshop items to an overlay (`POST /overlays/{id}/items`), the route saves `WorkshopItem` metadata and enqueues a `build_overlay` job. The build symlinks already-cached `.vpk` files and emits `skipped: not yet downloaded` to stderr for everything else. The only thing that actually pulls bytes from Steam is the admin-only `refresh_workshop_items` job, which is a global mutex blocking all server starts, all builds, and installs.
|
||||||
|
|
||||||
|
In practice, this means freshly-added items never appear in the overlay until an admin presses a button. That isn't workable.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Newly added items get downloaded without admin action.
|
||||||
|
2. Items that authors update on Steam get re-downloaded automatically on a daily cadence.
|
||||||
|
3. Overlay owners can manually re-check / re-pull their own overlay's items.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
See "Out of Scope" at the end. In particular: the `refresh_workshop_items` global mutex stays; there is no cache GC; no per-item retry inside `download_to_cache`; no update-aware server-restart prompt.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three changes layered onto the existing scheduler. None introduce a new job type or new scheduler rule.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ User adds items │
|
||||||
|
│ POST /overlays/{id}/items │
|
||||||
|
│ ↳ fetch metadata batch (mode=add) │
|
||||||
|
│ ↳ upsert WorkshopItem rows │
|
||||||
|
│ ↳ enqueue_build_overlay ◀── already happens today │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ build_overlay job (per-overlay; not a global mutex) │
|
||||||
|
│ WorkshopBuilder.build(): │
|
||||||
|
│ 1. query overlay's items │
|
||||||
|
│ 2. for each item where cache miss / stale: ◀── NEW │
|
||||||
|
│ download_to_cache(meta) with retry+backoff │
|
||||||
|
│ stamp WorkshopItem.last_downloaded_at │
|
||||||
|
│ 3. apply symlinks (existing logic) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Owner re-checks one overlay │
|
||||||
|
│ POST /overlays/{id}/refresh ◀── NEW │
|
||||||
|
│ ↳ fetch metadata batch for this overlay only (mode=refresh) │
|
||||||
|
│ ↳ update WorkshopItem rows │
|
||||||
|
│ ↳ enqueue_build_overlay (does the download) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Daily global update │
|
||||||
|
│ systemd timer → l4d2web workshop-refresh CLI ◀── NEW │
|
||||||
|
│ ↳ inserts Job(operation='refresh_workshop_items') │
|
||||||
|
│ ↳ worker picks it up; existing global-mutex rule still applies │
|
||||||
|
│ ↳ existing _run_refresh_workshop_items code unchanged │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Key invariant: **on-add downloads run inside the per-overlay `build_overlay` job, so they do not block server starts globally.** Only the daily global refresh keeps the existing global-mutex semantics.
|
||||||
|
|
||||||
|
## Component 1 — Auto-download inside `WorkshopBuilder.build`
|
||||||
|
|
||||||
|
The builder gets a new download phase between "query items" and "apply symlinks". Today's behavior (skip-uncached with stderr warning) is replaced.
|
||||||
|
|
||||||
|
### Decision logic
|
||||||
|
|
||||||
|
For each item bound to the overlay:
|
||||||
|
|
||||||
|
1. **Skip with warning** if `file_url == ""` (Steam returned `result != 1` last time we asked — delisted, private, or hidden). Emit one stderr line `workshop item {steam_id} skipped: no file_url (steam result: {last_error})`. Do **not** fail the build — these items quietly fall out of the symlink set because they never produce a cache file. An owner can investigate via the overlay detail page where `last_error` is shown.
|
||||||
|
2. Otherwise, **download** when any of:
|
||||||
|
- `last_downloaded_at IS NULL`, or
|
||||||
|
- cache file `{steam_id}.vpk` missing, or
|
||||||
|
- cache file `(mtime, size)` doesn't match `(time_updated, file_size)` from the row.
|
||||||
|
3. Otherwise, leave the item alone (its cache file is current).
|
||||||
|
|
||||||
|
`steam_workshop.download_to_cache` already does the `(mtime, size)` check internally and short-circuits when the cache is current, so the builder can call it unconditionally for items in the "maybe download" set and trust the helper for idempotence.
|
||||||
|
|
||||||
|
### Stamping
|
||||||
|
|
||||||
|
- On success per item: `WorkshopItem.last_downloaded_at = now()`, `last_error = ""`.
|
||||||
|
- On failure per item (after retry exhaustion): `last_error` records the final exception string; the builder raises → `last_build_status='failed'`.
|
||||||
|
|
||||||
|
### What the builder does NOT do
|
||||||
|
|
||||||
|
It does not fetch fresh Steam metadata. Metadata is the responsibility of the add route, the per-overlay refresh route, and the daily refresh job. The builder is a pure function of DB state — this keeps it cheap and predictable, and lets builds run without any outbound metadata call.
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
|
||||||
|
Items are downloaded sequentially within one builder run. Different overlays' builds run in parallel under existing scheduler rules; when two overlays share an item and race, the existing `download_to_cache` idempotence handles it — the loser sees a fresh file and skips. `last_downloaded_at` writes from two concurrent builds collapse to one timestamp; no real race.
|
||||||
|
|
||||||
|
### Cancellation
|
||||||
|
|
||||||
|
The builder threads `should_cancel` into `download_to_cache` (the helper already accepts it). Cancelled mid-download deletes the `.partial` file; the symlink phase doesn't run. Cancellation during the inter-attempt sleep wakes up within ~250 ms (see retry section).
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Each item's download start / finish / error emits one line. Counts are reported in the existing summary line:
|
||||||
|
|
||||||
|
```
|
||||||
|
workshop overlay 'mycollection': downloaded=3 cached=12 skipped=1 created=14 removed=1 unchanged=11 errors=0
|
||||||
|
```
|
||||||
|
|
||||||
|
`skipped` now means "Steam can't serve this item (no file_url)" instead of the old "uncached" meaning. Uncached items get downloaded.
|
||||||
|
|
||||||
|
## Component 2 — Retry & backoff
|
||||||
|
|
||||||
|
Wraps each `download_to_cache(meta, ...)` call inside the builder.
|
||||||
|
|
||||||
|
```
|
||||||
|
attempts = 3
|
||||||
|
delays = [1s, 2s, 4s] # exponential; slept between attempts
|
||||||
|
|
||||||
|
for n in 1..attempts:
|
||||||
|
try:
|
||||||
|
download_to_cache(meta, cache_root, should_cancel=should_cancel)
|
||||||
|
break
|
||||||
|
except InterruptedError: # cancellation
|
||||||
|
raise # propagate immediately
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
if n == attempts: raise # final attempt: bubble up → job fails
|
||||||
|
on_stderr(f"workshop {meta.steam_id} attempt {n}/{attempts} failed: {exc}")
|
||||||
|
sleep_with_cancel(delays[n-1], should_cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- `sleep_with_cancel` is a small helper that polls `should_cancel` every ~250 ms during the sleep so a cancel does not wait out the full backoff window.
|
||||||
|
- The retry loop lives in the builder (`overlay_builders.py`), not in `steam_workshop.download_to_cache`. The downloader stays a single-shot primitive; retry policy is a caller concern. Keeps the helper testable without time-mocking.
|
||||||
|
- HTTP 4xx responses raised by `raise_for_status()` are `requests.HTTPError` (a `RequestException`), so they are retried too. That is intentional — 404 / 410 will fail three times quickly and surface; the cost of three failed attempts is negligible compared to the cost of users having to guess why a single transient blip killed the job.
|
||||||
|
- On final failure the job fails with the per-item error string and overlay `last_build_status='failed'`, matching the existing "never silently mount a partial overlay" rule.
|
||||||
|
|
||||||
|
## Component 3 — Per-overlay refresh
|
||||||
|
|
||||||
|
New route `POST /overlays/{id}/refresh`. Mirrors the add route's metadata-fetch path but scoped to the items already in this overlay.
|
||||||
|
|
||||||
|
### Route sketch
|
||||||
|
|
||||||
|
```python
|
||||||
|
@bp.post("/overlays/<int:overlay_id>/refresh")
|
||||||
|
@require_login
|
||||||
|
def refresh_overlay(overlay_id: int) -> Response:
|
||||||
|
user = current_user()
|
||||||
|
with session_scope() as db:
|
||||||
|
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
|
||||||
|
if err is not None: return err
|
||||||
|
steam_ids = db.scalars(
|
||||||
|
select(WorkshopItem.steam_id)
|
||||||
|
.join(OverlayWorkshopItem, OverlayWorkshopItem.workshop_item_id == WorkshopItem.id)
|
||||||
|
.where(OverlayWorkshopItem.overlay_id == overlay_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not steam_ids:
|
||||||
|
return Response("overlay has no items", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metas = steam_workshop.fetch_metadata_batch(steam_ids, mode="refresh")
|
||||||
|
except Exception as exc:
|
||||||
|
return Response(f"steam api error: {exc}", status=502)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
|
||||||
|
if err is not None: return err
|
||||||
|
metas_by_id = {m.steam_id: m for m in metas}
|
||||||
|
for steam_id in steam_ids:
|
||||||
|
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == steam_id))
|
||||||
|
meta = metas_by_id.get(steam_id)
|
||||||
|
if wi is None: continue
|
||||||
|
if meta is None:
|
||||||
|
wi.last_error = "steam returned no entry for this item"
|
||||||
|
continue
|
||||||
|
wi.title = meta.title
|
||||||
|
wi.filename = meta.filename
|
||||||
|
wi.file_url = meta.file_url
|
||||||
|
wi.file_size = meta.file_size
|
||||||
|
wi.time_updated = meta.time_updated
|
||||||
|
wi.preview_url = meta.preview_url
|
||||||
|
wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}"
|
||||||
|
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
|
||||||
|
job_id = job.id
|
||||||
|
return redirect(f"/jobs/{job_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior notes
|
||||||
|
|
||||||
|
- Permission: same `_check_workshop_overlay_access` used by add/remove — owner or admin.
|
||||||
|
- `mode="refresh"` (not `"add"`): non-L4D2 items silently drop from the batch instead of raising. An item whose `consumer_app_id` somehow changed after add will not break refresh.
|
||||||
|
- The metadata write does **not** stamp `last_downloaded_at`. That field stays bound to actual file presence — the builder's download phase stamps it after the bytes land. A refresh that finds `time_updated` advanced therefore leaves `last_downloaded_at` pointing at the prior version; the `(mtime, size)` check in `download_to_cache` sees the mismatch and the builder re-downloads. Correct by construction.
|
||||||
|
- One Steam metadata POST per click, owner-gated. No new rate-limit concern.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
A "Refresh" button next to "Add items" on the overlay detail page (workshop type only). Submits the POST; redirects to the job page like everything else.
|
||||||
|
|
||||||
|
## Component 4 — Periodic global refresh (CLI + systemd timer)
|
||||||
|
|
||||||
|
The existing `_run_refresh_workshop_items` job is complete and correct — it fetches all metadata, downloads what advanced, re-enqueues `build_overlay` for affected overlays. We only need a way to enqueue it on a schedule.
|
||||||
|
|
||||||
|
### CLI subcommand
|
||||||
|
|
||||||
|
In `l4d2web/cli.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@cli.command("workshop-refresh")
|
||||||
|
def workshop_refresh() -> None:
|
||||||
|
"""Enqueue a global workshop refresh job. Idempotent: if one is already
|
||||||
|
queued or running, prints its id and exits 0."""
|
||||||
|
with session_scope() as db:
|
||||||
|
existing = db.scalar(
|
||||||
|
select(Job).where(
|
||||||
|
Job.operation == "refresh_workshop_items",
|
||||||
|
Job.state.in_(("queued", "running", "cancelling")),
|
||||||
|
).order_by(Job.id.desc()).limit(1)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
click.echo(f"refresh_workshop_items job {existing.id} already {existing.state}")
|
||||||
|
return
|
||||||
|
job = Job(
|
||||||
|
user_id=None,
|
||||||
|
server_id=None,
|
||||||
|
operation="refresh_workshop_items",
|
||||||
|
state="queued",
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
click.echo(f"enqueued refresh_workshop_items job {job.id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema follow-up
|
||||||
|
|
||||||
|
`Job.user_id = None` for system-enqueued refreshes. The implementation plan must verify whether the column is currently nullable; if it is `NOT NULL`, the plan either (a) relaxes it to nullable (preferred — "system" is a real category) or (b) records the lowest-id admin user as the actor. The design assumes (a).
|
||||||
|
|
||||||
|
### systemd units in `deploy/`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# left4me-workshop-refresh.service
|
||||||
|
[Unit]
|
||||||
|
Description=Left4me — enqueue daily workshop refresh
|
||||||
|
After=network-online.target left4me-web.service
|
||||||
|
Requires=left4me-web.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=left4me
|
||||||
|
ExecStart=/opt/left4me/bin/l4d2web workshop-refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# left4me-workshop-refresh.timer
|
||||||
|
[Unit]
|
||||||
|
Description=Left4me — daily workshop refresh
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 04:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=15min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operator notes
|
||||||
|
|
||||||
|
- The timer enqueues; the worker decides when to actually run. The existing scheduler will defer the refresh if a server start, install, or build is in progress. Worst case the refresh starts after the conflicting job finishes — the intended behavior.
|
||||||
|
- `Persistent=true` handles "host was down at 04:00" — the unit runs on next boot. The CLI's idempotence check prevents pile-up if it fires twice.
|
||||||
|
- Deployment wires this into the existing `deploy/` install flow (in scope for the implementation plan).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Layered against the existing test files. No new test infrastructure.
|
||||||
|
|
||||||
|
### `tests/test_overlay_builders.py` — bulk of new coverage
|
||||||
|
|
||||||
|
- `test_workshop_build_downloads_uncached_items` — item with `last_downloaded_at=None` and no cache file → patched `download_to_cache` is called → file appears → symlink created → `last_downloaded_at` stamped.
|
||||||
|
- `test_workshop_build_skips_already_cached_items` — item with cache file matching `(time_updated, size)` → `download_to_cache` returns immediately (its existing idempotence) → no network → symlink created.
|
||||||
|
- `test_workshop_build_redownloads_stale_cache` — cache file exists but `(mtime, size)` mismatches the DB row → re-download happens.
|
||||||
|
- `test_workshop_build_retry_succeeds` — patched downloader fails twice then succeeds → builder finishes ok, retry messages on stderr, `last_downloaded_at` stamped. Backoff sleep monkey-patched to zero for speed.
|
||||||
|
- `test_workshop_build_retry_exhausted_fails_job` — downloader fails all three attempts → builder raises → `last_build_status='failed'`, `last_error` populated on the WorkshopItem.
|
||||||
|
- `test_workshop_build_cancellation_during_download` — `should_cancel` flips true mid-download → builder returns early, `.partial` cleaned up by `download_to_cache`, symlink phase did not run.
|
||||||
|
- `test_workshop_build_cancellation_during_backoff` — cancel flips true while sleeping between retries → wakes up within ~250 ms of the cancel.
|
||||||
|
- `test_workshop_build_skips_items_with_no_file_url` — item with `file_url=""` and `last_error="steam result 9"` → builder writes one stderr line, does NOT call `download_to_cache`, build succeeds with `last_build_status='ok'`, item is absent from the symlink set.
|
||||||
|
|
||||||
|
### `tests/test_workshop_routes.py` — new per-overlay refresh route
|
||||||
|
|
||||||
|
- `test_overlay_refresh_owner_allowed` — owner POST → `fetch_metadata_batch` called with exactly that overlay's steam_ids → WorkshopItem rows updated → `build_overlay` enqueued → 302 to /jobs/{id}.
|
||||||
|
- `test_overlay_refresh_other_user_forbidden` — non-owner non-admin → 403.
|
||||||
|
- `test_overlay_refresh_admin_can_refresh_any` — admin POST on someone else's overlay → 200/302.
|
||||||
|
- `test_overlay_refresh_steam_api_error_502` — `fetch_metadata_batch` raises → response is 502, no job enqueued.
|
||||||
|
- `test_overlay_refresh_empty_overlay_400` — overlay has no items → 400, no Steam call.
|
||||||
|
- `test_overlay_refresh_drops_missing_items_gracefully` — Steam returns nothing for one ID → that row gets `last_error="steam returned no entry…"`, build still enqueued.
|
||||||
|
|
||||||
|
### `tests/test_cli.py` — new CLI subcommand
|
||||||
|
|
||||||
|
- `test_workshop_refresh_enqueues_job` — CLI invocation inserts a queued `Job(operation='refresh_workshop_items')` and prints its id.
|
||||||
|
- `test_workshop_refresh_idempotent_when_queued` — pre-existing queued/running refresh job → second invocation prints the existing id and does not insert a duplicate.
|
||||||
|
|
||||||
|
### `tests/test_job_worker.py`
|
||||||
|
|
||||||
|
No new tests. Scheduler rules and `_run_refresh_workshop_items` are unchanged. Existing coverage holds.
|
||||||
|
|
||||||
|
### Out of test scope
|
||||||
|
|
||||||
|
The systemd timer. Validating it requires a host; smoke it on the dev host post-deploy.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- **Replacing the global mutex on `refresh_workshop_items`.** Daily refresh still blocks server starts/builds during its run. Scheduled at 04:00 with `Persistent=true`; revisit only if it observably hurts.
|
||||||
|
- **Per-item retry policy in `download_to_cache`.** Retry stays in the builder.
|
||||||
|
- **Cache GC.** Cache still grows monotonically — same as the v1 spec.
|
||||||
|
- **Steam API rate-limit handling for the metadata endpoint.** No backoff for metadata calls. Retries apply only to per-item file downloads.
|
||||||
|
- **Update-aware server restart UX.** When the daily refresh re-downloads an item mounted by a running server, the running server keeps its old mount. Notifying the user / offering a "restart to pick up updates" prompt stays in the backlog.
|
||||||
|
- **Per-overlay refresh on non-workshop overlay types.** Only workshop overlays get the Refresh button.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
Implementation will touch roughly:
|
||||||
|
|
||||||
|
- `l4d2web/services/overlay_builders.py` — WorkshopBuilder download phase, retry helper.
|
||||||
|
- `l4d2web/routes/workshop_routes.py` — new `/overlays/{id}/refresh` route.
|
||||||
|
- `l4d2web/templates/...` — Refresh button on overlay detail page.
|
||||||
|
- `l4d2web/cli.py` — new `workshop-refresh` subcommand.
|
||||||
|
- `l4d2web/models.py` and `alembic/versions/...` — possibly relax `Job.user_id` to nullable (TBD per schema check).
|
||||||
|
- `deploy/` — systemd `.service` + `.timer` units, wired into the install flow.
|
||||||
|
- `l4d2web/tests/test_overlay_builders.py`, `test_workshop_routes.py`, `test_cli.py` — new test cases per the testing section.
|
||||||
|
|
||||||
|
The implementation plan will turn these into ordered steps with explicit checkpoints.
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
# Server live-state display (counts, map, roster, avatars, history)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The l4d2web UI currently shows systemd lifecycle state per game server (running/stopped/unknown) but nothing about what's happening *inside* the game: player count, current map, whether the server is hibernating, who is connected. To know any of that, users have to context-switch (open the game, query externally).
|
||||||
|
|
||||||
|
The goal is a **read-side live-state display**: counts + map + hibernating on the server list, plus a server-detail panel showing the current player roster (avatars + names) and a "recent players" section for who's been on lately. Backed by a persistent history table so we get count-over-time graphs and player-presence history (foundation for future ban UX) for free.
|
||||||
|
|
||||||
|
**Source: RCON exclusively.** A2S_INFO (UDP, anonymous) was investigated and discarded — it can't deliver Steam IDs, hibernating flag, or interactive commands, so anything beyond raw counts re-routes through RCON anyway. Both transports were verified working against prod `left4.me`. Going RCON-only means one transport, one set of tests, no throwaway scaffolding.
|
||||||
|
|
||||||
|
**Avatars: Steam Web API.** RCON gives Steam IDs; `ISteamUser/GetPlayerSummaries` resolves them to persona names + avatar URLs hot-linked from Steam's CDN. API key already obtained.
|
||||||
|
|
||||||
|
**Commands are deferred** to a separate plan. This plan is read-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ left4me-web (Flask) │
|
||||||
|
┌──────────────┐ RCON │ ┌───────────────────────┐ │
|
||||||
|
│ srcds 27016 │◄──────┼──┤ live-state poller │ │
|
||||||
|
└──────────────┘ TCP │ │ (daemon thread) │ │
|
||||||
|
│ └───────┬───────────────┘ │
|
||||||
|
┌──────────────┐ RCON │ │ writes │
|
||||||
|
│ srcds 27021 │◄──────┤ ▼ │
|
||||||
|
└──────────────┘ │ ┌───────────────────────┐ │
|
||||||
|
│ │ server_live_state │ │
|
||||||
|
Steam Web API │ │ server_player_session │ │
|
||||||
|
┌────────────┐ │ │ steam_user_profile │ │
|
||||||
|
│ Steam CDN │◄─┼──┤ │ │
|
||||||
|
│ avatars... │ │ └───────┬───────────────┘ │
|
||||||
|
└────────────┘ │ │ reads │
|
||||||
|
▲ │ ▼ │
|
||||||
|
│ │ ┌───────────────────────┐ │
|
||||||
|
└────────┼──┤ /servers, /servers/N │ │
|
||||||
|
<img src=...> │ │ (HTMX 5s refresh) │ │
|
||||||
|
│ └───────────────────────┘ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Single daemon thread (modeled on the existing `start_state_poller` in `l4d2web/services/job_worker.py:617-647`), inside the Flask process, polls every `LIVE_STATE_POLL_SECONDS` (default 5). Per poll, per running server with a configured RCON password:
|
||||||
|
|
||||||
|
1. TCP connect to `127.0.0.1:<port>`, auth, send `status`, parse response.
|
||||||
|
2. Compare server-level state (players/map/hibernating/etc.) to the latest `server_live_state` row for this server. If unchanged, bump `last_seen_at`. If changed, insert a new row.
|
||||||
|
3. Reconcile open sessions (`server_player_session` rows where `left_at IS NULL`) with the current `status` roster: open new sessions for new players (backfilling `joined_at` from RCON's `connected` field), close sessions for players no longer present, update `min_ping`/`max_ping` for continuing sessions.
|
||||||
|
4. Collect Steam IDs that are missing from `steam_user_profile` or have `fetched_at` older than 24h; batch them into a single `GetPlayerSummaries` call; upsert results.
|
||||||
|
5. Trim `server_live_state` and closed sessions older than retention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema (one new alembic migration)
|
||||||
|
|
||||||
|
### New column: `servers.rcon_password`
|
||||||
|
|
||||||
|
```python
|
||||||
|
rcon_password: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, default="", server_default=""
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty string = "no password configured yet" (poller skips). Migration backfills every existing row with `secrets.token_urlsafe(32)` (~43 chars, URL-safe character set so the literal `"..."` cfg-quoting needs no escaping).
|
||||||
|
|
||||||
|
### `server_live_state` — run-length-encoded snapshots
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE server_live_state (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
started_at DATETIME NOT NULL, -- when this exact state first appeared
|
||||||
|
last_seen_at DATETIME NOT NULL, -- most recent poll where it still held
|
||||||
|
players INTEGER NOT NULL,
|
||||||
|
max_players INTEGER NOT NULL,
|
||||||
|
bots INTEGER NOT NULL,
|
||||||
|
map VARCHAR(64) NOT NULL,
|
||||||
|
hibernating BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_sls_server_started ON server_live_state(server_id, started_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
- "State" = the tuple `(players, max_players, bots, map, hibernating)`. Ping/loss are deliberately not stored at server-level, so they don't churn rows.
|
||||||
|
- Idle hibernating server collapses from one-row-per-poll to one-row-per-state-change (≈17,280× compression for a 24h-idle server).
|
||||||
|
- Latest snapshot for a server: `ORDER BY started_at DESC LIMIT 1`. UI staleness check: `last_seen_at > now - LIVE_STATE_STALE_SECONDS` (default 30).
|
||||||
|
- Retention: trim rows where `last_seen_at < now - LIVE_STATE_HISTORY_DAYS` (default 30).
|
||||||
|
- Failed polls produce no DB write; the staleness check on `last_seen_at` handles UI degradation cleanly.
|
||||||
|
|
||||||
|
### `server_player_session` — interval per connection
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE server_player_session (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
steam_id_64 VARCHAR(20) NOT NULL,
|
||||||
|
joined_at DATETIME NOT NULL,
|
||||||
|
left_at DATETIME NULL, -- NULL = currently in-game
|
||||||
|
name_at_join VARCHAR(64) NOT NULL,
|
||||||
|
min_ping INTEGER NOT NULL,
|
||||||
|
max_ping INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_sps_server_open ON server_player_session(server_id, left_at);
|
||||||
|
CREATE INDEX ix_sps_steam_history ON server_player_session(steam_id_64, joined_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `joined_at` is **backfilled from RCON's `connected` duration** on first sighting (`joined_at = now - connected_seconds`). This heals brief polling gaps and survives web restarts: even if we just started polling, we know when the still-connected players actually joined.
|
||||||
|
- A player who disconnects and rejoins gets two rows, not one merged interval.
|
||||||
|
- Bots are excluded — rows with a non-`STEAM_X:Y:Z` uniqueid are skipped.
|
||||||
|
- `min_ping`/`max_ping` updated only when a new poll pushes the range, to avoid noise writes.
|
||||||
|
- On poller startup, close any sessions whose server isn't in current RCON output. Plus: close sessions after N consecutive failed polls of their server (TBD constant during implementation, e.g. 6 polls = ~30s).
|
||||||
|
- Retention: trim closed sessions where `left_at < now - SESSION_HISTORY_DAYS` (default 30). Open sessions never trimmed.
|
||||||
|
|
||||||
|
### `steam_user_profile` — cached profile data (24h TTL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE steam_user_profile (
|
||||||
|
steam_id_64 VARCHAR(20) PRIMARY KEY,
|
||||||
|
persona_name VARCHAR(64) NOT NULL,
|
||||||
|
avatar_url TEXT NOT NULL, -- avatarmedium from Steam Web API
|
||||||
|
fetched_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Cache is global, not per-server (one profile per Steam ID).
|
||||||
|
- Refreshed when `fetched_at < now - 24h` or when entry is missing.
|
||||||
|
- Soft-fail: if the Steam API key is unset, the API is down, or a profile is private, we just leave the cache as-is and the UI falls back to `name_at_join` + placeholder avatar.
|
||||||
|
|
||||||
|
### Bind-rendered queries
|
||||||
|
|
||||||
|
**Current players on server X:**
|
||||||
|
```sql
|
||||||
|
SELECT sp.steam_id_64, sp.joined_at, sp.name_at_join,
|
||||||
|
sp.min_ping, sp.max_ping,
|
||||||
|
p.persona_name, p.avatar_url
|
||||||
|
FROM server_player_session sp
|
||||||
|
LEFT JOIN steam_user_profile p USING (steam_id_64)
|
||||||
|
WHERE sp.server_id = ? AND sp.left_at IS NULL
|
||||||
|
ORDER BY sp.joined_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recent players on server X (last 30 days, excluding currently in-game):**
|
||||||
|
```sql
|
||||||
|
SELECT sp.steam_id_64, MAX(sp.left_at) AS last_seen,
|
||||||
|
p.persona_name, p.avatar_url
|
||||||
|
FROM server_player_session sp
|
||||||
|
LEFT JOIN steam_user_profile p USING (steam_id_64)
|
||||||
|
WHERE sp.server_id = ?
|
||||||
|
AND sp.left_at IS NOT NULL
|
||||||
|
AND sp.left_at > datetime('now', '-30 days')
|
||||||
|
AND sp.steam_id_64 NOT IN (
|
||||||
|
SELECT steam_id_64 FROM server_player_session
|
||||||
|
WHERE server_id = ? AND left_at IS NULL
|
||||||
|
)
|
||||||
|
GROUP BY sp.steam_id_64, p.persona_name, p.avatar_url
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### `l4d2web/services/rcon.py` (new)
|
||||||
|
|
||||||
|
Pure stdlib (`socket`, `struct`), no new dependency. Source RCON protocol:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class PlayerRow:
|
||||||
|
steam_id_64: str # converted from STEAM_X:Y:Z
|
||||||
|
name: str
|
||||||
|
connected_seconds: int
|
||||||
|
ping: int
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class StatusResponse:
|
||||||
|
map: str
|
||||||
|
players: int # humans
|
||||||
|
max_players: int
|
||||||
|
bots: int
|
||||||
|
hibernating: bool
|
||||||
|
roster: list[PlayerRow]
|
||||||
|
|
||||||
|
class RconError(Exception): ...
|
||||||
|
class RconAuthError(RconError): ...
|
||||||
|
|
||||||
|
def query_status(host: str, port: int, password: str, *, timeout: float = 2.0) -> StatusResponse: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
- Auth handshake quirk verified live: server sends a `type=0` empty-body packet **before** the `type=2` auth response. Consume both. `req_id == -1` on the auth response = bad password.
|
||||||
|
- Single TCP connection per query (loopback, ~10-20ms total round-trip — pooling not worth it at this scale).
|
||||||
|
- Header regex on `map :` and `players :` lines (the `(hibernating|not hibernating)` token is in `players :`).
|
||||||
|
- Roster regex: split lines starting with `#`, skip the column-header line, robustly extract the quoted name + the `STEAM_X:Y:Z` token + `MM:SS` or `HH:MM:SS` connected duration + ping. Tolerate the two-numeric-prefix L4D2 variant (`# 2 1 "Crone" STEAM_1:0:...`).
|
||||||
|
- Steam ID conversion: `STEAM_X:Y:Z` → `76561197960265728 + (Z * 2) + Y` (Y is the low bit; returned as string).
|
||||||
|
|
||||||
|
### `l4d2web/services/steam_users.py` (new)
|
||||||
|
|
||||||
|
Modeled directly on `l4d2web/services/steam_workshop.py:17-43` (single `requests.Session`, 30s timeout, anonymous-pattern POST with form-encoded body — only difference is the `key=` parameter).
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class SteamProfile:
|
||||||
|
steam_id_64: str
|
||||||
|
persona_name: str
|
||||||
|
avatar_url: str # avatarmedium
|
||||||
|
|
||||||
|
def fetch_profiles_batch(steam_ids: Iterable[str], *, api_key: str) -> list[SteamProfile]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Endpoint: `GET https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=<key>&steamids=<csv>`.
|
||||||
|
- Up to 100 IDs per call; caller batches.
|
||||||
|
- Returns only successful resolutions (private/deleted accounts simply absent from the response — fine, they stay uncached and the UI falls back).
|
||||||
|
- Raises on transport errors; caller decides whether to surface.
|
||||||
|
|
||||||
|
### `l4d2web/services/live_state_poller.py` (new)
|
||||||
|
|
||||||
|
Modeled on `start_state_poller` / `state_poller_loop` in `l4d2web/services/job_worker.py:617-647`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def start_live_state_poller(app) -> None: ... # spawns daemon thread, skipped under TESTING
|
||||||
|
def live_state_poller_loop(app, interval: float) -> None: ...
|
||||||
|
def poll_once() -> None: # one full pass over running servers
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-server algorithm:
|
||||||
|
1. RCON `status` → `StatusResponse` (or skip on auth/timeout, logged via `app.logger`).
|
||||||
|
2. **Server-level RLE upsert**: load newest `server_live_state` row for this server. If `(players, max_players, bots, map, hibernating)` matches → `UPDATE last_seen_at = now()`. Else → `INSERT` new row.
|
||||||
|
3. **Session reconciliation** in a single transaction:
|
||||||
|
- Load open sessions for this server.
|
||||||
|
- For each player in `response.roster` not in open sessions: `INSERT` new session with `joined_at = now - connected_seconds`, `name_at_join = roster.name`, `min_ping = max_ping = roster.ping`.
|
||||||
|
- For each open session whose player is in the roster: if `roster.ping < min_ping` or `> max_ping`, `UPDATE` the range. Otherwise skip the write.
|
||||||
|
- For each open session whose player is *not* in the roster: `UPDATE left_at = now()`.
|
||||||
|
4. **Profile enrichment**: collect Steam IDs from the roster where the cached profile is missing or `fetched_at < now - 24h`. Skip if `STEAM_WEB_API_KEY` unset. Batch into one Steam API call. Upsert results.
|
||||||
|
|
||||||
|
Periodic (every Nth cycle, e.g. once a minute):
|
||||||
|
- Trim `server_live_state` and closed sessions past retention.
|
||||||
|
- Close any open sessions whose `server_id` hasn't had a successful RCON response in the last `STUCK_SESSION_SECONDS` (default 60).
|
||||||
|
|
||||||
|
### Modify: `l4d2web/services/l4d2_facade.py:28-52`
|
||||||
|
|
||||||
|
`build_server_spec_payload` **appends** `f'rcon_password "{server.rcon_password}"'` as the *last* entry in the returned `config` list, only if the password is non-empty. Appending (not prepending) matters: Source's cfg semantics are last-wins, so putting our line after both the overlay `exec` lines and the user's blueprint config guarantees no overlay or blueprint can silently clobber the password and break the poller. `l4d2host/instances.py:40-58` already writes `spec.config` lines verbatim to `server.cfg` — **no host-side change needed**.
|
||||||
|
|
||||||
|
### Modify: server-create route
|
||||||
|
|
||||||
|
Wherever the server-create form handler lives (`l4d2web/routes/server_routes.py` or similar — confirm during implementation): before commit, generate `rcon_password = secrets.token_urlsafe(32)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
### Server list (template TBD: `ls l4d2web/templates/` during implementation)
|
||||||
|
|
||||||
|
Add an inline live-state cell per server row:
|
||||||
|
- Stopped server: `—`
|
||||||
|
- Stale (no row newer than `LIVE_STATE_STALE_SECONDS`): dim `?` with tooltip "no data"
|
||||||
|
- Hibernating: `0/4 · idle · c1m1_hotel`
|
||||||
|
- Active: `2/4 · c1m2_streets`
|
||||||
|
|
||||||
|
No HTMX on the list page; page reload picks up the latest snapshot.
|
||||||
|
|
||||||
|
### Server detail (`l4d2web/templates/server_detail.html`)
|
||||||
|
|
||||||
|
New section, HTMX-refreshed every `LIVE_STATE_POLL_SECONDS` (default 5):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="panel"
|
||||||
|
hx-get="/servers/{{ server.id }}/live-state"
|
||||||
|
hx-trigger="every 5s"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<!-- rendered from l4d2web/templates/_live_state.html -->
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
The partial renders three blocks:
|
||||||
|
|
||||||
|
1. **Summary**: `players/max_players · map · idle?` plus a small "polled Ns ago" caption.
|
||||||
|
2. **Current players** (only if non-empty): grid of cards, each `<img src="{{ profile.avatar_url or placeholder }}" /> {{ profile.persona_name or session.name_at_join }} · {{ joined_relative }} · ping {{ min }}-{{ max }}ms`.
|
||||||
|
3. **Recent players** (last 30 days, excluding current; only if non-empty): smaller cards, `{{ avatar }} {{ persona_name or name_at_join }} · last seen {{ last_seen_relative }}`.
|
||||||
|
|
||||||
|
New route: `GET /servers/<id>/live-state` returns the partial. Composition mirrors the existing build-status pattern at `l4d2web/templates/_overlay_build_status.html:1-5`.
|
||||||
|
|
||||||
|
Avatar `<img>` tags point straight at Steam CDN URLs (`avatars.cloudflare.steamstatic.com` / `avatars.akamai.steamstatic.com`). No proxying. Same approach as `WorkshopItem.preview_url`. Note: confirm the existing CSP allows these hosts; if not, extend it.
|
||||||
|
|
||||||
|
No JS framework added — HTMX only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config keys
|
||||||
|
|
||||||
|
In `l4d2web/config.py`, plus documented defaults in `deploy/templates/etc/left4me/web.env` where applicable:
|
||||||
|
|
||||||
|
| key | default | purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `LIVE_STATE_POLL_SECONDS` | `5` | poll interval |
|
||||||
|
| `LIVE_STATE_QUERY_TIMEOUT_SECONDS` | `2.0` | per-RCON-query timeout |
|
||||||
|
| `LIVE_STATE_POLL_WORKERS` | `4` | thread-pool size for parallel per-server polls |
|
||||||
|
| `LIVE_STATE_STALE_SECONDS` | `30` | UI staleness threshold |
|
||||||
|
| `LIVE_STATE_HISTORY_DAYS` | `30` | retention for snapshots + closed sessions |
|
||||||
|
| `STUCK_SESSION_SECONDS` | `60` | close open sessions whose server has been unreachable for this long |
|
||||||
|
| `STEAM_PROFILE_TTL_SECONDS` | `86400` | profile cache TTL |
|
||||||
|
| `STEAM_WEB_API_KEY` | `""` | from `web.env`; empty disables enrichment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `l4d2web/tests/test_rcon.py` — protocol handshake against an in-process TCP fixture: auth-success, auth-failure (`req_id == -1`), header parse (incl. `(hibernating)` and `(reserved <token>)` variants), roster parse (incl. the two-numeric-prefix L4D2 variant), Steam ID conversion.
|
||||||
|
- `l4d2web/tests/test_steam_users.py` — request shape (key in querystring, batched ids, 100-per-call ceiling), response parsing, partial response (some IDs missing).
|
||||||
|
- `l4d2web/tests/test_live_state_poller.py` — mirror `test_state_poller_*` at `l4d2web/tests/test_job_worker.py:882-952`. Cover: iterates only running servers with non-empty `rcon_password`, RLE upsert (matching state → `last_seen_at` bump only; differing state → new row), session open with backfilled `joined_at`, session close on disappearance, ping range expansion, stuck-session close after N failures, drops auth failures silently, respects retention.
|
||||||
|
- `l4d2web/tests/test_server_routes.py` (extend) — `/servers/<id>/live-state` fragment route renders summary/current/recent blocks correctly; stale rendering when latest snapshot is old; soft-fail rendering when no profile cached.
|
||||||
|
- `l4d2web/tests/test_l4d2_facade.py` (extend) — `build_server_spec_payload` appends `rcon_password "..."` as the last config line when password is set; omits the line when empty; appears after both the overlay `exec` lines and the blueprint config lines.
|
||||||
|
- Migration test — existing rows backfilled with non-empty 43-char passwords; tables created with correct indexes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- `l4d2web/services/rcon.py` — Source RCON client + status parser
|
||||||
|
- `l4d2web/services/steam_users.py` — Steam Web API client (mirrors `steam_workshop.py`)
|
||||||
|
- `l4d2web/services/live_state_poller.py` — background thread + poll loop + session reconciler
|
||||||
|
- `l4d2web/alembic/versions/00XX_server_live_state.py` — migration: new column, three new tables, password backfill
|
||||||
|
- `l4d2web/templates/_live_state.html` — HTMX-refreshed fragment (summary + current + recent)
|
||||||
|
- `l4d2web/tests/test_rcon.py`, `l4d2web/tests/test_steam_users.py`, `l4d2web/tests/test_live_state_poller.py`
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `l4d2web/models.py` — add `ServerLiveState`, `ServerPlayerSession`, `SteamUserProfile`; add `rcon_password` to `Server` (after line 137)
|
||||||
|
- `l4d2web/services/l4d2_facade.py:28-52` — `build_server_spec_payload` appends `rcon_password "..."` as the last config line when set
|
||||||
|
- `l4d2web/app.py` — call `start_live_state_poller(app)` next to existing `start_state_poller`
|
||||||
|
- `l4d2web/routes/server_routes.py` (or equivalent — confirm) — generate `rcon_password` in create handler; add `GET /servers/<id>/live-state`
|
||||||
|
- `l4d2web/templates/server_detail.html` — include `_live_state.html`
|
||||||
|
- `l4d2web/templates/<server-list>.html` — confirm filename; add inline badge column
|
||||||
|
- `l4d2web/config.py` — register the eight new config keys
|
||||||
|
- `deploy/templates/etc/left4me/web.env` — add `STEAM_WEB_API_KEY=` and any tunables we expose
|
||||||
|
|
||||||
|
**Reused without changes:**
|
||||||
|
- `l4d2web/services/job_worker.py:617-647` — daemon-thread / poll-loop pattern reference
|
||||||
|
- `l4d2web/services/steam_workshop.py:17-43` — `requests.Session` + form-POST pattern for Steam Web API
|
||||||
|
- `l4d2host/instances.py:40-58` — already writes `spec.config` verbatim, so no host-side change for password injection
|
||||||
|
- `l4d2web/templates/_overlay_build_status.html` — HTMX polling pattern reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Unit tests**:
|
||||||
|
```
|
||||||
|
pytest l4d2web/tests/test_rcon.py l4d2web/tests/test_steam_users.py l4d2web/tests/test_live_state_poller.py -v
|
||||||
|
pytest l4d2web/tests -q # full regression
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Migration check**:
|
||||||
|
```
|
||||||
|
alembic upgrade head
|
||||||
|
sqlite3 l4d2web.db "SELECT id, name, length(rcon_password) FROM servers;" # every row ~43
|
||||||
|
sqlite3 l4d2web.db ".schema server_live_state server_player_session steam_user_profile"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **End-to-end against prod** (`left4.me`):
|
||||||
|
- Deploy. Confirm `systemctl status left4me-web.service` shows no crash-loop and the journal logs `start_live_state_poller` once.
|
||||||
|
- Restart both existing game servers so they pick up the injected password.
|
||||||
|
- SQL sanity (web-host shell):
|
||||||
|
```
|
||||||
|
sqlite3 l4d2web.db "SELECT server_id, started_at, last_seen_at, players, map, hibernating
|
||||||
|
FROM server_live_state ORDER BY server_id, started_at DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
Expect a single recent row per server while idle; new rows when players come/go.
|
||||||
|
- Connect to one server from the L4D2 client; within 5s, `/servers/<id>` shows a card with your avatar + persona name + ping range. Disconnect; within 5s the card moves to "recent."
|
||||||
|
- `sqlite3 l4d2web.db "SELECT * FROM server_player_session WHERE left_at IS NULL;"` — empty when nobody's connected; one row per current player when someone is.
|
||||||
|
- `sqlite3 l4d2web.db "SELECT count(*), MIN(fetched_at), MAX(fetched_at) FROM steam_user_profile;"` — at least one row after a player has been resolved.
|
||||||
|
|
||||||
|
4. **Failure-path checks**:
|
||||||
|
- Manually corrupt `servers.rcon_password` for one server; confirm the journal logs auth failure and the row's badge goes stale within `LIVE_STATE_STALE_SECONDS`; other servers unaffected.
|
||||||
|
- Unset `STEAM_WEB_API_KEY` in `web.env`, restart web; confirm display still works (in-game names + placeholder avatars), no errors in journal.
|
||||||
|
- `nft` drop the loopback TCP on one server's port; confirm rows stop appearing, open sessions close after `STUCK_SESSION_SECONDS`, badge goes stale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open implementation questions
|
||||||
|
|
||||||
|
- **Server-list template filename**: confirm with `ls l4d2web/templates/` once implementation starts.
|
||||||
|
- **Server-create route location**: confirm path (likely `l4d2web/routes/server_routes.py`).
|
||||||
|
- **CSP allowlist for Steam avatar CDNs**: check `l4d2web/app.py` (or wherever security headers live) — extend `img-src` to include `avatars.cloudflare.steamstatic.com`, `avatars.akamai.steamstatic.com`, `avatars.steamstatic.com` if a CSP is enforced.
|
||||||
|
- **Adaptive backoff** for hibernating servers: defer; start with fixed 5s and revisit only if load becomes a concern (which it won't at current server count).
|
||||||
|
- **Migration data step**: SQLite alembic batch operation with a Python data step that iterates rows and generates `secrets.token_urlsafe(32)` per row — confirm pattern against existing migrations under `l4d2web/alembic/versions/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred to a separate plan
|
||||||
|
|
||||||
|
- Generic RCON command execution (`changelevel`, `kick`, `say`, `sm_ban`, ...)
|
||||||
|
- Web UI buttons mapped to those commands with CSRF + admin authz
|
||||||
|
- Audit log table for issued commands
|
||||||
|
- Player-count history graphs (data already accumulating from this plan)
|
||||||
|
- Ban UX (lookup by Steam ID, search across `server_player_session`)
|
||||||
109
l4d2web/alembic/versions/0010_server_live_state.py
Normal file
109
l4d2web/alembic/versions/0010_server_live_state.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""server_live_state schema
|
||||||
|
|
||||||
|
Revision ID: 0010_server_live_state
|
||||||
|
Revises: 0009_user_password_changed_at
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0010_server_live_state"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0009_user_password_changed_at"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add rcon_password to servers, default ''
|
||||||
|
with op.batch_alter_table("servers") as batch:
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"rcon_password",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
server_default="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Backfill every existing row with a freshly generated password.
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(sa.text("SELECT id FROM servers")).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE servers SET rcon_password = :pw WHERE id = :id"),
|
||||||
|
{"pw": secrets.token_urlsafe(32), "id": row.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. server_live_state — run-length-encoded snapshots
|
||||||
|
op.create_table(
|
||||||
|
"server_live_state",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"server_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("servers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("players", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_players", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("bots", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("map", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("hibernating", sa.Boolean(), nullable=False),
|
||||||
|
sqlite_autoincrement=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_sls_server_started",
|
||||||
|
"server_live_state",
|
||||||
|
["server_id", "started_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. server_player_session — connection intervals
|
||||||
|
op.create_table(
|
||||||
|
"server_player_session",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"server_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("servers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("steam_id_64", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("joined_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("left_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("name_at_join", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("min_ping", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_ping", sa.Integer(), nullable=False),
|
||||||
|
sqlite_autoincrement=True,
|
||||||
|
)
|
||||||
|
op.create_index("ix_sps_server_open", "server_player_session", ["server_id", "left_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_sps_steam_history", "server_player_session", ["steam_id_64", "joined_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. steam_user_profile — 24h profile cache
|
||||||
|
op.create_table(
|
||||||
|
"steam_user_profile",
|
||||||
|
sa.Column("steam_id_64", sa.String(length=20), primary_key=True),
|
||||||
|
sa.Column("persona_name", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("avatar_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("steam_user_profile")
|
||||||
|
op.drop_index("ix_sps_steam_history", table_name="server_player_session")
|
||||||
|
op.drop_index("ix_sps_server_open", table_name="server_player_session")
|
||||||
|
op.drop_table("server_player_session")
|
||||||
|
op.drop_index("ix_sls_server_started", table_name="server_live_state")
|
||||||
|
op.drop_table("server_live_state")
|
||||||
|
with op.batch_alter_table("servers") as batch:
|
||||||
|
batch.drop_column("rcon_password")
|
||||||
|
|
@ -25,6 +25,7 @@ from l4d2web.services.job_worker import (
|
||||||
start_job_workers,
|
start_job_workers,
|
||||||
start_state_poller,
|
start_state_poller,
|
||||||
)
|
)
|
||||||
|
from l4d2web.services.live_state_poller import start_live_state_poller
|
||||||
|
|
||||||
|
|
||||||
def _in_flask_cli_context() -> bool:
|
def _in_flask_cli_context() -> bool:
|
||||||
|
|
@ -98,6 +99,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
||||||
recover_stale_jobs()
|
recover_stale_jobs()
|
||||||
start_job_workers(app)
|
start_job_workers(app)
|
||||||
start_state_poller(app)
|
start_state_poller(app)
|
||||||
|
if not app.config.get("TESTING"):
|
||||||
|
start_live_state_poller(app)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import hash_password, validate_new_password
|
from l4d2web.auth import hash_password, validate_new_password
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Overlay, User
|
from l4d2web.models import Job, Overlay, User
|
||||||
from l4d2web.services.overlay_creation import (
|
from l4d2web.services.overlay_creation import (
|
||||||
create_overlay_directory,
|
create_overlay_directory,
|
||||||
generate_overlay_path,
|
generate_overlay_path,
|
||||||
|
|
@ -90,7 +90,38 @@ def seed_script_overlays(directory: Path) -> None:
|
||||||
click.echo(f"created {name} (id={overlay.id})")
|
click.echo(f"created {name} (id={overlay.id})")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("workshop-refresh")
|
||||||
|
def workshop_refresh() -> None:
|
||||||
|
"""Enqueue a global workshop refresh job. Idempotent: if a refresh is
|
||||||
|
already queued or running, prints its id and exits 0."""
|
||||||
|
with session_scope() as db:
|
||||||
|
existing = db.scalar(
|
||||||
|
select(Job)
|
||||||
|
.where(
|
||||||
|
Job.operation == "refresh_workshop_items",
|
||||||
|
Job.state.in_(("queued", "running", "cancelling")),
|
||||||
|
)
|
||||||
|
.order_by(Job.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
click.echo(
|
||||||
|
f"refresh_workshop_items job {existing.id} already {existing.state}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
job = Job(
|
||||||
|
user_id=None,
|
||||||
|
server_id=None,
|
||||||
|
operation="refresh_workshop_items",
|
||||||
|
state="queued",
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
click.echo(f"enqueued refresh_workshop_items job {job.id}")
|
||||||
|
|
||||||
|
|
||||||
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)
|
app.cli.add_command(create_user)
|
||||||
app.cli.add_command(seed_script_overlays)
|
app.cli.add_command(seed_script_overlays)
|
||||||
|
app.cli.add_command(workshop_refresh)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ DEFAULT_CONFIG: dict[str, object] = {
|
||||||
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
||||||
"PORT_RANGE_START": 27015,
|
"PORT_RANGE_START": 27015,
|
||||||
"PORT_RANGE_END": 27115,
|
"PORT_RANGE_END": 27115,
|
||||||
|
"LIVE_STATE_POLL_SECONDS": 5,
|
||||||
|
"LIVE_STATE_QUERY_TIMEOUT_SECONDS": 2.0,
|
||||||
|
"LIVE_STATE_STALE_SECONDS": 30,
|
||||||
|
"LIVE_STATE_HISTORY_DAYS": 30,
|
||||||
|
"LIVE_STATE_RETENTION_EVERY_TICKS": 60,
|
||||||
|
"STUCK_SESSION_SECONDS": 60,
|
||||||
|
"STEAM_PROFILE_TTL_SECONDS": 86400,
|
||||||
|
"STEAM_WEB_API_KEY": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,4 +41,12 @@ def load_config() -> dict[str, object]:
|
||||||
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
|
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
|
||||||
"PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")),
|
"PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")),
|
||||||
"PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")),
|
"PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")),
|
||||||
|
"LIVE_STATE_POLL_SECONDS": float(os.getenv("LIVE_STATE_POLL_SECONDS", "5")),
|
||||||
|
"LIVE_STATE_QUERY_TIMEOUT_SECONDS": float(os.getenv("LIVE_STATE_QUERY_TIMEOUT_SECONDS", "2.0")),
|
||||||
|
"LIVE_STATE_STALE_SECONDS": int(os.getenv("LIVE_STATE_STALE_SECONDS", "30")),
|
||||||
|
"LIVE_STATE_HISTORY_DAYS": int(os.getenv("LIVE_STATE_HISTORY_DAYS", "30")),
|
||||||
|
"LIVE_STATE_RETENTION_EVERY_TICKS": int(os.getenv("LIVE_STATE_RETENTION_EVERY_TICKS", "60")),
|
||||||
|
"STUCK_SESSION_SECONDS": int(os.getenv("STUCK_SESSION_SECONDS", "60")),
|
||||||
|
"STEAM_PROFILE_TTL_SECONDS": int(os.getenv("STEAM_PROFILE_TTL_SECONDS", "86400")),
|
||||||
|
"STEAM_WEB_API_KEY": os.getenv("STEAM_WEB_API_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ class Server(Base):
|
||||||
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
||||||
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
rcon_password: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, default="", server_default=""
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
@ -172,3 +175,52 @@ class JobLog(Base):
|
||||||
stream: Mapped[str] = mapped_column(String(8), nullable=False)
|
stream: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
line: Mapped[str] = mapped_column(Text, nullable=False)
|
line: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerLiveState(Base):
|
||||||
|
__tablename__ = "server_live_state"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_sls_server_started", "server_id", "started_at"),
|
||||||
|
{"sqlite_autoincrement": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
server_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
players: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
max_players: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
bots: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
map: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
hibernating: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerPlayerSession(Base):
|
||||||
|
__tablename__ = "server_player_session"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_sps_server_open", "server_id", "left_at"),
|
||||||
|
Index("ix_sps_steam_history", "steam_id_64", "joined_at"),
|
||||||
|
{"sqlite_autoincrement": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
server_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SteamUserProfile(Base):
|
||||||
|
__tablename__ = "steam_user_profile"
|
||||||
|
|
||||||
|
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||||
|
persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, Response, redirect, render_template, request
|
from flask import Blueprint, Response, current_app, redirect, render_template, request
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_admin, require_login
|
from l4d2web.auth import current_user, require_admin, require_login
|
||||||
|
|
@ -12,6 +13,7 @@ from l4d2web.models import (
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayWorkshopItem,
|
OverlayWorkshopItem,
|
||||||
Server,
|
Server,
|
||||||
|
ServerLiveState,
|
||||||
User,
|
User,
|
||||||
WorkshopItem,
|
WorkshopItem,
|
||||||
)
|
)
|
||||||
|
|
@ -154,6 +156,42 @@ def servers_page() -> str:
|
||||||
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
|
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
||||||
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
|
||||||
|
|
||||||
|
server_ids = [s.id for s, _bp in rows]
|
||||||
|
latest_rows: dict[int, ServerLiveState] = {}
|
||||||
|
if server_ids:
|
||||||
|
subq = (
|
||||||
|
select(
|
||||||
|
ServerLiveState.server_id,
|
||||||
|
func.max(ServerLiveState.started_at).label("mx"),
|
||||||
|
)
|
||||||
|
.where(ServerLiveState.server_id.in_(server_ids))
|
||||||
|
.group_by(ServerLiveState.server_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
sls_rows = db.scalars(
|
||||||
|
select(ServerLiveState).join(
|
||||||
|
subq,
|
||||||
|
(ServerLiveState.server_id == subq.c.server_id)
|
||||||
|
& (ServerLiveState.started_at == subq.c.mx),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for r in sls_rows:
|
||||||
|
latest_rows[r.server_id] = r
|
||||||
|
|
||||||
|
live_state_by_server: dict[int, dict] = {}
|
||||||
|
for sid, row in latest_rows.items():
|
||||||
|
fresh = row.last_seen_at >= cutoff
|
||||||
|
live_state_by_server[sid] = {
|
||||||
|
"fresh": fresh,
|
||||||
|
"players": row.players,
|
||||||
|
"max_players": row.max_players,
|
||||||
|
"map": row.map,
|
||||||
|
"hibernating": row.hibernating,
|
||||||
|
}
|
||||||
|
|
||||||
prefill_blueprint_id: int | None = None
|
prefill_blueprint_id: int | None = None
|
||||||
raw_prefill = request.args.get("blueprint_id")
|
raw_prefill = request.args.get("blueprint_id")
|
||||||
if raw_prefill:
|
if raw_prefill:
|
||||||
|
|
@ -169,6 +207,7 @@ def servers_page() -> str:
|
||||||
rows=rows,
|
rows=rows,
|
||||||
blueprints=blueprints,
|
blueprints=blueprints,
|
||||||
prefill_blueprint_id=prefill_blueprint_id,
|
prefill_blueprint_id=prefill_blueprint_id,
|
||||||
|
live_state_by_server=live_state_by_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
from flask import Blueprint, Response, current_app, jsonify, redirect, request
|
import secrets
|
||||||
from sqlalchemy import select
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, current_app, jsonify, redirect, render_template, request
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
from l4d2web.models import Blueprint as BlueprintModel
|
||||||
from l4d2web.models import Job, Server
|
from l4d2web.models import Job, Server, ServerLiveState, ServerPlayerSession, SteamUserProfile
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("server", __name__)
|
bp = Blueprint("server", __name__)
|
||||||
|
|
@ -90,6 +93,7 @@ def create_server() -> Response:
|
||||||
desired_state="stopped",
|
desired_state="stopped",
|
||||||
actual_state="unknown",
|
actual_state="unknown",
|
||||||
last_error="",
|
last_error="",
|
||||||
|
rcon_password=secrets.token_urlsafe(32),
|
||||||
)
|
)
|
||||||
db.add(server)
|
db.add(server)
|
||||||
|
|
||||||
|
|
@ -186,3 +190,84 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response:
|
||||||
if operation == "delete":
|
if operation == "delete":
|
||||||
return redirect("/servers")
|
return redirect("/servers")
|
||||||
return redirect(f"/servers/{server_id}")
|
return redirect(f"/servers/{server_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/servers/<int:server_id>/live-state")
|
||||||
|
@require_login
|
||||||
|
def live_state_fragment(server_id: int) -> Response:
|
||||||
|
user = current_user()
|
||||||
|
assert user is not None
|
||||||
|
with session_scope() as db:
|
||||||
|
server = db.scalar(select(Server).where(
|
||||||
|
Server.id == server_id, Server.user_id == user.id,
|
||||||
|
))
|
||||||
|
if server is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
||||||
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
|
||||||
|
|
||||||
|
latest = db.scalar(
|
||||||
|
select(ServerLiveState)
|
||||||
|
.where(ServerLiveState.server_id == server.id)
|
||||||
|
.order_by(ServerLiveState.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
current_rows = db.execute(
|
||||||
|
select(ServerPlayerSession, SteamUserProfile)
|
||||||
|
.outerjoin(
|
||||||
|
SteamUserProfile,
|
||||||
|
SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ServerPlayerSession.server_id == server.id,
|
||||||
|
ServerPlayerSession.left_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(ServerPlayerSession.joined_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
current_ids = [r[0].steam_id_64 for r in current_rows]
|
||||||
|
|
||||||
|
recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(
|
||||||
|
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_rows = db.execute(
|
||||||
|
select(
|
||||||
|
ServerPlayerSession.steam_id_64,
|
||||||
|
func.max(ServerPlayerSession.left_at).label("last_seen"),
|
||||||
|
ServerPlayerSession.name_at_join,
|
||||||
|
SteamUserProfile.persona_name,
|
||||||
|
SteamUserProfile.avatar_url,
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
SteamUserProfile,
|
||||||
|
SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ServerPlayerSession.server_id == server.id,
|
||||||
|
ServerPlayerSession.left_at.is_not(None),
|
||||||
|
ServerPlayerSession.left_at >= recent_cutoff,
|
||||||
|
~ServerPlayerSession.steam_id_64.in_(current_ids) if current_ids else True,
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
ServerPlayerSession.steam_id_64,
|
||||||
|
SteamUserProfile.persona_name,
|
||||||
|
SteamUserProfile.avatar_url,
|
||||||
|
ServerPlayerSession.name_at_join,
|
||||||
|
)
|
||||||
|
.order_by(func.max(ServerPlayerSession.left_at).desc())
|
||||||
|
.limit(20)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"_live_state.html",
|
||||||
|
server=server,
|
||||||
|
snapshot=latest,
|
||||||
|
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
|
||||||
|
current_players=current_rows,
|
||||||
|
recent_players=recent_rows,
|
||||||
|
now=datetime.now(UTC).replace(tzinfo=None),
|
||||||
|
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
admin global refresh)."""
|
admin global refresh)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
from flask import Blueprint, Response, redirect, request
|
from flask import Blueprint, Response, redirect, request
|
||||||
from sqlalchemy import delete as sa_delete
|
from sqlalchemy import delete as sa_delete
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -120,6 +121,60 @@ def remove_item(overlay_id: int, item_id: int) -> Response:
|
||||||
return redirect(f"/jobs/{job_id}")
|
return redirect(f"/jobs/{job_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/overlays/<int:overlay_id>/refresh")
|
||||||
|
@require_login
|
||||||
|
def refresh_overlay(overlay_id: int) -> Response:
|
||||||
|
user = current_user()
|
||||||
|
assert user is not None
|
||||||
|
with session_scope() as db:
|
||||||
|
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
steam_ids = list(
|
||||||
|
db.scalars(
|
||||||
|
select(WorkshopItem.steam_id)
|
||||||
|
.join(
|
||||||
|
OverlayWorkshopItem,
|
||||||
|
OverlayWorkshopItem.workshop_item_id == WorkshopItem.id,
|
||||||
|
)
|
||||||
|
.where(OverlayWorkshopItem.overlay_id == overlay_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not steam_ids:
|
||||||
|
return Response("overlay has no items", status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metas = steam_workshop.fetch_metadata_batch(steam_ids, mode="refresh")
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return Response(f"steam api error: {exc}", status=502)
|
||||||
|
|
||||||
|
metas_by_id = {m.steam_id: m for m in metas}
|
||||||
|
with session_scope() as db:
|
||||||
|
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
for steam_id in steam_ids:
|
||||||
|
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == steam_id))
|
||||||
|
if wi is None:
|
||||||
|
continue
|
||||||
|
meta = metas_by_id.get(steam_id)
|
||||||
|
if meta is None:
|
||||||
|
wi.last_error = "steam returned no entry for this item"
|
||||||
|
continue
|
||||||
|
wi.title = meta.title
|
||||||
|
wi.filename = meta.filename
|
||||||
|
wi.file_url = meta.file_url
|
||||||
|
wi.file_size = meta.file_size
|
||||||
|
wi.time_updated = meta.time_updated
|
||||||
|
wi.preview_url = meta.preview_url
|
||||||
|
wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}"
|
||||||
|
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
|
||||||
|
job_id = job.id
|
||||||
|
|
||||||
|
return redirect(f"/jobs/{job_id}")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/admin/workshop/refresh")
|
@bp.post("/admin/workshop/refresh")
|
||||||
@require_admin
|
@require_admin
|
||||||
def admin_refresh() -> Response:
|
def admin_refresh() -> Response:
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,16 @@ def build_server_spec_payload(
|
||||||
for overlay_id, _, expose in reversed(overlay_rows)
|
for overlay_id, _, expose in reversed(overlay_rows)
|
||||||
if expose
|
if expose
|
||||||
]
|
]
|
||||||
|
config_lines: list[str] = exec_lines + json.loads(blueprint.config)
|
||||||
|
# rcon_password is appended LAST so neither overlays nor user blueprint
|
||||||
|
# config can override it (Source's cvar semantics are last-wins).
|
||||||
|
if server.rcon_password:
|
||||||
|
config_lines.append(f'rcon_password "{server.rcon_password}"')
|
||||||
return {
|
return {
|
||||||
"port": server.port,
|
"port": server.port,
|
||||||
"overlays": overlays,
|
"overlays": overlays,
|
||||||
"arguments": json.loads(blueprint.arguments),
|
"arguments": json.loads(blueprint.arguments),
|
||||||
"config": exec_lines + json.loads(blueprint.config),
|
"config": config_lines,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
259
l4d2web/services/live_state_poller.py
Normal file
259
l4d2web/services/live_state_poller.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""Background poller that maintains live game-server state in the DB.
|
||||||
|
|
||||||
|
Modeled on l4d2web/services/job_worker.py:617-647. This module owns:
|
||||||
|
- per-server snapshot writes with run-length encoding into
|
||||||
|
`server_live_state`
|
||||||
|
- player-session lifecycle in `server_player_session` (Task 7)
|
||||||
|
- Steam profile enrichment into `steam_user_profile` (Task 8)
|
||||||
|
- retention pruning and stuck-session closure (Task 10)
|
||||||
|
|
||||||
|
This file is built up across Tasks 6-10.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.db import session_scope
|
||||||
|
from l4d2web.models import (
|
||||||
|
Server,
|
||||||
|
ServerLiveState,
|
||||||
|
ServerPlayerSession,
|
||||||
|
SteamUserProfile,
|
||||||
|
)
|
||||||
|
from l4d2web.services.rcon import RconError, StatusResponse, query_status
|
||||||
|
from l4d2web.services.steam_users import fetch_profiles_batch
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def poll_once() -> None:
|
||||||
|
"""One pass over all running servers with a configured rcon_password."""
|
||||||
|
with session_scope() as db:
|
||||||
|
servers = db.scalars(
|
||||||
|
select(Server)
|
||||||
|
.where(Server.actual_state == "running")
|
||||||
|
.where(Server.rcon_password != "")
|
||||||
|
).all()
|
||||||
|
targets = [(s.id, s.port, s.rcon_password) for s in servers]
|
||||||
|
|
||||||
|
api_key = current_app.config.get("STEAM_WEB_API_KEY", "") or ""
|
||||||
|
ttl_seconds = int(current_app.config.get("STEAM_PROFILE_TTL_SECONDS", 86400))
|
||||||
|
timeout = float(current_app.config.get("LIVE_STATE_QUERY_TIMEOUT_SECONDS", 2.0))
|
||||||
|
|
||||||
|
for server_id, port, password in targets:
|
||||||
|
try:
|
||||||
|
status = query_status("127.0.0.1", port, password, timeout=timeout)
|
||||||
|
except RconError:
|
||||||
|
logger.warning("rcon query failed for server %d", server_id, exc_info=True)
|
||||||
|
_close_stuck_sessions(server_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_record_snapshot(server_id, status)
|
||||||
|
_reconcile_sessions(server_id, status)
|
||||||
|
if api_key:
|
||||||
|
_enrich_profiles(status, api_key=api_key, ttl_seconds=ttl_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_profiles(status: StatusResponse, *, api_key: str, ttl_seconds: int) -> None:
|
||||||
|
"""Fetch+cache Steam profile data for any roster IDs missing or stale."""
|
||||||
|
roster_ids = {p.steam_id_64 for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)}
|
||||||
|
if not roster_ids:
|
||||||
|
return
|
||||||
|
cutoff = _now() - timedelta(seconds=ttl_seconds)
|
||||||
|
with session_scope() as db:
|
||||||
|
fresh = set(db.scalars(
|
||||||
|
select(SteamUserProfile.steam_id_64).where(
|
||||||
|
SteamUserProfile.steam_id_64.in_(roster_ids),
|
||||||
|
SteamUserProfile.fetched_at >= cutoff,
|
||||||
|
)
|
||||||
|
).all())
|
||||||
|
needs_fetch = sorted(roster_ids - fresh)
|
||||||
|
if not needs_fetch:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
profiles = fetch_profiles_batch(needs_fetch, api_key=api_key)
|
||||||
|
except Exception: # network / API errors are soft-fail
|
||||||
|
logger.warning("steam profile enrichment failed", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
with session_scope() as db:
|
||||||
|
for p in profiles:
|
||||||
|
row = db.get(SteamUserProfile, p.steam_id_64)
|
||||||
|
if row is None:
|
||||||
|
db.add(SteamUserProfile(
|
||||||
|
steam_id_64=p.steam_id_64,
|
||||||
|
persona_name=p.persona_name,
|
||||||
|
avatar_url=p.avatar_url,
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
row.persona_name = p.persona_name
|
||||||
|
row.avatar_url = p.avatar_url
|
||||||
|
row.fetched_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def prune_history() -> None:
|
||||||
|
"""Delete snapshots and closed sessions older than retention."""
|
||||||
|
days = int(current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30))
|
||||||
|
cutoff = _now() - timedelta(days=days)
|
||||||
|
with session_scope() as db:
|
||||||
|
db.query(ServerLiveState).filter(
|
||||||
|
ServerLiveState.last_seen_at < cutoff
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.query(ServerPlayerSession).filter(
|
||||||
|
ServerPlayerSession.left_at.is_not(None),
|
||||||
|
ServerPlayerSession.left_at < cutoff,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_stuck_sessions(server_id: int) -> None:
|
||||||
|
"""If a server has open sessions whose joined_at is older than threshold
|
||||||
|
AND we've failed to observe them, close them as stuck."""
|
||||||
|
seconds = int(current_app.config.get("STUCK_SESSION_SECONDS", 60))
|
||||||
|
cutoff = _now() - timedelta(seconds=seconds)
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(ServerPlayerSession).where(
|
||||||
|
ServerPlayerSession.server_id == server_id,
|
||||||
|
ServerPlayerSession.left_at.is_(None),
|
||||||
|
ServerPlayerSession.joined_at < cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
row.left_at = _now()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_snapshot(server_id: int, status: StatusResponse) -> None:
|
||||||
|
"""RLE write: bump last_seen_at if state matches, else insert a new row."""
|
||||||
|
now = _now()
|
||||||
|
with session_scope() as db:
|
||||||
|
latest = db.scalars(
|
||||||
|
select(ServerLiveState)
|
||||||
|
.where(ServerLiveState.server_id == server_id)
|
||||||
|
.order_by(ServerLiveState.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if latest is not None and _matches(latest, status):
|
||||||
|
latest.last_seen_at = now
|
||||||
|
return
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ServerLiveState(
|
||||||
|
server_id=server_id,
|
||||||
|
started_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
players=status.players,
|
||||||
|
max_players=status.max_players,
|
||||||
|
bots=status.bots,
|
||||||
|
map=status.map,
|
||||||
|
hibernating=status.hibernating,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(row: ServerLiveState, status: StatusResponse) -> bool:
|
||||||
|
return (
|
||||||
|
row.players == status.players
|
||||||
|
and row.max_players == status.max_players
|
||||||
|
and row.bots == status.bots
|
||||||
|
and row.map == status.map
|
||||||
|
and row.hibernating == status.hibernating
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_STEAM_ID_64_PREFIX = "7656" # all SteamID64s start with this; bots/anon do not
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_steam_id_64(value: str) -> bool:
|
||||||
|
return value.startswith(_STEAM_ID_64_PREFIX) and value.isdigit() and len(value) == 17
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_sessions(server_id: int, status: StatusResponse) -> None:
|
||||||
|
"""Open new sessions, update ping ranges, close departed sessions."""
|
||||||
|
now = _now()
|
||||||
|
roster = [p for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)]
|
||||||
|
seen_ids = {p.steam_id_64 for p in roster}
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
open_rows = db.scalars(
|
||||||
|
select(ServerPlayerSession).where(
|
||||||
|
ServerPlayerSession.server_id == server_id,
|
||||||
|
ServerPlayerSession.left_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
open_by_sid = {r.steam_id_64: r for r in open_rows}
|
||||||
|
|
||||||
|
# Close sessions for players no longer in the roster.
|
||||||
|
for sid, row in open_by_sid.items():
|
||||||
|
if sid not in seen_ids:
|
||||||
|
row.left_at = now
|
||||||
|
|
||||||
|
# Open / update sessions for current roster.
|
||||||
|
for p in roster:
|
||||||
|
existing = open_by_sid.get(p.steam_id_64)
|
||||||
|
if existing is None:
|
||||||
|
db.add(
|
||||||
|
ServerPlayerSession(
|
||||||
|
server_id=server_id,
|
||||||
|
steam_id_64=p.steam_id_64,
|
||||||
|
joined_at=now - timedelta(seconds=p.connected_seconds),
|
||||||
|
left_at=None,
|
||||||
|
name_at_join=p.name,
|
||||||
|
min_ping=p.ping,
|
||||||
|
max_ping=p.ping,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if p.ping < existing.min_ping:
|
||||||
|
existing.min_ping = p.ping
|
||||||
|
if p.ping > existing.max_ping:
|
||||||
|
existing.max_ping = p.ping
|
||||||
|
|
||||||
|
|
||||||
|
_poller_started_lock = threading.Lock()
|
||||||
|
_poller_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def start_live_state_poller(app) -> None:
|
||||||
|
"""Spawn the daemon poller thread once per process."""
|
||||||
|
global _poller_started
|
||||||
|
with _poller_started_lock:
|
||||||
|
if _poller_started:
|
||||||
|
return
|
||||||
|
_poller_started = True
|
||||||
|
interval = float(app.config.get("LIVE_STATE_POLL_SECONDS", 5))
|
||||||
|
retention_every = max(1, int(app.config.get("LIVE_STATE_RETENTION_EVERY_TICKS", 60)))
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_poller_loop,
|
||||||
|
args=(app, interval, retention_every),
|
||||||
|
name="left4me-live-state-poller",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _poller_loop(app, interval: float, retention_every: int) -> None:
|
||||||
|
tick = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
poll_once()
|
||||||
|
if tick % retention_every == 0:
|
||||||
|
prune_history()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("live-state poller loop exception")
|
||||||
|
tick += 1
|
||||||
|
time.sleep(interval)
|
||||||
|
|
@ -10,9 +10,12 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Protocol
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
|
import requests
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2host.paths import get_left4me_root
|
from l4d2host.paths import get_left4me_root
|
||||||
|
|
@ -20,6 +23,7 @@ from l4d2host.paths import get_left4me_root
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Overlay, OverlayWorkshopItem, WorkshopItem
|
from l4d2web.models import Overlay, OverlayWorkshopItem, WorkshopItem
|
||||||
from l4d2web.services.host_commands import run_command
|
from l4d2web.services.host_commands import run_command
|
||||||
|
from l4d2web.services.steam_workshop import WorkshopMetadata, download_to_cache
|
||||||
from l4d2web.services.workshop_paths import cache_path, workshop_cache_root
|
from l4d2web.services.workshop_paths import cache_path, workshop_cache_root
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,6 +34,59 @@ LogSink = Callable[[str], None]
|
||||||
SCRIPT_SANDBOX_HELPER = "/usr/local/libexec/left4me/left4me-script-sandbox"
|
SCRIPT_SANDBOX_HELPER = "/usr/local/libexec/left4me/left4me-script-sandbox"
|
||||||
DISK_BUDGET_BYTES = 20 * 1024**3
|
DISK_BUDGET_BYTES = 20 * 1024**3
|
||||||
|
|
||||||
|
DOWNLOAD_RETRY_ATTEMPTS = 3
|
||||||
|
DOWNLOAD_RETRY_BACKOFF_SECONDS = (1.0, 2.0)
|
||||||
|
assert len(DOWNLOAD_RETRY_BACKOFF_SECONDS) == DOWNLOAD_RETRY_ATTEMPTS - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _sleep_with_cancel(
|
||||||
|
seconds: float,
|
||||||
|
should_cancel: CancelCheck,
|
||||||
|
*,
|
||||||
|
poll_interval: float = 0.25,
|
||||||
|
) -> bool:
|
||||||
|
"""Sleep up to `seconds`, returning early (True) if `should_cancel` becomes
|
||||||
|
True. Returns False on a full uninterrupted sleep. Polls every
|
||||||
|
`poll_interval` seconds."""
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while True:
|
||||||
|
if should_cancel():
|
||||||
|
return True
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(poll_interval, remaining))
|
||||||
|
|
||||||
|
|
||||||
|
def _download_with_retry(
|
||||||
|
meta: WorkshopMetadata,
|
||||||
|
cache_root: Path,
|
||||||
|
*,
|
||||||
|
on_stderr: LogSink,
|
||||||
|
should_cancel: CancelCheck,
|
||||||
|
) -> None:
|
||||||
|
"""Wrap `download_to_cache` with bounded retries and cancel-aware backoff.
|
||||||
|
Raises the last exception after `DOWNLOAD_RETRY_ATTEMPTS` failures.
|
||||||
|
Raises `InterruptedError` if cancelled during a backoff sleep."""
|
||||||
|
last_exc: BaseException | None = None
|
||||||
|
for attempt in range(1, DOWNLOAD_RETRY_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
download_to_cache(meta, cache_root, should_cancel=should_cancel)
|
||||||
|
return
|
||||||
|
except InterruptedError:
|
||||||
|
raise
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt == DOWNLOAD_RETRY_ATTEMPTS:
|
||||||
|
raise
|
||||||
|
on_stderr(
|
||||||
|
f"workshop {meta.steam_id} attempt {attempt}/"
|
||||||
|
f"{DOWNLOAD_RETRY_ATTEMPTS} failed: {exc}"
|
||||||
|
)
|
||||||
|
delay = DOWNLOAD_RETRY_BACKOFF_SECONDS[attempt - 1]
|
||||||
|
if _sleep_with_cancel(delay, should_cancel):
|
||||||
|
raise InterruptedError("download cancelled during backoff") from last_exc
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_script_dir() -> Path:
|
def _sandbox_script_dir() -> Path:
|
||||||
"""Where script tmpfiles live before being bind-mounted into the sandbox.
|
"""Where script tmpfiles live before being bind-mounted into the sandbox.
|
||||||
|
|
@ -70,9 +127,9 @@ def overlay_path_for_id(overlay_id: int) -> Path:
|
||||||
|
|
||||||
class WorkshopBuilder:
|
class WorkshopBuilder:
|
||||||
"""Diff-apply symlinks under `left4dead2/addons/` against the overlay's
|
"""Diff-apply symlinks under `left4dead2/addons/` against the overlay's
|
||||||
current `WorkshopItem` associations. Cached items get an absolute symlink
|
current `WorkshopItem` associations. Downloads missing or stale items
|
||||||
into `workshop_cache/{steam_id}.vpk`. Items missing from cache are
|
before applying symlinks. Items with no file_url are skipped with a
|
||||||
skipped with a warning rather than turned into broken symlinks."""
|
warning."""
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
self,
|
self,
|
||||||
|
|
@ -85,8 +142,9 @@ class WorkshopBuilder:
|
||||||
addons_dir = _overlay_root(overlay) / "left4dead2" / "addons"
|
addons_dir = _overlay_root(overlay) / "left4dead2" / "addons"
|
||||||
addons_dir.mkdir(parents=True, exist_ok=True)
|
addons_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Snapshot every field the decision logic + downloader will need.
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
items = db.scalars(
|
rows = db.scalars(
|
||||||
select(WorkshopItem)
|
select(WorkshopItem)
|
||||||
.join(
|
.join(
|
||||||
OverlayWorkshopItem,
|
OverlayWorkshopItem,
|
||||||
|
|
@ -94,37 +152,117 @@ class WorkshopBuilder:
|
||||||
)
|
)
|
||||||
.where(OverlayWorkshopItem.overlay_id == overlay.id)
|
.where(OverlayWorkshopItem.overlay_id == overlay.id)
|
||||||
).all()
|
).all()
|
||||||
# Detach items so we can use them outside the session.
|
|
||||||
items_data = [
|
items_data = [
|
||||||
(it.steam_id, it.last_downloaded_at) for it in items
|
(
|
||||||
|
it.id,
|
||||||
|
it.steam_id,
|
||||||
|
it.title,
|
||||||
|
it.filename,
|
||||||
|
it.file_url,
|
||||||
|
it.file_size,
|
||||||
|
it.time_updated,
|
||||||
|
it.preview_url,
|
||||||
|
it.last_downloaded_at,
|
||||||
|
it.last_error,
|
||||||
|
)
|
||||||
|
for it in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
cache_root = workshop_cache_root()
|
cache_root = workshop_cache_root()
|
||||||
# desired: symlink-name -> absolute target path (only for cached items)
|
cache_root.mkdir(parents=True, exist_ok=True)
|
||||||
desired: dict[str, Path] = {}
|
|
||||||
skipped: list[str] = []
|
downloaded = 0
|
||||||
for steam_id, last_downloaded_at in items_data:
|
cached = 0
|
||||||
target = cache_path(steam_id)
|
skipped: set[str] = set()
|
||||||
if last_downloaded_at is None or not target.exists():
|
|
||||||
skipped.append(steam_id)
|
# Download phase.
|
||||||
|
for (
|
||||||
|
item_id, steam_id, title, filename, file_url, file_size,
|
||||||
|
time_updated, preview_url, last_downloaded_at, last_error,
|
||||||
|
) in items_data:
|
||||||
|
if should_cancel():
|
||||||
|
on_stderr("workshop build cancelled during download phase")
|
||||||
|
return
|
||||||
|
if not file_url:
|
||||||
|
on_stderr(
|
||||||
|
f"workshop item {steam_id} skipped: no file_url "
|
||||||
|
f"(steam result: {last_error or 'unknown'})"
|
||||||
|
)
|
||||||
|
skipped.add(steam_id)
|
||||||
continue
|
continue
|
||||||
|
target = cache_path(steam_id)
|
||||||
|
needs_download = (
|
||||||
|
last_downloaded_at is None
|
||||||
|
or not target.exists()
|
||||||
|
or int(target.stat().st_mtime) != int(time_updated)
|
||||||
|
or int(target.stat().st_size) != int(file_size)
|
||||||
|
)
|
||||||
|
if not needs_download:
|
||||||
|
cached += 1
|
||||||
|
continue
|
||||||
|
# download_to_cache only reads steam_id, file_url, file_size, time_updated;
|
||||||
|
# consumer_app_id and result are required by the dataclass but unused here.
|
||||||
|
meta = WorkshopMetadata(
|
||||||
|
steam_id=steam_id,
|
||||||
|
title=title,
|
||||||
|
filename=filename,
|
||||||
|
file_url=file_url,
|
||||||
|
file_size=file_size,
|
||||||
|
time_updated=time_updated,
|
||||||
|
preview_url=preview_url,
|
||||||
|
consumer_app_id=550,
|
||||||
|
result=1,
|
||||||
|
)
|
||||||
|
on_stdout(f"workshop {steam_id} downloading")
|
||||||
|
try:
|
||||||
|
_download_with_retry(
|
||||||
|
meta, cache_root,
|
||||||
|
on_stderr=on_stderr, should_cancel=should_cancel,
|
||||||
|
)
|
||||||
|
except InterruptedError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
with session_scope() as db:
|
||||||
|
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.id == item_id))
|
||||||
|
if wi is not None:
|
||||||
|
wi.last_error = f"download failed: {exc}"
|
||||||
|
raise
|
||||||
|
with session_scope() as db:
|
||||||
|
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.id == item_id))
|
||||||
|
if wi is not None:
|
||||||
|
wi.last_downloaded_at = datetime.now(UTC)
|
||||||
|
wi.last_error = ""
|
||||||
|
downloaded += 1
|
||||||
|
|
||||||
|
# Re-snapshot for symlink phase: only items that have a cache file now
|
||||||
|
# belong in the desired set. Items skipped above stay out.
|
||||||
|
desired: dict[str, Path] = {}
|
||||||
|
for (
|
||||||
|
_item_id, steam_id, _title, _filename, _file_url, _file_size,
|
||||||
|
_time_updated, _preview_url, _last_downloaded_at, _last_error,
|
||||||
|
) in items_data:
|
||||||
|
if steam_id in skipped:
|
||||||
|
continue
|
||||||
|
target = cache_path(steam_id)
|
||||||
|
if not target.exists():
|
||||||
|
continue # shouldn't happen post-download; safety net
|
||||||
desired[f"{steam_id}.vpk"] = target.resolve()
|
desired[f"{steam_id}.vpk"] = target.resolve()
|
||||||
|
|
||||||
if should_cancel():
|
if should_cancel():
|
||||||
on_stderr("workshop build cancelled before applying symlinks")
|
on_stderr("workshop build cancelled before applying symlinks")
|
||||||
return
|
return
|
||||||
|
|
||||||
# existing: symlink-name -> link target (only for symlinks pointing at our cache)
|
# existing: symlink-name -> link target (only symlinks pointing at our cache)
|
||||||
existing: dict[str, Path] = {}
|
existing: dict[str, Path] = {}
|
||||||
for entry in os.scandir(addons_dir):
|
for entry in os.scandir(addons_dir):
|
||||||
if not entry.is_symlink():
|
if not entry.is_symlink():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
target = Path(os.readlink(entry.path))
|
link_target = Path(os.readlink(entry.path))
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resolved = target.resolve(strict=False)
|
resolved = link_target.resolve(strict=False)
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if not _is_under(resolved, cache_root):
|
if not _is_under(resolved, cache_root):
|
||||||
|
|
@ -135,7 +273,6 @@ class WorkshopBuilder:
|
||||||
removed = 0
|
removed = 0
|
||||||
unchanged = 0
|
unchanged = 0
|
||||||
|
|
||||||
# Remove obsolete or stale symlinks first.
|
|
||||||
for name, current_target in existing.items():
|
for name, current_target in existing.items():
|
||||||
if should_cancel():
|
if should_cancel():
|
||||||
on_stderr("workshop build cancelled mid-removal")
|
on_stderr("workshop build cancelled mid-removal")
|
||||||
|
|
@ -146,16 +283,14 @@ class WorkshopBuilder:
|
||||||
removed += 1
|
removed += 1
|
||||||
elif current_target != desired_target:
|
elif current_target != desired_target:
|
||||||
os.unlink(addons_dir / name)
|
os.unlink(addons_dir / name)
|
||||||
# will be recreated below
|
|
||||||
else:
|
else:
|
||||||
unchanged += 1
|
unchanged += 1
|
||||||
|
|
||||||
# Recompute existing post-removal so the create loop knows what's left.
|
|
||||||
post_removal_existing = {
|
post_removal_existing = {
|
||||||
name for name in existing if name in desired and existing[name] == desired[name]
|
name for name in existing
|
||||||
|
if name in desired and existing[name] == desired[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create new symlinks.
|
|
||||||
for name, target in desired.items():
|
for name, target in desired.items():
|
||||||
if should_cancel():
|
if should_cancel():
|
||||||
on_stderr("workshop build cancelled mid-creation")
|
on_stderr("workshop build cancelled mid-creation")
|
||||||
|
|
@ -176,19 +311,14 @@ class WorkshopBuilder:
|
||||||
f"refusing to overwrite foreign symlink at {link_path}"
|
f"refusing to overwrite foreign symlink at {link_path}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
os.symlink(str(target), str(link_path))
|
os.symlink(target, link_path)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
on_stdout(
|
on_stdout(
|
||||||
f"workshop overlay {overlay.name!r}: created={created} "
|
f"workshop overlay {overlay.name!r}: "
|
||||||
f"removed={removed} unchanged={unchanged} "
|
f"downloaded={downloaded} cached={cached} skipped={len(skipped)} "
|
||||||
f"skipped(uncached)={len(skipped)}"
|
f"created={created} removed={removed} unchanged={unchanged}"
|
||||||
)
|
)
|
||||||
for steam_id in skipped:
|
|
||||||
on_stderr(
|
|
||||||
f"workshop item {steam_id} skipped: not yet downloaded "
|
|
||||||
f"(refresh required before this overlay can mount it)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_sandboxed_script(
|
def run_sandboxed_script(
|
||||||
|
|
|
||||||
179
l4d2web/services/rcon.py
Normal file
179
l4d2web/services/rcon.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""Source RCON client + status parser.
|
||||||
|
|
||||||
|
Pure stdlib. One TCP connection per query — fine at our scale (loopback
|
||||||
|
~10-20ms round-trip; pooling not worth the complexity).
|
||||||
|
|
||||||
|
Source RCON wire format:
|
||||||
|
size : little-endian int32 (count of the bytes that follow)
|
||||||
|
req_id: little-endian int32
|
||||||
|
ptype : little-endian int32
|
||||||
|
body : utf-8 string, null-terminated
|
||||||
|
pad : one extra null byte
|
||||||
|
|
||||||
|
Packet types:
|
||||||
|
SERVERDATA_AUTH = 3 (client -> server)
|
||||||
|
SERVERDATA_EXECCOMMAND = 2 (client -> server)
|
||||||
|
SERVERDATA_AUTH_RESPONSE= 2 (server -> client)
|
||||||
|
SERVERDATA_RESPONSE_VALUE = 0 (server -> client)
|
||||||
|
|
||||||
|
After auth, the server sends a type=0 empty packet *first* and then the
|
||||||
|
type=2 auth response. req_id == -1 on the auth response = bad password.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
SERVERDATA_AUTH = 3
|
||||||
|
SERVERDATA_EXECCOMMAND = 2
|
||||||
|
SERVERDATA_AUTH_RESPONSE = 2
|
||||||
|
SERVERDATA_RESPONSE_VALUE = 0
|
||||||
|
|
||||||
|
_STEAM_ID_BASE = 76561197960265728
|
||||||
|
|
||||||
|
|
||||||
|
class RconError(Exception):
|
||||||
|
"""Network, timeout, or protocol error."""
|
||||||
|
|
||||||
|
|
||||||
|
class RconAuthError(RconError):
|
||||||
|
"""The server rejected the password."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class PlayerRow:
|
||||||
|
steam_id_64: str
|
||||||
|
name: str
|
||||||
|
connected_seconds: int
|
||||||
|
ping: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class StatusResponse:
|
||||||
|
map: str
|
||||||
|
players: int
|
||||||
|
max_players: int
|
||||||
|
bots: int
|
||||||
|
hibernating: bool
|
||||||
|
roster: list[PlayerRow]
|
||||||
|
|
||||||
|
|
||||||
|
def query_status(
|
||||||
|
host: str, port: int, password: str, *, timeout: float = 2.0
|
||||||
|
) -> StatusResponse:
|
||||||
|
"""Connect to the RCON port, authenticate, send `status`, return parsed result."""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
sock.connect((host, port))
|
||||||
|
except OSError as exc:
|
||||||
|
raise RconError(f"connect failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
_send_packet(sock, 1, SERVERDATA_AUTH, password)
|
||||||
|
# Drain the leading empty type-0 packet; then read the real auth response.
|
||||||
|
r1 = _recv_packet(sock)
|
||||||
|
r2 = _recv_packet(sock)
|
||||||
|
auth = r2 if r1[1] == SERVERDATA_RESPONSE_VALUE else r1
|
||||||
|
if auth[0] == -1:
|
||||||
|
raise RconAuthError("bad rcon password")
|
||||||
|
|
||||||
|
_send_packet(sock, 2, SERVERDATA_EXECCOMMAND, "status")
|
||||||
|
_, _, body = _recv_packet(sock)
|
||||||
|
except (OSError, socket.timeout) as exc:
|
||||||
|
raise RconError(f"rcon i/o error: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
return parse_status(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_packet(sock: socket.socket, req_id: int, ptype: int, body: str) -> None:
|
||||||
|
body_bytes = body.encode("utf-8") + b"\x00\x00"
|
||||||
|
size = 4 + 4 + len(body_bytes)
|
||||||
|
sock.sendall(struct.pack("<iii", size, req_id, ptype) + body_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def _recv_packet(sock: socket.socket) -> tuple[int, int, str]:
|
||||||
|
size = struct.unpack("<i", _recvall(sock, 4))[0]
|
||||||
|
payload = _recvall(sock, size)
|
||||||
|
req_id, ptype = struct.unpack("<ii", payload[:8])
|
||||||
|
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
||||||
|
return req_id, ptype, body
|
||||||
|
|
||||||
|
|
||||||
|
def _recvall(sock: socket.socket, n: int) -> bytes:
|
||||||
|
data = b""
|
||||||
|
while len(data) < n:
|
||||||
|
chunk = sock.recv(n - len(data))
|
||||||
|
if not chunk:
|
||||||
|
raise RconError("rcon connection closed")
|
||||||
|
data += chunk
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Status parsing -------------------------------------------------------
|
||||||
|
|
||||||
|
_MAP_RE = re.compile(r"^map\s*:\s*(\S+)", re.MULTILINE)
|
||||||
|
_PLAYERS_RE = re.compile(
|
||||||
|
r"^players\s*:\s*(\d+)\s+humans,\s*(\d+)\s+bots\s*\((\d+)\s+max\)"
|
||||||
|
r"\s*\((not hibernating|hibernating)\)",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
# A status player row: starts with `#`, then variable numeric prefixes,
|
||||||
|
# then a quoted name, then STEAM_X:Y:Z, then connected time, then ping.
|
||||||
|
_PLAYER_RE = re.compile(
|
||||||
|
r'^#\s+(?:\d+\s+)+"(?P<name>[^"]*)"\s+'
|
||||||
|
r"(?P<sid>STEAM_\d+:(?P<y>\d+):(?P<z>\d+))\s+"
|
||||||
|
r"(?P<connected>[\d:]+)\s+"
|
||||||
|
r"(?P<ping>\d+)\s+",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status(body: str) -> StatusResponse:
|
||||||
|
map_match = _MAP_RE.search(body)
|
||||||
|
if not map_match:
|
||||||
|
raise RconError(f"status: no map line in response\n{body!r}")
|
||||||
|
players_match = _PLAYERS_RE.search(body)
|
||||||
|
if not players_match:
|
||||||
|
raise RconError(f"status: no players line\n{body!r}")
|
||||||
|
|
||||||
|
roster: list[PlayerRow] = []
|
||||||
|
for m in _PLAYER_RE.finditer(body):
|
||||||
|
y = int(m.group("y"))
|
||||||
|
z = int(m.group("z"))
|
||||||
|
roster.append(
|
||||||
|
PlayerRow(
|
||||||
|
steam_id_64=str(_STEAM_ID_BASE + (z * 2) + y),
|
||||||
|
name=m.group("name"),
|
||||||
|
connected_seconds=_parse_duration(m.group("connected")),
|
||||||
|
ping=int(m.group("ping")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StatusResponse(
|
||||||
|
map=map_match.group(1),
|
||||||
|
players=int(players_match.group(1)),
|
||||||
|
bots=int(players_match.group(2)),
|
||||||
|
max_players=int(players_match.group(3)),
|
||||||
|
hibernating=(players_match.group(4) == "hibernating"),
|
||||||
|
roster=roster,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_duration(text: str) -> int:
|
||||||
|
"""Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds."""
|
||||||
|
try:
|
||||||
|
parts = [int(p) for p in text.split(":")]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RconError(f"unparseable connected duration: {text!r}") from exc
|
||||||
|
if len(parts) == 2:
|
||||||
|
return parts[0] * 60 + parts[1]
|
||||||
|
if len(parts) == 3:
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||||
|
raise RconError(f"unparseable connected duration: {text!r}")
|
||||||
76
l4d2web/services/steam_users.py
Normal file
76
l4d2web/services/steam_users.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Steam Web API client for player profile lookups.
|
||||||
|
|
||||||
|
Mirrors the shape of l4d2web/services/steam_workshop.py:17-43:
|
||||||
|
- single thread-local requests.Session
|
||||||
|
- 30s timeout
|
||||||
|
- HTTPS only
|
||||||
|
|
||||||
|
Difference: GetPlayerSummaries requires an API key in the querystring,
|
||||||
|
unlike the anonymous workshop endpoints.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
GET_PLAYER_SUMMARIES_URL = (
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/"
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 30.0
|
||||||
|
MAX_IDS_PER_CALL = 100
|
||||||
|
|
||||||
|
_session_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> requests.Session:
|
||||||
|
sess = getattr(_session_local, "session", None)
|
||||||
|
if sess is None:
|
||||||
|
sess = requests.Session()
|
||||||
|
_session_local.session = sess
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def _session_get(url: str, params: dict, timeout: float = REQUEST_TIMEOUT_SECONDS):
|
||||||
|
"""Indirection seam so tests can monkeypatch a fake here."""
|
||||||
|
return _session().get(url, params=params, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class SteamProfile:
|
||||||
|
steam_id_64: str
|
||||||
|
persona_name: str
|
||||||
|
avatar_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_profiles_batch(
|
||||||
|
steam_ids: Iterable[str], *, api_key: str
|
||||||
|
) -> list[SteamProfile]:
|
||||||
|
"""Resolve a batch of SteamID64 strings to persona name + avatar URL.
|
||||||
|
|
||||||
|
Steam's API caps each call at 100 IDs; this helper chunks transparently.
|
||||||
|
IDs that Steam can't resolve (private, deleted) are simply absent from
|
||||||
|
the response and from the returned list.
|
||||||
|
"""
|
||||||
|
ids = list(steam_ids)
|
||||||
|
out: list[SteamProfile] = []
|
||||||
|
for i in range(0, len(ids), MAX_IDS_PER_CALL):
|
||||||
|
chunk = ids[i : i + MAX_IDS_PER_CALL]
|
||||||
|
params = {"key": api_key, "steamids": ",".join(chunk)}
|
||||||
|
resp = _session_get(GET_PLAYER_SUMMARIES_URL, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json() or {}
|
||||||
|
players = (payload.get("response") or {}).get("players") or []
|
||||||
|
for p in players:
|
||||||
|
out.append(
|
||||||
|
SteamProfile(
|
||||||
|
steam_id_64=str(p["steamid"]),
|
||||||
|
persona_name=str(p.get("personaname", "")),
|
||||||
|
avatar_url=str(p.get("avatarmedium", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
57
l4d2web/templates/_live_state.html
Normal file
57
l4d2web/templates/_live_state.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<section class="panel live-state"
|
||||||
|
hx-get="/servers/{{ server.id }}/live-state"
|
||||||
|
hx-trigger="every {{ poll_seconds }}s"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<h2 class="section-title">Live state</h2>
|
||||||
|
{% if not snapshot or not snapshot_fresh %}
|
||||||
|
<p class="muted">No data — server is not currently reporting.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="server-live-summary">
|
||||||
|
{{ snapshot.players }}/{{ snapshot.max_players }}
|
||||||
|
{% if snapshot.hibernating %}· idle{% endif %}
|
||||||
|
· {{ snapshot.map }}
|
||||||
|
<small class="muted">
|
||||||
|
polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_players %}
|
||||||
|
<h3 class="section-subtitle">Current players</h3>
|
||||||
|
<ul class="player-grid">
|
||||||
|
{% for session, profile in current_players %}
|
||||||
|
<li class="player-card">
|
||||||
|
{% if profile and profile.avatar_url %}
|
||||||
|
<img class="avatar" src="{{ profile.avatar_url }}" alt="" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="avatar placeholder" aria-hidden="true"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago
|
||||||
|
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recent_players %}
|
||||||
|
<h3 class="section-subtitle">Recent players</h3>
|
||||||
|
<ul class="player-grid recent">
|
||||||
|
{% for row in recent_players %}
|
||||||
|
<li class="player-card">
|
||||||
|
{% if row.avatar_url %}
|
||||||
|
<img class="avatar" src="{{ row.avatar_url }}" alt="" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="avatar placeholder" aria-hidden="true"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="name">{{ row.persona_name or row.name_at_join }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
@ -51,6 +51,10 @@
|
||||||
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="stack workshop-refresh-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
|
<button type="submit">Refresh from Steam</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="overlay-item-table">
|
<div id="overlay-item-table">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
<h2 class="section-title">Server Log</h2>
|
<h2 class="section-title">Server Log</h2>
|
||||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
|
|
||||||
|
<section class="panel live-state"
|
||||||
|
hx-get="/servers/{{ server.id }}/live-state"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
</section>
|
||||||
|
|
||||||
<h2 class="section-title">Files</h2>
|
<h2 class="section-title">Files</h2>
|
||||||
{% if not file_tree_root_entries %}
|
{% if not file_tree_root_entries %}
|
||||||
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,30 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
|
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th><th>Live</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for server, blueprint in rows %}
|
{% for server, blueprint in rows %}
|
||||||
|
{% set ls = live_state_by_server.get(server.id) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
|
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
|
||||||
<td>{{ server.port }}</td>
|
<td>{{ server.port }}</td>
|
||||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||||
<td>{{ server.desired_state }}</td>
|
<td>{{ server.desired_state }}</td>
|
||||||
<td>{{ server.actual_state }}</td>
|
<td>{{ server.actual_state }}</td>
|
||||||
|
<td class="server-live">
|
||||||
|
{% if server.actual_state != 'running' %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% elif ls is none or not ls.fresh %}
|
||||||
|
<span class="muted" title="no recent data">?</span>
|
||||||
|
{% elif ls.hibernating %}
|
||||||
|
{{ ls.players }}/{{ ls.max_players }} · idle · {{ ls.map }}
|
||||||
|
{% else %}
|
||||||
|
{{ ls.players }}/{{ ls.max_players }} · {{ ls.map }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="5" class="muted">No servers configured.</td></tr>
|
<tr><td colspan="6" class="muted">No servers configured.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
64
l4d2web/tests/test_cli.py
Normal file
64
l4d2web/tests/test_cli.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Tests for the l4d2web Flask CLI subcommands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from click.testing import CliRunner
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.app import create_app
|
||||||
|
from l4d2web.cli import workshop_refresh
|
||||||
|
from l4d2web.db import init_db, session_scope
|
||||||
|
from l4d2web.models import Job
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_env(tmp_path, monkeypatch):
|
||||||
|
db_url = f"sqlite:///{tmp_path/'cli.db'}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
|
init_db()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_refresh_enqueues_job(app_env):
|
||||||
|
runner = CliRunner()
|
||||||
|
with app_env.app_context():
|
||||||
|
result = runner.invoke(workshop_refresh, [])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "enqueued refresh_workshop_items job" in result.output
|
||||||
|
with session_scope() as db:
|
||||||
|
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0].state == "queued"
|
||||||
|
assert jobs[0].user_id is None
|
||||||
|
assert jobs[0].server_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_refresh_is_idempotent_when_job_queued(app_env):
|
||||||
|
runner = CliRunner()
|
||||||
|
with app_env.app_context():
|
||||||
|
first = runner.invoke(workshop_refresh, [])
|
||||||
|
second = runner.invoke(workshop_refresh, [])
|
||||||
|
assert first.exit_code == 0
|
||||||
|
assert second.exit_code == 0
|
||||||
|
assert "already queued" in second.output
|
||||||
|
with session_scope() as db:
|
||||||
|
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
|
||||||
|
assert len(jobs) == 1, "must not insert a second job when one is already queued"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("active_state", ["running", "cancelling"])
|
||||||
|
def test_workshop_refresh_is_idempotent_when_job_active(app_env, active_state):
|
||||||
|
runner = CliRunner()
|
||||||
|
with app_env.app_context():
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(Job(
|
||||||
|
user_id=None, server_id=None,
|
||||||
|
operation="refresh_workshop_items", state=active_state,
|
||||||
|
))
|
||||||
|
result = runner.invoke(workshop_refresh, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert f"already {active_state}" in result.output
|
||||||
|
with session_scope() as db:
|
||||||
|
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
|
@ -343,3 +343,45 @@ def test_initialize_fails_fast_on_uncached_workshop_items(
|
||||||
assert all("initialize" not in cmd for cmd in invocations), invocations
|
assert all("initialize" not in cmd for cmd in invocations), invocations
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_server_spec_payload — rcon_password injection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_server_blueprint(rcon: str = "") -> tuple[Server, Blueprint]:
|
||||||
|
bp = Blueprint(
|
||||||
|
id=1, user_id=1, name="bp",
|
||||||
|
arguments='["-tickrate","60"]',
|
||||||
|
config='["sv_consistency 1","mp_gamemode coop"]',
|
||||||
|
)
|
||||||
|
srv = Server(
|
||||||
|
id=1, user_id=1, blueprint_id=1, name="s", port=27500,
|
||||||
|
rcon_password=rcon,
|
||||||
|
)
|
||||||
|
return srv, bp
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_server_spec_payload_appends_rcon_password_last() -> None:
|
||||||
|
from l4d2web.services.l4d2_facade import build_server_spec_payload
|
||||||
|
|
||||||
|
srv, bp = _make_server_blueprint(rcon="topsecret123")
|
||||||
|
overlays = [(7, "/overlays/7", True), (8, "/overlays/8", False)]
|
||||||
|
spec = build_server_spec_payload(srv, bp, overlays)
|
||||||
|
|
||||||
|
cfg = spec["config"]
|
||||||
|
# rcon_password line is the LAST entry — overlay exec lines + blueprint
|
||||||
|
# config + rcon_password.
|
||||||
|
assert cfg[-1] == 'rcon_password "topsecret123"'
|
||||||
|
# Lines before our injection still contain the blueprint config.
|
||||||
|
assert "sv_consistency 1" in cfg
|
||||||
|
assert "mp_gamemode coop" in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_server_spec_payload_omits_rcon_password_when_empty() -> None:
|
||||||
|
from l4d2web.services.l4d2_facade import build_server_spec_payload
|
||||||
|
|
||||||
|
srv, bp = _make_server_blueprint(rcon="")
|
||||||
|
spec = build_server_spec_payload(srv, bp, [])
|
||||||
|
for line in spec["config"]:
|
||||||
|
assert not line.startswith("rcon_password ")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
371
l4d2web/tests/test_live_state_poller.py
Normal file
371
l4d2web/tests/test_live_state_poller.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""Live-state poller tests.
|
||||||
|
|
||||||
|
Each test seeds an app + DB, monkeypatches the RCON client to return a
|
||||||
|
canned StatusResponse, and asserts on what the poller writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.app import create_app
|
||||||
|
from l4d2web.db import init_db, session_scope
|
||||||
|
from l4d2web.models import (
|
||||||
|
Blueprint,
|
||||||
|
Server,
|
||||||
|
ServerLiveState,
|
||||||
|
ServerPlayerSession,
|
||||||
|
SteamUserProfile,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from l4d2web.services import live_state_poller
|
||||||
|
from l4d2web.services import steam_users
|
||||||
|
from l4d2web.services.rcon import PlayerRow, StatusResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(tmp_path):
|
||||||
|
db_url = f"sqlite:///{tmp_path/'p.db'}"
|
||||||
|
import os
|
||||||
|
os.environ["DATABASE_URL"] = db_url
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "x"})
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
u = User(username="u", password_digest="x"); db.add(u); db.flush()
|
||||||
|
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
|
||||||
|
s = Server(
|
||||||
|
user_id=u.id, blueprint_id=bp.id, name="s", port=27500,
|
||||||
|
rcon_password="pw", actual_state="running",
|
||||||
|
)
|
||||||
|
db.add(s); db.flush()
|
||||||
|
return app, s.id
|
||||||
|
|
||||||
|
|
||||||
|
def _status(players: int, map_: str = "c1m1_hotel", hibernating: bool = False,
|
||||||
|
roster: list[PlayerRow] | None = None) -> StatusResponse:
|
||||||
|
return StatusResponse(
|
||||||
|
map=map_, players=players, max_players=4, bots=0,
|
||||||
|
hibernating=hibernating, roster=roster or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rle_bumps_last_seen_when_state_unchanged(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda host, port, password, timeout: _status(players=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(ServerLiveState).where(ServerLiveState.server_id == sid)
|
||||||
|
.order_by(ServerLiveState.started_at)
|
||||||
|
).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].players == 0
|
||||||
|
assert rows[0].last_seen_at >= rows[0].started_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_rle_inserts_new_row_on_state_change(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
snapshots = iter([_status(players=0), _status(players=1)])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: next(snapshots),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(ServerLiveState).where(ServerLiveState.server_id == sid)
|
||||||
|
.order_by(ServerLiveState.started_at)
|
||||||
|
).all()
|
||||||
|
assert [r.players for r in rows] == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_servers_without_rcon_password(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
with session_scope() as db:
|
||||||
|
s = db.scalar(select(Server).where(Server.id == sid))
|
||||||
|
s.rcon_password = ""
|
||||||
|
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: called.append(1) or _status(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_non_running_servers(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
with session_scope() as db:
|
||||||
|
s = db.scalar(select(Server).where(Server.id == sid))
|
||||||
|
s.actual_state = "stopped"
|
||||||
|
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: called.append(1) or _status(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def _player(steam_id: str = "76561197960828710", name: str = "Alice",
|
||||||
|
connected: int = 21, ping: int = 60) -> PlayerRow:
|
||||||
|
return PlayerRow(
|
||||||
|
steam_id_64=steam_id, name=name, connected_seconds=connected, ping=ping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_player_opens_session_with_backfilled_join(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: _status(players=1, roster=[_player(connected=30)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
sessions = db.scalars(
|
||||||
|
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
|
||||||
|
).all()
|
||||||
|
assert len(sessions) == 1
|
||||||
|
s = sessions[0]
|
||||||
|
assert s.steam_id_64 == "76561197960828710"
|
||||||
|
assert s.name_at_join == "Alice"
|
||||||
|
assert s.left_at is None
|
||||||
|
assert s.min_ping == 60
|
||||||
|
assert s.max_ping == 60
|
||||||
|
# joined_at should be ~30s before now
|
||||||
|
delta = (datetime.now(UTC).replace(tzinfo=None) - s.joined_at).total_seconds()
|
||||||
|
assert 25 <= delta <= 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_closes_when_player_no_longer_in_roster(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
snapshots = iter([
|
||||||
|
_status(players=1, roster=[_player()]),
|
||||||
|
_status(players=0, roster=[]),
|
||||||
|
])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: next(snapshots),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
sessions = db.scalars(
|
||||||
|
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
|
||||||
|
).all()
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0].left_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_ping_range_extends(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
snapshots = iter([
|
||||||
|
_status(players=1, roster=[_player(ping=60)]),
|
||||||
|
_status(players=1, roster=[_player(ping=200)]),
|
||||||
|
_status(players=1, roster=[_player(ping=40)]),
|
||||||
|
])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: next(snapshots),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
s = db.scalar(select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid))
|
||||||
|
assert s.min_ping == 40
|
||||||
|
assert s.max_ping == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_skips_bots(tmp_path, monkeypatch) -> None:
|
||||||
|
# Bots have non-STEAM uniqueid; our parser already drops them, but verify
|
||||||
|
# that even if a non-STEAM steam_id_64 makes it through, sessions filter
|
||||||
|
# it out. We exercise the filter directly by giving a string that doesn't
|
||||||
|
# match the expected SteamID64 numeric form.
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: _status(
|
||||||
|
players=0, roster=[_player(steam_id="BOT", name="Coach")]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
sessions = db.scalars(
|
||||||
|
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
|
||||||
|
).all()
|
||||||
|
assert sessions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_enriches_missing_profile(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
app.config["STEAM_WEB_API_KEY"] = "KEY"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: _status(players=1, roster=[_player()]),
|
||||||
|
)
|
||||||
|
captured: list = []
|
||||||
|
def fake_fetch(ids, *, api_key):
|
||||||
|
captured.append((list(ids), api_key))
|
||||||
|
return [steam_users.SteamProfile(
|
||||||
|
steam_id_64="76561197960828710",
|
||||||
|
persona_name="Alice",
|
||||||
|
avatar_url="https://avatars.../alice_medium.jpg",
|
||||||
|
)]
|
||||||
|
monkeypatch.setattr(live_state_poller, "fetch_profiles_batch", fake_fetch)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
assert captured and captured[0][1] == "KEY"
|
||||||
|
with session_scope() as db:
|
||||||
|
p = db.scalar(select(SteamUserProfile))
|
||||||
|
assert p is not None
|
||||||
|
assert p.persona_name == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_enrichment_when_api_key_unset(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
app.config["STEAM_WEB_API_KEY"] = ""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: _status(players=1, roster=[_player()]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "fetch_profiles_batch",
|
||||||
|
lambda ids, *, api_key: pytest.fail("must not call without key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_trims_old_rows(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
app.config["LIVE_STATE_HISTORY_DAYS"] = 30
|
||||||
|
long_ago = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=45)
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(ServerLiveState(
|
||||||
|
server_id=sid, started_at=long_ago, last_seen_at=long_ago,
|
||||||
|
players=0, max_players=4, bots=0, map="old", hibernating=True,
|
||||||
|
))
|
||||||
|
db.add(ServerPlayerSession(
|
||||||
|
server_id=sid, steam_id_64="76561197960828710",
|
||||||
|
joined_at=long_ago, left_at=long_ago,
|
||||||
|
name_at_join="OldPlayer", min_ping=10, max_ping=10,
|
||||||
|
))
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.prune_history()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
snaps = db.scalars(select(ServerLiveState)).all()
|
||||||
|
sess = db.scalars(select(ServerPlayerSession)).all()
|
||||||
|
assert snaps == []
|
||||||
|
assert sess == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_stuck_sessions_after_threshold(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
app.config["STUCK_SESSION_SECONDS"] = 60
|
||||||
|
way_back = datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=2)
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(ServerPlayerSession(
|
||||||
|
server_id=sid, steam_id_64="76561197960828710",
|
||||||
|
joined_at=way_back, left_at=None,
|
||||||
|
name_at_join="GhostPlayer", min_ping=10, max_ping=10,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Server is in `targets` but the RCON call fails — i.e., we haven't seen
|
||||||
|
# this server respond in a long time. Poller must close stuck sessions.
|
||||||
|
def boom(*a, **kw):
|
||||||
|
from l4d2web.services.rcon import RconError
|
||||||
|
raise RconError("simulated outage")
|
||||||
|
monkeypatch.setattr(live_state_poller, "query_status", boom)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(select(ServerPlayerSession))
|
||||||
|
assert row.left_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_live_state_poller_skipped_during_testing(monkeypatch, tmp_path) -> None:
|
||||||
|
from l4d2web import app as app_module
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app_module, "start_live_state_poller", lambda app: called.append(app)
|
||||||
|
)
|
||||||
|
app_module.create_app({"TESTING": True, "DATABASE_URL": f"sqlite:///{tmp_path/'x.db'}", "SECRET_KEY": "k"})
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_live_state_poller_started_outside_testing(monkeypatch, tmp_path) -> None:
|
||||||
|
from l4d2web import app as app_module
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app_module, "start_live_state_poller", lambda app: called.append(app)
|
||||||
|
)
|
||||||
|
app = app_module.create_app(
|
||||||
|
{"TESTING": False, "DATABASE_URL": f"sqlite:///{tmp_path/'x.db'}", "SECRET_KEY": "k"}
|
||||||
|
)
|
||||||
|
assert called == [app]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_enrichment_when_cache_is_fresh(tmp_path, monkeypatch) -> None:
|
||||||
|
app, sid = _seed(tmp_path)
|
||||||
|
app.config["STEAM_WEB_API_KEY"] = "KEY"
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(SteamUserProfile(
|
||||||
|
steam_id_64="76561197960828710",
|
||||||
|
persona_name="cached",
|
||||||
|
avatar_url="cached.jpg",
|
||||||
|
fetched_at=datetime.now(UTC).replace(tzinfo=None),
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "query_status",
|
||||||
|
lambda *a, **kw: _status(players=1, roster=[_player()]),
|
||||||
|
)
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
live_state_poller, "fetch_profiles_batch",
|
||||||
|
lambda ids, *, api_key: called.append(list(ids)) or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
live_state_poller.poll_once()
|
||||||
|
|
||||||
|
assert called == []
|
||||||
69
l4d2web/tests/test_migrations.py
Normal file
69
l4d2web/tests/test_migrations.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""Tests for migration 0010_server_live_state."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
|
||||||
|
_ALEMBIC_DIR = Path(__file__).resolve().parents[1] / "alembic"
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config(db_url: str) -> Config:
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option("script_location", str(_ALEMBIC_DIR))
|
||||||
|
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0010_backfills_rcon_password(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
db_path = tmp_path / "t.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
|
||||||
|
cfg = _alembic_config(db_url)
|
||||||
|
|
||||||
|
# Run alembic up to 0009 only.
|
||||||
|
command.upgrade(cfg, "0009_user_password_changed_at")
|
||||||
|
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"INSERT INTO users (id, username, password_digest, admin, active, "
|
||||||
|
"created_at, updated_at, password_changed_at) "
|
||||||
|
"VALUES (1, 'u', 'x', 0, 1, '2026-01-01', '2026-01-01', '2026-01-01')"
|
||||||
|
))
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"INSERT INTO blueprints (id, user_id, name, arguments, config, "
|
||||||
|
"created_at, updated_at) "
|
||||||
|
"VALUES (1, 1, 'bp', '[]', '[]', '2026-01-01', '2026-01-01')"
|
||||||
|
))
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
|
||||||
|
"desired_state, actual_state, last_error, created_at, updated_at) "
|
||||||
|
"VALUES (1, 1, 1, 's1', 27600, 'stopped', 'unknown', '', "
|
||||||
|
"'2026-01-01', '2026-01-01')"
|
||||||
|
))
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
|
||||||
|
"desired_state, actual_state, last_error, created_at, updated_at) "
|
||||||
|
"VALUES (2, 1, 1, 's2', 27601, 'stopped', 'unknown', '', "
|
||||||
|
"'2026-01-01', '2026-01-01')"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Apply migration 0010.
|
||||||
|
command.upgrade(cfg, "0010_server_live_state")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.execute(sa.text("SELECT id, rcon_password FROM servers ORDER BY id")).fetchall()
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
for row in rows:
|
||||||
|
assert len(row.rcon_password) >= 32
|
||||||
|
assert row.rcon_password.replace("_", "").replace("-", "").isalnum()
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
assert "server_live_state" in inspector.get_table_names()
|
||||||
|
assert "server_player_session" in inspector.get_table_names()
|
||||||
|
assert "steam_user_profile" in inspector.get_table_names()
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.db import init_db, session_scope
|
from l4d2web.db import init_db, session_scope
|
||||||
from l4d2web.models import Blueprint, User
|
from l4d2web.models import (
|
||||||
|
Blueprint,
|
||||||
|
Server,
|
||||||
|
ServerLiveState,
|
||||||
|
ServerPlayerSession,
|
||||||
|
SteamUserProfile,
|
||||||
|
User,
|
||||||
|
now_utc as now_utc_aware,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None:
|
def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None:
|
||||||
|
|
@ -38,3 +48,63 @@ def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
|
||||||
|
|
||||||
assert user.password_changed_at is not None
|
assert user.password_changed_at is not None
|
||||||
assert user.password_changed_at >= before
|
assert user.password_changed_at >= before
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_has_rcon_password_column(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
u = User(username="u", password_digest="x")
|
||||||
|
db.add(u); db.flush()
|
||||||
|
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]")
|
||||||
|
db.add(bp); db.flush()
|
||||||
|
s = Server(
|
||||||
|
user_id=u.id, blueprint_id=bp.id, name="s", port=27500,
|
||||||
|
rcon_password="abc",
|
||||||
|
)
|
||||||
|
db.add(s); db.flush()
|
||||||
|
assert db.scalar(select(Server.rcon_password).where(Server.id == s.id)) == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_live_state_table_columns(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
u = User(username="u", password_digest="x"); db.add(u); db.flush()
|
||||||
|
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
|
||||||
|
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27501, rcon_password="x")
|
||||||
|
db.add(s); db.flush()
|
||||||
|
row = ServerLiveState(
|
||||||
|
server_id=s.id, started_at=now_utc_aware(), last_seen_at=now_utc_aware(),
|
||||||
|
players=2, max_players=4, bots=0, map="c1m1_hotel", hibernating=False,
|
||||||
|
)
|
||||||
|
db.add(row); db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_player_session_table_columns(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
u = User(username="u", password_digest="x"); db.add(u); db.flush()
|
||||||
|
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
|
||||||
|
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27502, rcon_password="x")
|
||||||
|
db.add(s); db.flush()
|
||||||
|
row = ServerPlayerSession(
|
||||||
|
server_id=s.id, steam_id_64="76561197960828710",
|
||||||
|
joined_at=now_utc_aware(), left_at=None,
|
||||||
|
name_at_join="Crone", min_ping=42, max_ping=185,
|
||||||
|
)
|
||||||
|
db.add(row); db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
row = SteamUserProfile(
|
||||||
|
steam_id_64="76561197960828710",
|
||||||
|
persona_name="MrCool42",
|
||||||
|
avatar_url="https://avatars.cloudflare.steamstatic.com/abc_medium.jpg",
|
||||||
|
fetched_at=now_utc_aware(),
|
||||||
|
)
|
||||||
|
db.add(row); db.flush()
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ def _add_workshop_item(steam_id: str, *, downloaded: bool, cache_root: Path, con
|
||||||
if downloaded:
|
if downloaded:
|
||||||
cache_root.mkdir(parents=True, exist_ok=True)
|
cache_root.mkdir(parents=True, exist_ok=True)
|
||||||
(cache_root / f"{steam_id}.vpk").write_bytes(content)
|
(cache_root / f"{steam_id}.vpk").write_bytes(content)
|
||||||
|
os.utime(cache_root / f"{steam_id}.vpk", (1700000000, 1700000000))
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
wi = WorkshopItem(
|
wi = WorkshopItem(
|
||||||
steam_id=steam_id,
|
steam_id=steam_id,
|
||||||
|
|
@ -135,27 +136,6 @@ def test_workshop_builder_creates_absolute_symlinks(env: Path) -> None:
|
||||||
assert link_b.resolve() == (cache_root / "1002.vpk").resolve()
|
assert link_b.resolve() == (cache_root / "1002.vpk").resolve()
|
||||||
|
|
||||||
|
|
||||||
def test_workshop_builder_skips_uncached_items_with_warning(env: Path) -> None:
|
|
||||||
_, overlay_id = _create_user_and_overlay("ws", "workshop")
|
|
||||||
cache_root = env / "workshop_cache"
|
|
||||||
|
|
||||||
cached = _add_workshop_item("1001", downloaded=True, cache_root=cache_root)
|
|
||||||
uncached = _add_workshop_item("9999", downloaded=False, cache_root=cache_root)
|
|
||||||
_associate(overlay_id, cached)
|
|
||||||
_associate(overlay_id, uncached)
|
|
||||||
|
|
||||||
out, err, on_stdout, on_stderr = _capture_logs()
|
|
||||||
with session_scope() as s:
|
|
||||||
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
|
||||||
overlay_builders.BUILDERS["workshop"].build(
|
|
||||||
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False
|
|
||||||
)
|
|
||||||
|
|
||||||
addons = env / "overlays" / "7" / "left4dead2" / "addons"
|
|
||||||
assert (addons / "1001.vpk").is_symlink()
|
|
||||||
assert not (addons / "9999.vpk").exists(), "must NOT create dangling symlink"
|
|
||||||
assert any("9999" in line and ("skip" in line.lower() or "uncached" in line.lower()) for line in err + out), err + out
|
|
||||||
|
|
||||||
|
|
||||||
def test_workshop_builder_rerun_is_idempotent(env: Path) -> None:
|
def test_workshop_builder_rerun_is_idempotent(env: Path) -> None:
|
||||||
_, overlay_id = _create_user_and_overlay("ws", "workshop")
|
_, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
|
@ -380,3 +360,399 @@ def test_script_builder_cleans_up_tmpfile_on_failure(env, monkeypatch) -> None:
|
||||||
should_cancel=lambda: False,
|
should_cancel=lambda: False,
|
||||||
)
|
)
|
||||||
assert not os.path.exists(captured["script_path"])
|
assert not os.path.exists(captured["script_path"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_with_cancel_returns_normally_when_not_cancelled():
|
||||||
|
from l4d2web.services.overlay_builders import _sleep_with_cancel
|
||||||
|
|
||||||
|
cancelled = _sleep_with_cancel(0.05, lambda: False, poll_interval=0.01)
|
||||||
|
assert cancelled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_with_cancel_returns_early_when_cancelled():
|
||||||
|
import time
|
||||||
|
from l4d2web.services.overlay_builders import _sleep_with_cancel
|
||||||
|
|
||||||
|
flag = {"cancel": False}
|
||||||
|
def cancel_check():
|
||||||
|
return flag["cancel"]
|
||||||
|
|
||||||
|
import threading
|
||||||
|
threading.Timer(0.05, lambda: flag.update(cancel=True)).start()
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
cancelled = _sleep_with_cancel(5.0, cancel_check, poll_interval=0.01)
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
|
assert cancelled is True
|
||||||
|
assert elapsed < 0.5, f"should have woken up promptly, slept {elapsed:.3f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_retry_succeeds_on_first_attempt(env, tmp_path, monkeypatch):
|
||||||
|
from l4d2web.services import overlay_builders, steam_workshop
|
||||||
|
|
||||||
|
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
|
||||||
|
calls = []
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
calls.append(1)
|
||||||
|
return cache_root / f"{meta.steam_id}.vpk"
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
|
||||||
|
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
overlay_builders._download_with_retry(
|
||||||
|
meta, tmp_path / "cache",
|
||||||
|
on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
assert calls == [1]
|
||||||
|
assert err == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_retry_retries_then_succeeds(env, tmp_path, monkeypatch):
|
||||||
|
import requests
|
||||||
|
from l4d2web.services import overlay_builders, steam_workshop
|
||||||
|
|
||||||
|
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
|
||||||
|
attempts = {"n": 0}
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
attempts["n"] += 1
|
||||||
|
if attempts["n"] < 3:
|
||||||
|
raise requests.ConnectionError("boom")
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
|
||||||
|
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
overlay_builders._download_with_retry(
|
||||||
|
meta, tmp_path / "cache",
|
||||||
|
on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
assert attempts["n"] == 3
|
||||||
|
assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_retry_exhausts_and_raises(env, tmp_path, monkeypatch):
|
||||||
|
import requests
|
||||||
|
from l4d2web.services import overlay_builders, steam_workshop
|
||||||
|
|
||||||
|
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
raise requests.ConnectionError("permanent")
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
|
||||||
|
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with pytest.raises(requests.ConnectionError):
|
||||||
|
overlay_builders._download_with_retry(
|
||||||
|
meta, tmp_path / "cache",
|
||||||
|
on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
# Two stderr "attempt N/3 failed" lines for attempts 1 and 2; the final
|
||||||
|
# attempt re-raises without logging.
|
||||||
|
assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_retry_propagates_interrupted(env, tmp_path, monkeypatch):
|
||||||
|
from l4d2web.services import overlay_builders, steam_workshop
|
||||||
|
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
raise InterruptedError("cancelled")
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
|
||||||
|
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with pytest.raises(InterruptedError):
|
||||||
|
overlay_builders._download_with_retry(
|
||||||
|
meta, tmp_path / "cache",
|
||||||
|
on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_retry_bails_when_cancelled_during_backoff(env, tmp_path, monkeypatch):
|
||||||
|
import requests
|
||||||
|
from l4d2web.services import overlay_builders, steam_workshop
|
||||||
|
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
raise requests.ConnectionError("boom")
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: True)
|
||||||
|
|
||||||
|
meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
|
||||||
|
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with pytest.raises(InterruptedError):
|
||||||
|
overlay_builders._download_with_retry(
|
||||||
|
meta, tmp_path / "cache",
|
||||||
|
on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_meta_from_db_row(steam_id: str, *, file_size: int, time_updated: int):
|
||||||
|
from l4d2web.services import steam_workshop
|
||||||
|
return steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id=steam_id, title=f"item-{steam_id}", filename=f"orig-{steam_id}.vpk",
|
||||||
|
file_url=f"https://example.com/{steam_id}.vpk", file_size=file_size,
|
||||||
|
time_updated=time_updated, preview_url="", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_downloads_uncached_and_stamps_timestamp(env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
cache_root = tmp_path / "workshop_cache"
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("2001", downloaded=False, cache_root=cache_root)
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
download_calls = []
|
||||||
|
def fake_download(meta, cache_root_arg, *, should_cancel=None):
|
||||||
|
download_calls.append(meta.steam_id)
|
||||||
|
cache_root_arg.mkdir(parents=True, exist_ok=True)
|
||||||
|
(cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"data")
|
||||||
|
os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated))
|
||||||
|
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
overlay = s.scalar(__import__("sqlalchemy").select(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert download_calls == ["2001"]
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _select
|
||||||
|
wi = s.scalar(_select(WorkshopItem).where(WorkshopItem.id == item_id))
|
||||||
|
assert wi.last_downloaded_at is not None
|
||||||
|
assert wi.last_error == ""
|
||||||
|
|
||||||
|
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
|
||||||
|
assert (addons / "2001.vpk").is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_skips_already_cached(env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
cache_root = tmp_path / "workshop_cache"
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("2002", downloaded=True, cache_root=cache_root)
|
||||||
|
# Make the cache file's (mtime, size) match the DB row exactly.
|
||||||
|
file_path = cache_root / "2002.vpk"
|
||||||
|
os.utime(file_path, (1700000000, 1700000000))
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel, update as _upd
|
||||||
|
s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values(
|
||||||
|
file_size=os.path.getsize(file_path), time_updated=1700000000,
|
||||||
|
))
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
overlay_builders, "download_to_cache",
|
||||||
|
lambda *a, **kw: called.append(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert called == [], "should not call downloader for an already-cached item"
|
||||||
|
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
|
||||||
|
assert (addons / "2002.vpk").is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_redownloads_stale_cache(env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
cache_root = tmp_path / "workshop_cache"
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("2003", downloaded=True, cache_root=cache_root)
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import update as _upd
|
||||||
|
s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values(
|
||||||
|
file_size=99, time_updated=1800000000,
|
||||||
|
))
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
download_calls = []
|
||||||
|
def fake_download(meta, cache_root_arg, *, should_cancel=None):
|
||||||
|
download_calls.append(meta.steam_id)
|
||||||
|
(cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____")
|
||||||
|
os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated))
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert download_calls == ["2003"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_skips_items_with_no_file_url(env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
with session_scope() as s:
|
||||||
|
wi = WorkshopItem(
|
||||||
|
steam_id="2004", title="gone", filename="",
|
||||||
|
file_url="", file_size=0, time_updated=0, preview_url="",
|
||||||
|
last_downloaded_at=None, last_error="steam result 9",
|
||||||
|
)
|
||||||
|
s.add(wi)
|
||||||
|
s.flush()
|
||||||
|
item_id = wi.id
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
overlay_builders, "download_to_cache",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(AssertionError("must not be called")),
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("2004" in line and "skipped" in line for line in err)
|
||||||
|
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
|
||||||
|
assert not (addons / "2004.vpk").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_fails_when_all_retries_exhausted(env, tmp_path, monkeypatch):
|
||||||
|
import requests
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("2005", downloaded=False, cache_root=tmp_path / "workshop_cache")
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
overlay_builders, "download_to_cache",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("net")),
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
with pytest.raises(requests.ConnectionError):
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
wi = s.scalar(_sel(WorkshopItem).where(WorkshopItem.id == item_id))
|
||||||
|
assert "download failed" in wi.last_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_cancels_cleanly_during_download_phase(env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("2006", downloaded=False, cache_root=tmp_path / "workshop_cache")
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
cancel_flag = {"v": False}
|
||||||
|
def fake_download(meta, cache_root, *, should_cancel=None):
|
||||||
|
cancel_flag["v"] = True
|
||||||
|
raise InterruptedError("cancelled")
|
||||||
|
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
with pytest.raises(InterruptedError):
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr,
|
||||||
|
should_cancel=lambda: cancel_flag["v"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_refuses_to_overwrite_non_symlink_file(env, tmp_path, monkeypatch):
|
||||||
|
"""If a plain file collides with a workshop symlink name, the build logs
|
||||||
|
a refusal and leaves the file alone instead of crashing."""
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
cache_root = tmp_path / "workshop_cache"
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("3001", downloaded=True, cache_root=cache_root)
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
# Pre-create a plain file (not a symlink) where the builder would place its symlink.
|
||||||
|
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
|
||||||
|
addons.mkdir(parents=True, exist_ok=True)
|
||||||
|
(addons / "3001.vpk").write_bytes(b"manual file, don't touch")
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The plain file is still there, unchanged.
|
||||||
|
assert (addons / "3001.vpk").exists()
|
||||||
|
assert not (addons / "3001.vpk").is_symlink()
|
||||||
|
assert (addons / "3001.vpk").read_bytes() == b"manual file, don't touch"
|
||||||
|
# And a refusal message was logged.
|
||||||
|
assert any("refusing to overwrite non-symlink" in line for line in err)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_build_refuses_to_overwrite_foreign_symlink(env, tmp_path, monkeypatch):
|
||||||
|
"""A symlink pointing outside the workshop cache is left alone — not
|
||||||
|
overwritten, not failed."""
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
cache_root = tmp_path / "workshop_cache"
|
||||||
|
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
|
||||||
|
item_id = _add_workshop_item("3002", downloaded=True, cache_root=cache_root)
|
||||||
|
_associate(overlay_id, item_id)
|
||||||
|
|
||||||
|
# Pre-create a symlink pointing outside the cache root.
|
||||||
|
foreign_target = tmp_path / "elsewhere" / "thing.vpk"
|
||||||
|
foreign_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
foreign_target.write_bytes(b"some other vpk")
|
||||||
|
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
|
||||||
|
addons.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.symlink(foreign_target, addons / "3002.vpk")
|
||||||
|
|
||||||
|
out, err, on_stdout, on_stderr = _capture_logs()
|
||||||
|
with session_scope() as s:
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
|
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
|
||||||
|
s.expunge(overlay)
|
||||||
|
overlay_builders.WorkshopBuilder().build(
|
||||||
|
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The foreign symlink still points where it did.
|
||||||
|
assert (addons / "3002.vpk").is_symlink()
|
||||||
|
assert os.readlink(addons / "3002.vpk") == str(foreign_target)
|
||||||
|
assert any("refusing to overwrite foreign symlink" in line for line in err)
|
||||||
|
|
|
||||||
|
|
@ -742,3 +742,35 @@ def test_overlay_detail_no_global_source_block(auth_client_with_server) -> None:
|
||||||
|
|
||||||
assert "Global source" not in text
|
assert "Global source" not in text
|
||||||
assert "source_url" not in text
|
assert "source_url" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_overlay_detail_renders_refresh_button(auth_client_with_server) -> None:
|
||||||
|
"""The owner sees a 'Refresh from Steam' button on their workshop overlay."""
|
||||||
|
with session_scope() as s:
|
||||||
|
user_id = s.query(User).filter_by(username="alice").one().id
|
||||||
|
overlay_id = _seed_overlay("ws-refresh-button", "workshop", user_id)
|
||||||
|
|
||||||
|
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
|
||||||
|
text = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Refresh from Steam" in text
|
||||||
|
assert f'action="/overlays/{overlay_id}/refresh"' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_workshop_overlay_refresh_button_hidden_during_build(auth_client_with_server) -> None:
|
||||||
|
"""While a build is in flight the Refresh button hides, matching the Add form."""
|
||||||
|
with session_scope() as s:
|
||||||
|
user_id = s.query(User).filter_by(username="alice").one().id
|
||||||
|
overlay_id = _seed_overlay("ws-refresh-hidden", "workshop", user_id)
|
||||||
|
with session_scope() as s:
|
||||||
|
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running"))
|
||||||
|
|
||||||
|
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
|
||||||
|
text = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Refresh from Steam" not in text
|
||||||
|
# Positive companion: confirm the build-running guard actually fired,
|
||||||
|
# otherwise the negative could pass vacuously on a broken page.
|
||||||
|
assert "building…" in text
|
||||||
|
|
|
||||||
155
l4d2web/tests/test_rcon.py
Normal file
155
l4d2web/tests/test_rcon.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""Source RCON client tests against an in-process TCP fixture.
|
||||||
|
|
||||||
|
The handshake quirk we verified live: after a SERVERDATA_AUTH (type=3) the
|
||||||
|
server sends a SERVERDATA_RESPONSE_VALUE (type=0) FIRST and THEN the
|
||||||
|
SERVERDATA_AUTH_RESPONSE (type=2). The auth response's req_id == -1 means
|
||||||
|
bad password. The client must consume both packets before sending the
|
||||||
|
command.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from l4d2web.services.rcon import (
|
||||||
|
RconAuthError,
|
||||||
|
RconError,
|
||||||
|
query_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack(req_id: int, ptype: int, body: str) -> bytes:
|
||||||
|
body_bytes = body.encode("utf-8") + b"\x00\x00"
|
||||||
|
size = 4 + 4 + len(body_bytes)
|
||||||
|
return struct.pack("<iii", size, req_id, ptype) + body_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_one(conn: socket.socket) -> tuple[int, int, str]:
|
||||||
|
raw_size = conn.recv(4)
|
||||||
|
size = struct.unpack("<i", raw_size)[0]
|
||||||
|
payload = b""
|
||||||
|
while len(payload) < size:
|
||||||
|
payload += conn.recv(size - len(payload))
|
||||||
|
req_id, ptype = struct.unpack("<ii", payload[:8])
|
||||||
|
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
||||||
|
return req_id, ptype, body
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_rcon_server(handler) -> Iterator[int]:
|
||||||
|
"""Start a TCP server on an ephemeral port; handler(conn) runs in a thread.
|
||||||
|
|
||||||
|
Handler exceptions propagate at context exit so a buggy handler surfaces
|
||||||
|
as a real test failure instead of degrading into a client-side timeout.
|
||||||
|
"""
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
server.bind(("127.0.0.1", 0))
|
||||||
|
server.listen(1)
|
||||||
|
port = server.getsockname()[1]
|
||||||
|
server.settimeout(3.0)
|
||||||
|
|
||||||
|
handler_error: list[BaseException] = []
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
try:
|
||||||
|
conn, _ = server.accept()
|
||||||
|
try:
|
||||||
|
handler(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
handler_error.append(exc)
|
||||||
|
|
||||||
|
t = threading.Thread(target=serve, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
yield port
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
t.join(timeout=1.0)
|
||||||
|
if handler_error:
|
||||||
|
raise AssertionError("fake_rcon_server handler raised") from handler_error[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
response_body = (
|
||||||
|
"hostname: Left 4 Dead 2\n"
|
||||||
|
"version : 2.2.4.3 9309 secure (unknown)\n"
|
||||||
|
"udp/ip : 127.0.0.1:27016 [ public 1.2.3.4:27016 ]\n"
|
||||||
|
"os : Linux Dedicated\n"
|
||||||
|
"map : c1m2_streets\n"
|
||||||
|
"players : 1 humans, 0 bots (4 max) (not hibernating) (reserved 1860000e7e5e446)\n"
|
||||||
|
"\n"
|
||||||
|
"# userid name uniqueid connected ping loss state rate adr\n"
|
||||||
|
'# 2 1 "Crone" STEAM_1:0:12376499 00:21 185 20 active 30000 91.55.5.100:27005\n'
|
||||||
|
"#end\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(conn: socket.socket) -> None:
|
||||||
|
req_id, ptype, body = _unpack_one(conn)
|
||||||
|
assert ptype == 3
|
||||||
|
assert body == "letmein"
|
||||||
|
conn.sendall(_pack(req_id, 0, ""))
|
||||||
|
conn.sendall(_pack(req_id, 2, ""))
|
||||||
|
cmd_id, cmd_type, cmd = _unpack_one(conn)
|
||||||
|
assert cmd_type == 2
|
||||||
|
assert cmd == "status"
|
||||||
|
conn.sendall(_pack(cmd_id, 0, response_body))
|
||||||
|
|
||||||
|
with fake_rcon_server(handler) as port:
|
||||||
|
result = query_status("127.0.0.1", port, "letmein", timeout=2.0)
|
||||||
|
|
||||||
|
assert result.map == "c1m2_streets"
|
||||||
|
assert result.players == 1
|
||||||
|
assert result.bots == 0
|
||||||
|
assert result.max_players == 4
|
||||||
|
assert result.hibernating is False
|
||||||
|
assert len(result.roster) == 1
|
||||||
|
p = result.roster[0]
|
||||||
|
assert p.name == "Crone"
|
||||||
|
assert p.steam_id_64 == "76561197985018726" # 76561197960265728 + 0 + 12376499*2
|
||||||
|
assert p.connected_seconds == 21
|
||||||
|
assert p.ping == 185
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def handler(conn: socket.socket) -> None:
|
||||||
|
req_id, _, _ = _unpack_one(conn)
|
||||||
|
conn.sendall(_pack(req_id, 0, ""))
|
||||||
|
conn.sendall(_pack(-1, 2, "")) # bad password sentinel
|
||||||
|
|
||||||
|
with fake_rcon_server(handler) as port:
|
||||||
|
with pytest.raises(RconAuthError):
|
||||||
|
query_status("127.0.0.1", port, "wrong", timeout=2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def handler(conn: socket.socket) -> None:
|
||||||
|
import time
|
||||||
|
time.sleep(3.0)
|
||||||
|
|
||||||
|
with fake_rcon_server(handler) as port:
|
||||||
|
with pytest.raises(RconError):
|
||||||
|
query_status("127.0.0.1", port, "x", timeout=0.3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_duration_handles_hours() -> None:
|
||||||
|
from l4d2web.services.rcon import _parse_duration
|
||||||
|
|
||||||
|
assert _parse_duration("00:21") == 21
|
||||||
|
assert _parse_duration("01:23:45") == 5025
|
||||||
|
assert _parse_duration("12:00") == 720
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_duration_rejects_malformed_as_rcon_error() -> None:
|
||||||
|
from l4d2web.services.rcon import _parse_duration
|
||||||
|
|
||||||
|
for bad in ["", ":", "abc", "1:", ":5", "1:2:3:4"]:
|
||||||
|
with pytest.raises(RconError):
|
||||||
|
_parse_duration(bad)
|
||||||
|
|
@ -427,6 +427,147 @@ def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
|
||||||
assert response.headers["Location"] == f"/servers/{server_id}"
|
assert response.headers["Location"] == f"/servers/{server_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_generates_rcon_password(user_client_with_blueprints) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.models import Server
|
||||||
|
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
res = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": "fresh", "blueprint_id": str(data["blueprint_id"])},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert res.status_code in (200, 201, 302)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
row = db.scalar(select(Server).where(Server.name == "fresh"))
|
||||||
|
assert row is not None
|
||||||
|
assert len(row.rcon_password) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_servers_index_renders_live_state_badge(user_client_with_blueprints) -> None:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.models import Server, ServerLiveState
|
||||||
|
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
|
||||||
|
# Seed one server with a recent snapshot, one without.
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
with session_scope() as db:
|
||||||
|
s_active = Server(
|
||||||
|
user_id=data["user_id"],
|
||||||
|
blueprint_id=data["blueprint_id"],
|
||||||
|
name="active",
|
||||||
|
port=27700,
|
||||||
|
rcon_password="x",
|
||||||
|
actual_state="running",
|
||||||
|
)
|
||||||
|
s_stale = Server(
|
||||||
|
user_id=data["user_id"],
|
||||||
|
blueprint_id=data["blueprint_id"],
|
||||||
|
name="stale",
|
||||||
|
port=27701,
|
||||||
|
rcon_password="x",
|
||||||
|
actual_state="running",
|
||||||
|
)
|
||||||
|
db.add_all([s_active, s_stale])
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
ServerLiveState(
|
||||||
|
server_id=s_active.id,
|
||||||
|
started_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
players=2,
|
||||||
|
max_players=4,
|
||||||
|
bots=0,
|
||||||
|
map="c1m2_streets",
|
||||||
|
hibernating=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
old = now - timedelta(minutes=5)
|
||||||
|
db.add(
|
||||||
|
ServerLiveState(
|
||||||
|
server_id=s_stale.id,
|
||||||
|
started_at=old,
|
||||||
|
last_seen_at=old,
|
||||||
|
players=0,
|
||||||
|
max_players=4,
|
||||||
|
bots=0,
|
||||||
|
map="c1m1_hotel",
|
||||||
|
hibernating=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/servers")
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
assert "2/4" in html
|
||||||
|
assert "c1m2_streets" in html
|
||||||
|
# Stale server's map MUST NOT render — fresh badge condition must guard it.
|
||||||
|
assert "c1m1_hotel" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_state_fragment_renders_current_and_recent(user_client_with_blueprints) -> None:
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from l4d2web.models import (
|
||||||
|
Server, ServerLiveState, ServerPlayerSession, SteamUserProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
with session_scope() as db:
|
||||||
|
srv = Server(
|
||||||
|
user_id=data["user_id"], blueprint_id=data["blueprint_id"],
|
||||||
|
name="srv", port=27800, rcon_password="x", actual_state="running",
|
||||||
|
)
|
||||||
|
db.add(srv); db.flush()
|
||||||
|
srv_id = srv.id
|
||||||
|
db.add(ServerLiveState(
|
||||||
|
server_id=srv_id, started_at=now, last_seen_at=now,
|
||||||
|
players=1, max_players=4, bots=0, map="c1m2_streets", hibernating=False,
|
||||||
|
))
|
||||||
|
db.add(ServerPlayerSession(
|
||||||
|
server_id=srv_id, steam_id_64="76561197960828710",
|
||||||
|
joined_at=now - timedelta(minutes=5), left_at=None,
|
||||||
|
name_at_join="Crone", min_ping=40, max_ping=60,
|
||||||
|
))
|
||||||
|
db.add(ServerPlayerSession(
|
||||||
|
server_id=srv_id, steam_id_64="76561198021234567",
|
||||||
|
joined_at=now - timedelta(hours=2), left_at=now - timedelta(hours=1),
|
||||||
|
name_at_join="OldPlayer", min_ping=20, max_ping=80,
|
||||||
|
))
|
||||||
|
db.add(SteamUserProfile(
|
||||||
|
steam_id_64="76561197960828710",
|
||||||
|
persona_name="MrCool42",
|
||||||
|
avatar_url="https://avatars.cloudflare.steamstatic.com/cur_medium.jpg",
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
db.add(SteamUserProfile(
|
||||||
|
steam_id_64="76561198021234567",
|
||||||
|
persona_name="OldPersona",
|
||||||
|
avatar_url="https://avatars.cloudflare.steamstatic.com/old_medium.jpg",
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
res = client.get(f"/servers/{srv_id}/live-state")
|
||||||
|
assert res.status_code == 200
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
# Summary
|
||||||
|
assert "1/4" in html
|
||||||
|
assert "c1m2_streets" in html
|
||||||
|
# Current player block
|
||||||
|
assert "MrCool42" in html
|
||||||
|
assert "cur_medium.jpg" in html
|
||||||
|
assert "40-60" in html or "40–60" in html
|
||||||
|
# Recent block — only OldPlayer, not MrCool42
|
||||||
|
assert "OldPersona" in html
|
||||||
|
assert "old_medium.jpg" in html
|
||||||
|
|
||||||
|
|
||||||
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
|
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
|
||||||
78
l4d2web/tests/test_steam_users.py
Normal file
78
l4d2web/tests/test_steam_users.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from l4d2web.services import steam_users
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, json_body: dict[str, Any], status: int = 200) -> None:
|
||||||
|
self._body = json_body
|
||||||
|
self.status_code = status
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RuntimeError(f"http {self.status_code}")
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_get(monkeypatch: pytest.MonkeyPatch, body: dict, capture: list) -> None:
|
||||||
|
def fake_get(url: str, params: dict, timeout: float = 30.0) -> _FakeResponse:
|
||||||
|
capture.append({"url": url, "params": params, "timeout": timeout})
|
||||||
|
return _FakeResponse(body)
|
||||||
|
|
||||||
|
monkeypatch.setattr(steam_users, "_session_get", fake_get)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_builds_correct_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: list = []
|
||||||
|
body = {"response": {"players": [
|
||||||
|
{"steamid": "76561197960828710", "personaname": "Alice",
|
||||||
|
"avatarmedium": "https://avatars.../alice_medium.jpg"},
|
||||||
|
]}}
|
||||||
|
_patched_get(monkeypatch, body, captured)
|
||||||
|
|
||||||
|
profiles = steam_users.fetch_profiles_batch(
|
||||||
|
["76561197960828710", "76561198021234567"], api_key="KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured[0]["url"].endswith("/GetPlayerSummaries/v0002/")
|
||||||
|
assert captured[0]["params"]["key"] == "KEY"
|
||||||
|
assert captured[0]["params"]["steamids"] == "76561197960828710,76561198021234567"
|
||||||
|
assert len(profiles) == 1
|
||||||
|
p = profiles[0]
|
||||||
|
assert p.steam_id_64 == "76561197960828710"
|
||||||
|
assert p.persona_name == "Alice"
|
||||||
|
assert p.avatar_url.endswith("alice_medium.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_skips_private_or_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# The Steam API simply omits non-resolvable IDs from the response. Caller
|
||||||
|
# should accept that and return only what's there.
|
||||||
|
body = {"response": {"players": [
|
||||||
|
{"steamid": "76561197960828710", "personaname": "Alice",
|
||||||
|
"avatarmedium": "https://avatars.../alice_medium.jpg"},
|
||||||
|
]}}
|
||||||
|
_patched_get(monkeypatch, body, [])
|
||||||
|
profiles = steam_users.fetch_profiles_batch(
|
||||||
|
["76561197960828710", "76561197999999999"], api_key="KEY"
|
||||||
|
)
|
||||||
|
assert len(profiles) == 1
|
||||||
|
assert profiles[0].steam_id_64 == "76561197960828710"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profiles_batch_chunks_by_100(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
ids = [str(76561197960000000 + i) for i in range(150)]
|
||||||
|
calls: list = []
|
||||||
|
body = {"response": {"players": []}}
|
||||||
|
_patched_get(monkeypatch, body, calls)
|
||||||
|
|
||||||
|
steam_users.fetch_profiles_batch(ids, api_key="KEY")
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0]["params"]["steamids"].count(",") == 99 # 100 ids -> 99 commas
|
||||||
|
assert calls[1]["params"]["steamids"].count(",") == 49 # 50 ids
|
||||||
|
|
@ -283,3 +283,172 @@ def test_other_user_cannot_modify_workshop_overlay(overlay_for):
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_owner_can_refresh_and_enqueues_build(overlay_for):
|
||||||
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/items",
|
||||||
|
data={"input": "1001", "input_mode": "items"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
with session_scope() as session:
|
||||||
|
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
|
||||||
|
job.state = "succeeded"
|
||||||
|
|
||||||
|
fresh_meta = steam_workshop.WorkshopMetadata(
|
||||||
|
steam_id="1001", title="Item 1001 (updated)", filename="1001.vpk",
|
||||||
|
file_url="https://example.com/1001.vpk", file_size=99, time_updated=1800000000,
|
||||||
|
preview_url="https://example.com/preview-1001.jpg", consumer_app_id=550, result=1,
|
||||||
|
)
|
||||||
|
with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[fresh_meta]) as fetch:
|
||||||
|
response = user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["Location"].startswith("/jobs/")
|
||||||
|
fetch.assert_called_once_with(["1001"], mode="refresh")
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
wi = session.query(WorkshopItem).filter_by(steam_id="1001").one()
|
||||||
|
assert wi.title == "Item 1001 (updated)"
|
||||||
|
assert wi.time_updated == 1800000000
|
||||||
|
new_jobs = session.query(Job).filter_by(
|
||||||
|
operation="build_overlay", overlay_id=overlay_id, state="queued"
|
||||||
|
).all()
|
||||||
|
assert len(new_jobs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_returns_400_when_overlay_empty(overlay_for):
|
||||||
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
|
||||||
|
with patch.object(steam_workshop, "fetch_metadata_batch") as fetch:
|
||||||
|
response = user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_forbidden_for_non_owner(overlay_for, env_user):
|
||||||
|
app, login, _user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
with session_scope() as session:
|
||||||
|
bob = User(username="bob", password_digest=hash_password("x"), admin=False)
|
||||||
|
session.add(bob)
|
||||||
|
session.flush()
|
||||||
|
bob_id = bob.id
|
||||||
|
bob_client = login(bob_id)
|
||||||
|
response = bob_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_admin_can_refresh_anyone(overlay_for):
|
||||||
|
app, login, user_id, admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/items",
|
||||||
|
data={"input": "1001", "input_mode": "items"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_client = login(admin_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_502_on_steam_error(overlay_for):
|
||||||
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/items",
|
||||||
|
data={"input": "1001", "input_mode": "items"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
with session_scope() as session:
|
||||||
|
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
|
||||||
|
job.state = "succeeded"
|
||||||
|
baseline_job_count = session.query(Job).filter_by(
|
||||||
|
operation="build_overlay", overlay_id=overlay_id, state="queued"
|
||||||
|
).count()
|
||||||
|
|
||||||
|
import requests as _requests
|
||||||
|
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=_requests.ConnectionError("boom")):
|
||||||
|
response = user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert b"steam api error" in response.data
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
n = session.query(Job).filter_by(
|
||||||
|
operation="build_overlay", overlay_id=overlay_id, state="queued"
|
||||||
|
).count()
|
||||||
|
assert n == baseline_job_count
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_non_requests_exception_propagates(overlay_for):
|
||||||
|
"""A non-requests exception (e.g., ValueError from a server-side data
|
||||||
|
issue) must not be disguised as a 502 — let it bubble up as 500."""
|
||||||
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/items",
|
||||||
|
data={"input": "1001", "input_mode": "items"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
with session_scope() as session:
|
||||||
|
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
|
||||||
|
job.state = "succeeded"
|
||||||
|
|
||||||
|
# Flask in TESTING mode re-raises uncaught exceptions instead of returning
|
||||||
|
# 500, so we assert the ValueError propagates rather than checking a status.
|
||||||
|
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=ValueError("bad steam_id in db")):
|
||||||
|
with pytest.raises(ValueError, match="bad steam_id in db"):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_refresh_missing_item_records_last_error(overlay_for):
|
||||||
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
|
user_client = login(user_id)
|
||||||
|
with _patch_steam([_meta("1001")]):
|
||||||
|
user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/items",
|
||||||
|
data={"input": "1001", "input_mode": "items"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
with session_scope() as session:
|
||||||
|
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
|
||||||
|
job.state = "succeeded"
|
||||||
|
|
||||||
|
with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[]):
|
||||||
|
response = user_client.post(
|
||||||
|
f"/overlays/{overlay_id}/refresh",
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
with session_scope() as session:
|
||||||
|
wi = session.query(WorkshopItem).filter_by(steam_id="1001").one()
|
||||||
|
assert "no entry" in wi.last_error
|
||||||
|
new_jobs = session.query(Job).filter_by(
|
||||||
|
operation="build_overlay", overlay_id=overlay_id, state="queued"
|
||||||
|
).all()
|
||||||
|
assert len(new_jobs) == 1, "refresh should enqueue build even when Steam returns no entries"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue