# 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_.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_`). - Spec yaml carries an optional `alias` per overlay; web app sets it to `overlay_` when the box is checked, otherwise omits it. - Host copies `/left4dead2/cfg/server.cfg` → `merged/left4dead2/cfg/server_.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_` 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.py` — `BlueprintOverlay` gets: ```python 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. ```python @dataclass(slots=True) class OverlayRef: path: str alias: str | None = None # if set, copy server.cfg to server_.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`: ```python 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: ```python 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 `
  • ` 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.py` — `BlueprintOverlay.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.py` — `OverlayRef` 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_`). - 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_.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_`. - `l4d2web/tests/`: blueprint payload `config` field equals `["exec server_overlay_", "exec server_overlay_", *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//merged/left4dead2/cfg/server_*.cfg` → shows `server_overlay_.cfg` for the checked overlay only. - Inspect the written `server.cfg`: `head -n 5 /var/lib/left4me/instances//server.cfg` → top lines are `exec server_overlay_` 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_.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).