Adds two managed system overlays (l4d2center-maps, cedapug-maps) that fetch curated map archives from upstream sources and reconcile addons symlinks for non-Steam maps. A daily systemd timer enqueues a coalesced refresh_global_overlays worker job; downloads, extraction, and rebuilds run in the existing job worker and surface in the job log UI. Schema: GlobalOverlaySource / GlobalOverlayItem / GlobalOverlayItemFile plus nullable Job.user_id so system jobs render as "system" in the UI. The new builder reconciles symlinks against the per-source vpk cache and leaves foreign symlinks untouched. Initialize-time guard refuses to mount a partial overlay if any expected vpk is missing from cache. Refresh service uses shutil.move to handle EXDEV when /tmp and the cache live on different filesystems. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
10 KiB
Python
287 lines
10 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 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)"
|
|
)
|
|
|
|
|
|
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] = {
|
|
"external": ExternalBuilder(),
|
|
"workshop": WorkshopBuilder(),
|
|
"l4d2center_maps": GlobalMapOverlayBuilder(),
|
|
"cedapug_maps": GlobalMapOverlayBuilder(),
|
|
}
|