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 sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.db import session_scope
|
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 import host_commands
|
||||||
from l4d2web.services.spec_yaml import write_temp_spec
|
from l4d2web.services.spec_yaml import write_temp_spec
|
||||||
|
from l4d2web.services.workshop_paths import cache_path
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@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:
|
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)
|
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))
|
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
|
||||||
try:
|
try:
|
||||||
host_commands.run_command(
|
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)
|
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:
|
def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
||||||
server, _, _ = load_server_blueprint_bundle(server_id)
|
server, _, _ = load_server_blueprint_bundle(server_id)
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -5,7 +6,15 @@ import pytest
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.auth import hash_password
|
||||||
from l4d2web.db import init_db, session_scope
|
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
|
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):
|
def server_with_blueprint(tmp_path, monkeypatch):
|
||||||
db_url = f"sqlite:///{tmp_path/'facade.db'}"
|
db_url = f"sqlite:///{tmp_path/'facade.db'}"
|
||||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
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"})
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
@ -22,7 +32,7 @@ def server_with_blueprint(tmp_path, monkeypatch):
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.flush()
|
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.add(overlay)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
@ -41,8 +51,9 @@ def server_with_blueprint(tmp_path, monkeypatch):
|
||||||
session.add(server)
|
session.add(server)
|
||||||
session.flush()
|
session.flush()
|
||||||
server_id = server.id
|
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(
|
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
|
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] == ["l4d2ctl", "initialize", "alpha"]
|
||||||
assert calls[0][3] == "-f"
|
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
|
from l4d2web.services.l4d2_facade import delete_server, install_runtime, start_server, stop_server
|
||||||
|
|
||||||
|
server_id, _ = server_with_blueprint
|
||||||
install_runtime()
|
install_runtime()
|
||||||
start_server(server_with_blueprint)
|
start_server(server_id)
|
||||||
stop_server(server_with_blueprint)
|
stop_server(server_id)
|
||||||
delete_server(server_with_blueprint)
|
delete_server(server_id)
|
||||||
|
|
||||||
assert calls == [
|
assert calls == [
|
||||||
["l4d2ctl", "install"],
|
["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 calls == [["l4d2ctl", "logs", "alpha", "--lines", "10", "--no-follow"]]
|
||||||
assert lines == ["one", "two"]
|
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