"""Overlay builder registry. Each `Overlay.type` maps to a builder. `build_overlay(overlay_id)` jobs (and the synchronous `initialize_server` hook) dispatch through `BUILDERS`. Adding a new overlay type means writing a new builder and registering it here — no changes to the worker, the mount layer, or the blueprint editor. """ from __future__ import annotations import os from pathlib import Path from typing import Callable, Protocol from sqlalchemy import select from l4d2host.paths import get_left4me_root from l4d2web.db import session_scope from l4d2web.models import Overlay, OverlayWorkshopItem, WorkshopItem from l4d2web.services.workshop_paths import cache_path, workshop_cache_root CancelCheck = Callable[[], bool] LogSink = Callable[[str], None] class OverlayBuilder(Protocol): def build( self, overlay: Overlay, *, on_stdout: LogSink, on_stderr: LogSink, should_cancel: CancelCheck, ) -> None: ... def _overlay_root(overlay: Overlay) -> Path: return get_left4me_root() / "overlays" / overlay.path class ExternalBuilder: """No-op builder for admin-managed overlays. Ensures the overlay directory exists; everything inside it is the admin's responsibility (SFTP, etc.).""" def build( self, overlay: Overlay, *, on_stdout: LogSink, on_stderr: LogSink, should_cancel: CancelCheck, ) -> None: root = _overlay_root(overlay) root.mkdir(parents=True, exist_ok=True) on_stdout(f"external overlay {overlay.name!r} ready at {root}") class WorkshopBuilder: """Diff-apply symlinks under `left4dead2/addons/` against the overlay's current `WorkshopItem` associations. Cached items get an absolute symlink into `workshop_cache/{steam_id}.vpk`. Items missing from cache are skipped with a warning rather than turned into broken symlinks.""" def build( self, overlay: Overlay, *, on_stdout: LogSink, on_stderr: LogSink, should_cancel: CancelCheck, ) -> None: addons_dir = _overlay_root(overlay) / "left4dead2" / "addons" addons_dir.mkdir(parents=True, exist_ok=True) with session_scope() as db: items = db.scalars( select(WorkshopItem) .join( OverlayWorkshopItem, OverlayWorkshopItem.workshop_item_id == WorkshopItem.id, ) .where(OverlayWorkshopItem.overlay_id == overlay.id) ).all() # Detach items so we can use them outside the session. items_data = [ (it.steam_id, it.last_downloaded_at) for it in items ] cache_root = workshop_cache_root() # desired: symlink-name -> absolute target path (only for cached items) desired: dict[str, Path] = {} skipped: list[str] = [] for steam_id, last_downloaded_at in items_data: target = cache_path(steam_id) if last_downloaded_at is None or not target.exists(): skipped.append(steam_id) continue desired[f"{steam_id}.vpk"] = target.resolve() if should_cancel(): on_stderr("workshop build cancelled before applying symlinks") return # existing: symlink-name -> link target (only for symlinks pointing at our cache) existing: dict[str, Path] = {} for entry in os.scandir(addons_dir): if not entry.is_symlink(): continue try: target = Path(os.readlink(entry.path)) except OSError: continue try: resolved = target.resolve(strict=False) except OSError: continue if not _is_under(resolved, cache_root): continue existing[entry.name] = resolved created = 0 removed = 0 unchanged = 0 # Remove obsolete or stale symlinks first. for name, current_target in existing.items(): if should_cancel(): on_stderr("workshop build cancelled mid-removal") return desired_target = desired.get(name) if desired_target is None: os.unlink(addons_dir / name) removed += 1 elif current_target != desired_target: os.unlink(addons_dir / name) # will be recreated below else: unchanged += 1 # Recompute existing post-removal so the create loop knows what's left. post_removal_existing = { name for name in existing if name in desired and existing[name] == desired[name] } # Create new symlinks. for name, target in desired.items(): if should_cancel(): on_stderr("workshop build cancelled mid-creation") return if name in post_removal_existing: continue link_path = addons_dir / name # Defensive: if a non-symlink file collides with our name, leave it. if link_path.exists() and not link_path.is_symlink(): on_stderr( f"refusing to overwrite non-symlink at {link_path}; manual intervention required" ) continue if link_path.is_symlink(): # An obsolete symlink not in `existing` (target outside cache). # We don't manage these — leave alone. on_stderr( f"refusing to overwrite foreign symlink at {link_path}" ) continue os.symlink(str(target), str(link_path)) created += 1 on_stdout( f"workshop overlay {overlay.name!r}: created={created} " f"removed={removed} unchanged={unchanged} " f"skipped(uncached)={len(skipped)}" ) for steam_id in skipped: on_stderr( f"workshop item {steam_id} skipped: not yet downloaded " f"(refresh required before this overlay can mount it)" ) def _is_under(path: Path, root: Path) -> bool: try: path.relative_to(root) except ValueError: return False return True BUILDERS: dict[str, OverlayBuilder] = { "external": ExternalBuilder(), "workshop": WorkshopBuilder(), }