The workshop + managed-global overlay surface fully covers the admin-SFTP flow that 'external' was a placeholder for. Drop the type from the model defaults, builder registry, routes, template, and tests, and add migration 0004 that deletes any leftover external rows along with their blueprint and job references. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
9.7 KiB
Python
269 lines
9.7 KiB
Python
"""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(),
|
|
}
|