left4me/docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md
mwiegand 985df970f8
feat(l4d2-web): per-overlay server.cfg aliases — expose checkbox + auto-exec
Each linked overlay gets a checkbox on the blueprint detail page that opts
its server.cfg in as exec server_overlay_<id>. The web app builds the
spec with {path, alias} per overlay and prepends exec server_overlay_<id>
lines to the blueprint config in lowest-overlay-first order. The host
stages those copies in the overlayfs upper layer before mounting (avoids
copy-up writes against a sandbox-uid file). A live preview block above the
Config textarea shows what gets auto-executed.

Schema:
- alembic 0007: BlueprintOverlay.expose_server_cfg BOOLEAN

Spec contract:
- l4d2host OverlayRef(path, alias?). load_spec accepts both bare-string
  and {path, alias} entries.

Side effects folded in (same file in l4d2_facade):
- start_server auto-initializes; the manual Initialize step is no longer
  needed before Start.
- initialize_server no longer runs blueprint builders — builds happen on
  overlay save, not on every server Start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:31 +02:00

9.3 KiB

Per-overlay server.cfg aliases — opt-in via blueprint checkbox

Context

L4D2 overlays stack via kernel overlayfs. When two overlays both ship left4dead2/cfg/server.cfg, the topmost wins; lower-layer copies become unreachable. On top of that, the blueprint's own server.cfg is copied into merged/.../cfg/server.cfg at instance start (l4d2host/instances.py:112-115), so the merged view's server.cfg is always the blueprint's.

We want a per-blueprint opt-in mechanism: for each linked overlay, the blueprint owner can check a box to expose that overlay's server.cfg as a reloadable alias under a known name. The alias is identified by overlay id (server_overlay_<id>.cfg), so it's stable across overlay renames and namespaced.

Trade-off accepted: only checked overlays are addressable in the in-game console. That's intentional — explicit opt-in beats automatic exposure of every overlay's config.

Earlier rounds of this plan considered (and rejected):

  • Doing it in each script overlay: too easy to forget, doesn't scale.
  • A name-as-slug constraint with auto-aliasing for every overlay: more invasive (regex on names, blueprint-wide collision checks) and exposes everything by default.

Approach

  • New boolean column expose_server_cfg on BlueprintOverlay (per-blueprint, per-overlay state).
  • Blueprint detail page: each linked overlay row gets a checkbox labeled with its alias (exec server_overlay_<id>).
  • Spec yaml carries an optional alias per overlay; web app sets it to overlay_<id> when the box is checked, otherwise omits it.
  • Host copies <lowerdir>/left4dead2/cfg/server.cfgmerged/left4dead2/cfg/server_<alias>.cfg at instance start, only for entries with alias set and an existing source. Pre-sweep removes stale aliases from prior starts.
  • Auto-inject exec lines into the blueprint's final server.cfg: for each opted-in overlay, prepend exec server_overlay_<id> to the config list, in BlueprintOverlay.position ascending order (lowest overlay first, highest last), with the user's custom config lines appended after. Source-style cfg semantics: later lines override earlier ones, so this gives "lowest overlay's settings → higher overlay's settings → blueprint customizations" in the right precedence.

No constraint on Overlay.name. No alias / slug column on Overlay. The previously-added manual cp in competitive_rework.sh gets reverted (the framework will do it when checked).

Changes

1. Schema

l4d2web/models.pyBlueprintOverlay gets:

expose_server_cfg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))

l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py — new Alembic migration:

  • op.add_column("blueprint_overlays", sa.Column("expose_server_cfg", sa.Boolean(), nullable=False, server_default=sa.text("0")))
  • Downgrade drops the column.

2. Spec contract (host ↔ web)

l4d2host/spec.py: replace overlays: list[str] with typed refs.

@dataclass(slots=True)
class OverlayRef:
    path: str
    alias: str | None = None   # if set, copy server.cfg to server_<alias>.cfg in merged

@dataclass(slots=True)
class InstanceSpec:
    port: int
    overlays: list[OverlayRef] = field(default_factory=list)
    arguments: list[str] = field(default_factory=list)
    config: list[str] = field(default_factory=list)

load_spec accepts both shapes per overlay entry: a bare string is treated as OverlayRef(path=string) (back-compat for hand-written specs and existing tests); a dict carries path and optional alias.

l4d2web/services/l4d2_facade.py:

  • load_server_blueprint_bundle: change select to select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg), ordered by BlueprintOverlay.position ascending (already the case). Returns the raw list of (id, path, expose) tuples to the caller.
  • build_server_spec_payload:
    • Emit overlays as dicts: {"path": p} if not exposed, {"path": p, "alias": f"overlay_{i}"} if exposed.
    • Build exec_lines = [f"exec server_overlay_{i}" for i, _, expose in rows if expose] — same ordering as overlays (lowest first).
    • Set config = exec_lines + json.loads(blueprint.config). Net effect: exec lines appear at the top of the written instance_dir/server.cfg, blueprint custom lines follow.

3. Lowerdir construction (host)

l4d2host/instances.py:44:

lowerdirs = [str(overlay_path(o.path, root=root)) for o in spec.overlays]

4. Per-overlay copy in start_instance (host)

l4d2host/instances.py, after the existing main server.cfg copy. New block:

emit_step("copying per-overlay server.cfg aliases...", on_stdout, passthrough)
cfg_dir = runtime_dir / "merged" / "left4dead2" / "cfg"
for stale in cfg_dir.glob("server_*.cfg"):
    stale.unlink()
for o in spec.overlays:
    if not o.alias:
        continue
    src = root / "overlays" / o.path / "left4dead2" / "cfg" / "server.cfg"
    if not src.exists():
        continue
    shutil.copy2(src, cfg_dir / f"server_{o.alias}.cfg")
  • Sweep first: prevents orphans when a checkbox is unticked or an overlay is removed from the blueprint.
  • Skip overlays with no alias (not opted in) and overlays whose lower dir has no server.cfg (workshop overlays etc.).
  • Writes go to the upper layer of the overlayfs mount; lower dirs untouched.

5. Blueprint detail UI

l4d2web/templates/blueprint_detail.html — extend each linked-overlay <li> with a checkbox + label showing the alias inline.

l4d2web/routes/page_routes.py blueprint_page: also pass an overlay_expose_state: dict[int, bool] keyed by overlay_id so the template can read the current expose_server_cfg value.

l4d2web/routes/blueprint_routes.py (replace_blueprint_overlays and its callers): also read expose_server_cfg_ids from the form (request.form.getlist("expose_server_cfg_ids")), convert to set[int], and set BlueprintOverlay.expose_server_cfg = (overlay_id in expose_set) per row.

6. Revert the manual cp

examples/script-overlays/competitive_rework.sh: remove the cp "$DEST/cfg/server.cfg" "$DEST/cfg/server_competitive.cfg" block added in the previous round. The framework handles this on demand now.

Critical files

  • l4d2web/models.pyBlueprintOverlay.expose_server_cfg
  • l4d2web/alembic/versions/0007_*.py — new Alembic migration
  • l4d2web/routes/blueprint_routes.py — read checkbox set on save, persist expose flag
  • l4d2web/routes/page_routes.py — pass overlay state map to template
  • l4d2web/templates/blueprint_detail.html — checkbox + alias display
  • l4d2web/services/l4d2_facade.py — emit alias per overlay in spec payload + prepend exec lines
  • l4d2host/spec.pyOverlayRef dataclass + spec deserialization
  • l4d2host/instances.py — lowerdir construction + per-overlay copy step + sweep
  • examples/script-overlays/competitive_rework.sh — remove manual cp

Out of scope

  • No constraint on Overlay.name.
  • No cfg_alias / slug column on Overlay.
  • No per-blueprint custom alias text (id-based naming is fixed: overlay_<id>).
  • No automatic detection of which overlays ship a server.cfg to gate the checkbox in UI — checkbox is always available; the host silently skips at start time if the source doesn't exist.

Verification

  1. Unit tests:

    • l4d2host/tests/: start_instance test where two overlays exist on disk — one with server.cfg, one without; spec marks both with aliases; assert only the one with a source produces server_<alias>.cfg in merged. Pre-existing server_old.cfg in merged is swept.
    • l4d2host/tests/: spec yaml round-trip test for OverlayRef with and without alias; back-compat test for bare-string entries.
    • l4d2web/tests/: blueprint payload build asserts overlays without expose_server_cfg produce no alias; with, produce overlay_<id>.
    • l4d2web/tests/: blueprint payload config field equals ["exec server_overlay_<id_low>", "exec server_overlay_<id_high>", *blueprint_custom_lines]exec lines in BlueprintOverlay.position ascending order, custom lines last, no exec lines for unchecked overlays.
    • l4d2web/tests/: form submit with expose_server_cfg_ids=[6, 8] updates the matching BlueprintOverlay rows; unchecked rows reset to false.
    • Run: pytest l4d2host/tests -q, pytest l4d2web/tests -q.
  2. End-to-end on the test server (ckn@10.0.4.128):

    • Deploy via deploy/deploy-test-server.sh.
    • Blueprint detail: each linked overlay shows a checkbox with its alias label.
    • Tick the box for competitive_rework; save; reload; checkbox stays checked.
    • Start a server using that blueprint: ls /var/lib/left4me/runtime/<name>/merged/left4dead2/cfg/server_*.cfg → shows server_overlay_<id>.cfg for the checked overlay only.
    • Inspect the written server.cfg: head -n 5 /var/lib/left4me/instances/<name>/server.cfg → top lines are exec server_overlay_<id> for each checked overlay in lowest-first order, followed by the blueprint's custom lines.
    • In-game console: server boot should auto-load the per-overlay configs.
    • Untick the box, restart the server → server_overlay_<id>.cfg no longer present in merged, and the corresponding exec line is no longer in the written server.cfg.
  3. Negative: tick an overlay that doesn't ship a server.cfg (e.g. a workshop overlay) → start succeeds, no alias file produced (host skipped silently).