from dataclasses import dataclass import json from pathlib import Path from sqlalchemy import select from l4d2web.db import session_scope from l4d2web.models import ( Blueprint, BlueprintOverlay, Overlay, OverlayWorkshopItem, Server, WorkshopItem, ) from l4d2web.services import host_commands from l4d2web.services.server_identity import server_unit_name from l4d2web.services.spec_yaml import write_temp_spec from l4d2web.services.workshop_paths import cache_path @dataclass(slots=True) class ServerStatus: state: str raw_active_state: str raw_sub_state: str def build_server_spec_payload( server: Server, blueprint: Blueprint, overlay_rows: list[tuple[int, str, bool]], ) -> dict: overlays: list[dict] = [] for overlay_id, path, expose in overlay_rows: if expose: overlays.append({"path": path, "alias": f"overlay_{overlay_id}"}) else: overlays.append({"path": path}) # Source `exec` is last-wins. First list entry = topmost overlay = highest # precedence, so its exec runs LAST. Emit in reverse position order. exec_lines = [ f"exec server_overlay_{overlay_id}" for overlay_id, _, expose in reversed(overlay_rows) if expose ] return { "port": server.port, "overlays": overlays, "arguments": json.loads(blueprint.arguments), "config": exec_lines + json.loads(blueprint.config), } def load_server_blueprint_bundle( server_id: int, ) -> tuple[Server, Blueprint, list[tuple[int, str, bool]]]: with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id)) if server is None: raise ValueError("server not found") blueprint = db.scalar(select(Blueprint).where(Blueprint.id == server.blueprint_id)) if blueprint is None: raise ValueError("blueprint not found") rows = db.execute( select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .where(BlueprintOverlay.blueprint_id == blueprint.id) .order_by(BlueprintOverlay.position) ).all() overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows] return server, blueprint, overlay_rows def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: host_commands.run_command( ["l4d2ctl", "install"], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id) # Builders are NOT run here. Overlays rebuild from their own save/build # flows; doing it on every Start is expensive and redundant. # Workshop overlays may have items not yet downloaded. Fail fast rather # than mount a partial overlay (would silently leave maps missing in-game). _check_workshop_overlay_caches(blueprint_id=blueprint.id) spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_rows)) try: host_commands.run_command( ["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) finally: 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: # Always initialize before starting so blueprint edits and overlay rebuilds # take effect on the next start without a manual two-step. initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) server, _, _ = load_server_blueprint_bundle(server_id) host_commands.run_command( ["l4d2ctl", "start", server_unit_name(server.id)], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) def stop_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( ["l4d2ctl", "stop", server_unit_name(server.id)], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) def delete_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( ["l4d2ctl", "delete", server_unit_name(server.id)], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) def reset_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( ["l4d2ctl", "reset", server_unit_name(server.id)], on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel, ) def server_status(unit_name: str) -> ServerStatus: result = host_commands.run_command(["l4d2ctl", "status", unit_name, "--json"]) payload = json.loads(result.stdout or "{}") return ServerStatus( state=str(payload.get("state", "unknown")), raw_active_state=str(payload.get("raw_active_state", "unknown")), raw_sub_state=str(payload.get("raw_sub_state", "unknown")), ) def stream_server_logs(unit_name: str, *, lines: int = 200, follow: bool = True): command = ["l4d2ctl", "logs", unit_name, "--lines", str(lines)] command.append("--follow" if follow else "--no-follow") return host_commands.stream_command(command)