"""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 GlobalOverlayItem, GlobalOverlayItemFile, GlobalOverlaySource, Overlay, OverlayWorkshopItem, WorkshopItem from l4d2web.services.global_map_cache import global_overlay_cache_root 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 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)" ) class GlobalMapOverlayBuilder: """Reconcile symlinks for managed global map overlays.""" 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: source = db.scalar(select(GlobalOverlaySource).where(GlobalOverlaySource.overlay_id == overlay.id)) if source is None: raise ValueError(f"global overlay source for overlay {overlay.id} not found") rows = db.execute( select(GlobalOverlayItemFile.vpk_name, GlobalOverlayItemFile.cache_path) .join(GlobalOverlayItem, GlobalOverlayItem.id == GlobalOverlayItemFile.item_id) .where(GlobalOverlayItem.source_id == source.id) ).all() source_key = source.source_key cache_root = global_overlay_cache_root().resolve() source_vpk_root = (global_overlay_cache_root() / source_key / "vpks").resolve() desired: dict[str, Path] = {} skipped = 0 for vpk_name, cache_path_value in rows: target = (global_overlay_cache_root() / cache_path_value).resolve() if not _is_under(target, source_vpk_root) or not target.exists(): on_stderr(f"global overlay {overlay.name!r}: missing cache file for {vpk_name}") skipped += 1 continue desired[vpk_name] = target existing: dict[str, Path] = {} for entry in os.scandir(addons_dir): if not entry.is_symlink(): continue try: resolved = Path(os.readlink(entry.path)).resolve(strict=False) except OSError: continue if _is_under(resolved, source_vpk_root): existing[entry.name] = resolved elif _is_under(resolved, cache_root): on_stderr(f"global overlay {overlay.name!r}: leaving foreign cache symlink {entry.name}") created = 0 removed = 0 unchanged = 0 for name, current_target in existing.items(): if should_cancel(): on_stderr("global overlay 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: unchanged += 1 else: os.unlink(addons_dir / name) current_names = { name for name, current_target in existing.items() if name in desired and current_target == desired[name] } for name, target in desired.items(): if should_cancel(): on_stderr("global overlay build cancelled mid-creation") return if name in current_names: continue link_path = addons_dir / name if link_path.exists() and not link_path.is_symlink(): on_stderr(f"refusing to overwrite non-symlink at {link_path}") continue if link_path.is_symlink(): on_stderr(f"refusing to overwrite foreign symlink at {link_path}") continue os.symlink(str(target), str(link_path)) created += 1 on_stdout( f"global overlay {overlay.name!r}: created={created} removed={removed} " f"unchanged={unchanged} skipped(missing)={skipped}" ) def _is_under(path: Path, root: Path) -> bool: try: path.relative_to(root) except ValueError: return False return True BUILDERS: dict[str, OverlayBuilder] = { "workshop": WorkshopBuilder(), "l4d2center_maps": GlobalMapOverlayBuilder(), "cedapug_maps": GlobalMapOverlayBuilder(), }