feat(l4d2-web): initialize-time guard for uncached workshop items

Before invoking l4d2ctl initialize, run each blueprint overlay's builder
synchronously and then verify that every workshop item attached to the
blueprint has a cache file on disk. If any are missing, raise a clear
error naming the overlay and the missing steam_ids — server start can't
silently mount a partial overlay where some maps are mysteriously absent
in-game.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-07 16:53:04 +02:00
parent df1ccb4cca
commit ac020d1e77
No known key found for this signature in database
2 changed files with 211 additions and 8 deletions

View file

@ -5,9 +5,17 @@ from pathlib import Path
from sqlalchemy import select
from l4d2web.db import session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server
from l4d2web.models import (
Blueprint,
BlueprintOverlay,
Overlay,
OverlayWorkshopItem,
Server,
WorkshopItem,
)
from l4d2web.services import host_commands
from l4d2web.services.spec_yaml import write_temp_spec
from l4d2web.services.workshop_paths import cache_path
@dataclass(slots=True)
@ -57,6 +65,21 @@ def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id)
# Run each overlay's builder synchronously so symlinks/dirs are present
# before l4d2ctl initialize composes the lowerdirs.
_run_blueprint_builders(
blueprint_id=blueprint.id,
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
# Workshop overlays may have items not yet downloaded. The builders skip
# them, but we don't want to mount a partial overlay silently — fail
# loudly with the missing IDs.
_check_workshop_overlay_caches(blueprint_id=blueprint.id)
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
try:
host_commands.run_command(
@ -69,6 +92,87 @@ def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_can
spec_path.unlink(missing_ok=True)
def _run_blueprint_builders(
*,
blueprint_id: int,
on_stdout=None,
on_stderr=None,
should_cancel=None,
) -> None:
"""Synchronously invoke each overlay's builder for the given blueprint."""
from l4d2web.services.overlay_builders import BUILDERS
with session_scope() as db:
overlays = db.scalars(
select(Overlay)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint_id)
.order_by(BlueprintOverlay.position)
).all()
for overlay in overlays:
db.expunge(overlay)
log_stdout = on_stdout if on_stdout is not None else (lambda _line: None)
log_stderr = on_stderr if on_stderr is not None else (lambda _line: None)
cancel = should_cancel if should_cancel is not None else (lambda: False)
for overlay in overlays:
builder = BUILDERS.get(overlay.type)
if builder is None:
raise ValueError(f"no builder registered for overlay type {overlay.type!r}")
builder.build(
overlay,
on_stdout=log_stdout,
on_stderr=log_stderr,
should_cancel=cancel,
)
def _check_workshop_overlay_caches(*, blueprint_id: int) -> None:
"""Raise if any workshop overlay attached to this blueprint has items
that aren't yet in the workshop_cache. Mounting a partial overlay would
leave maps mysteriously missing in-game; surface the issue here instead.
"""
with session_scope() as db:
rows = db.execute(
select(Overlay.id, Overlay.name, WorkshopItem.steam_id)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.join(
OverlayWorkshopItem,
OverlayWorkshopItem.overlay_id == Overlay.id,
)
.join(
WorkshopItem,
WorkshopItem.id == OverlayWorkshopItem.workshop_item_id,
)
.where(
BlueprintOverlay.blueprint_id == blueprint_id,
Overlay.type == "workshop",
)
).all()
missing: dict[tuple[int, str], list[str]] = {}
for overlay_id, overlay_name, steam_id in rows:
if not cache_path(steam_id).exists():
missing.setdefault((overlay_id, overlay_name), []).append(steam_id)
if not missing:
return
parts = []
for (overlay_id, overlay_name), steam_ids in missing.items():
ids = ", ".join(steam_ids)
parts.append(
f"overlay {overlay_name!r} (id={overlay_id}): items {ids} not yet downloaded"
)
detail = "; ".join(parts)
raise RuntimeError(
f"workshop content missing — {detail}. "
f"Open the overlay page and click Build (or wait for the auto-rebuild job), "
f"then retry."
)
def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command(

View file

@ -1,3 +1,4 @@
from datetime import UTC, datetime
from pathlib import Path
import pytest
@ -5,7 +6,15 @@ import pytest
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server, User
from l4d2web.models import (
Blueprint,
BlueprintOverlay,
Overlay,
OverlayWorkshopItem,
Server,
User,
WorkshopItem,
)
from l4d2web.services.host_commands import CommandResult
@ -13,6 +22,7 @@ from l4d2web.services.host_commands import CommandResult
def server_with_blueprint(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'facade.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()
@ -22,7 +32,7 @@ def server_with_blueprint(tmp_path, monkeypatch):
session.add(user)
session.flush()
overlay = Overlay(name="Standard Overlay", path="standard")
overlay = Overlay(name="Standard Overlay", path="standard", type="external", user_id=None)
session.add(overlay)
session.flush()
@ -41,8 +51,9 @@ def server_with_blueprint(tmp_path, monkeypatch):
session.add(server)
session.flush()
server_id = server.id
user_id = user.id
return server_id
return server_id, user_id
def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
@ -70,7 +81,8 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
from l4d2web.services.l4d2_facade import initialize_server
initialize_server(server_with_blueprint)
server_id, _ = server_with_blueprint
initialize_server(server_id)
assert calls[0][:3] == ["l4d2ctl", "initialize", "alpha"]
assert calls[0][3] == "-f"
@ -97,10 +109,11 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
from l4d2web.services.l4d2_facade import delete_server, install_runtime, start_server, stop_server
server_id, _ = server_with_blueprint
install_runtime()
start_server(server_with_blueprint)
stop_server(server_with_blueprint)
delete_server(server_with_blueprint)
start_server(server_id)
stop_server(server_id)
delete_server(server_id)
assert calls == [
["l4d2ctl", "install"],
@ -159,3 +172,89 @@ def test_server_logs_stream_l4d2ctl_logs(monkeypatch: pytest.MonkeyPatch) -> Non
assert calls == [["l4d2ctl", "logs", "alpha", "--lines", "10", "--no-follow"]]
assert lines == ["one", "two"]
def _attach_workshop_overlay_to_blueprint(
server_id: int, user_id: int, *, item_cached: bool, tmp_path: Path
) -> tuple[int, str]:
"""Add a workshop overlay with a single workshop item to the server's
blueprint. Returns (overlay_id, steam_id)."""
with session_scope() as session:
server = session.query(Server).filter_by(id=server_id).one()
overlay = Overlay(name="ws", path="placeholder", type="workshop", user_id=user_id)
session.add(overlay)
session.flush()
# Path matches id, like the production create_overlay flow does.
overlay.path = str(overlay.id)
wi = WorkshopItem(
steam_id="1001",
title="A",
filename="a.vpk",
file_url="https://example.com/a.vpk",
file_size=3,
time_updated=1700000000,
last_downloaded_at=datetime.now(UTC) if item_cached else None,
)
session.add(wi)
session.flush()
session.add(
BlueprintOverlay(blueprint_id=server.blueprint_id, overlay_id=overlay.id, position=1)
)
session.add(OverlayWorkshopItem(overlay_id=overlay.id, workshop_item_id=wi.id))
overlay_id = overlay.id
if item_cached:
cache_root = tmp_path / "workshop_cache"
cache_root.mkdir(exist_ok=True)
(cache_root / "1001.vpk").write_bytes(b"abc")
return overlay_id, "1001"
def test_initialize_runs_overlay_builders_synchronously(
monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
) -> None:
server_id, user_id = server_with_blueprint
overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint(
server_id, user_id, item_cached=True, tmp_path=tmp_path
)
monkeypatch.setattr(
"l4d2web.services.host_commands.run_command",
lambda *args, **kwargs: CommandResult(returncode=0, stdout="", stderr=""),
)
from l4d2web.services.l4d2_facade import initialize_server
initialize_server(server_id)
addons = tmp_path / "overlays" / str(overlay_id) / "left4dead2" / "addons"
assert (addons / "1001.vpk").is_symlink(), "workshop builder must run before l4d2ctl initialize"
def test_initialize_fails_fast_on_uncached_workshop_items(
monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
) -> None:
server_id, user_id = server_with_blueprint
overlay_id, steam_id = _attach_workshop_overlay_to_blueprint(
server_id, user_id, item_cached=False, tmp_path=tmp_path
)
invocations: list[list[str]] = []
def fake_run_command(cmd, **kwargs):
invocations.append(list(cmd))
return CommandResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
from l4d2web.services.l4d2_facade import initialize_server
with pytest.raises(Exception) as excinfo:
initialize_server(server_id)
msg = str(excinfo.value)
assert steam_id in msg
assert str(overlay_id) in msg or "ws" in msg
# l4d2ctl initialize MUST NOT run when uncached items are present.
assert all("initialize" not in cmd for cmd in invocations), invocations