From ac020d1e77bbddd2c75ab95dd4907dfae435b0c9 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 7 May 2026 16:53:04 +0200 Subject: [PATCH] feat(l4d2-web): initialize-time guard for uncached workshop items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- l4d2web/services/l4d2_facade.py | 106 +++++++++++++++++++++++++++- l4d2web/tests/test_l4d2_facade.py | 113 ++++++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/l4d2web/services/l4d2_facade.py b/l4d2web/services/l4d2_facade.py index 2154b19..7fb5d1d 100644 --- a/l4d2web/services/l4d2_facade.py +++ b/l4d2web/services/l4d2_facade.py @@ -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( diff --git a/l4d2web/tests/test_l4d2_facade.py b/l4d2web/tests/test_l4d2_facade.py index 0fa6458..30fed82 100644 --- a/l4d2web/tests/test_l4d2_facade.py +++ b/l4d2web/tests/test_l4d2_facade.py @@ -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