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:
parent
df1ccb4cca
commit
ac020d1e77
2 changed files with 211 additions and 8 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue