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>
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_cfgonBlueprintOverlay(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
aliasper overlay; web app sets it tooverlay_<id>when the box is checked, otherwise omits it. - Host copies
<lowerdir>/left4dead2/cfg/server.cfg→merged/left4dead2/cfg/server_<alias>.cfgat instance start, only for entries withaliasset and an existing source. Pre-sweep removes stale aliases from prior starts. - Auto-inject
execlines into the blueprint's finalserver.cfg: for each opted-in overlay, prependexec server_overlay_<id>to the config list, inBlueprintOverlay.positionascending 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:
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 toselect(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg), ordered byBlueprintOverlay.positionascending (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:execlines appear at the top of the writteninstance_dir/server.cfg, blueprint custom lines follow.
- Emit overlays as dicts:
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 noserver.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.py—BlueprintOverlay.expose_server_cfgl4d2web/alembic/versions/0007_*.py— new Alembic migrationl4d2web/routes/blueprint_routes.py— read checkbox set on save, persist expose flagl4d2web/routes/page_routes.py— pass overlay state map to templatel4d2web/templates/blueprint_detail.html— checkbox + alias displayl4d2web/services/l4d2_facade.py— emit alias per overlay in spec payload + prepend exec linesl4d2host/spec.py—OverlayRefdataclass + spec deserializationl4d2host/instances.py— lowerdir construction + per-overlay copy step + sweepexamples/script-overlays/competitive_rework.sh— remove manualcp
Out of scope
- No constraint on
Overlay.name. - No
cfg_alias/slugcolumn onOverlay. - No per-blueprint custom alias text (id-based naming is fixed:
overlay_<id>). - No automatic detection of which overlays ship a
server.cfgto gate the checkbox in UI — checkbox is always available; the host silently skips at start time if the source doesn't exist.
Verification
-
Unit tests:
l4d2host/tests/:start_instancetest where two overlays exist on disk — one withserver.cfg, one without; spec marks both with aliases; assert only the one with a source producesserver_<alias>.cfgin merged. Pre-existingserver_old.cfgin merged is swept.l4d2host/tests/: spec yaml round-trip test forOverlayRefwith and withoutalias; back-compat test for bare-string entries.l4d2web/tests/: blueprint payload build asserts overlays withoutexpose_server_cfgproduce noalias; with, produceoverlay_<id>.l4d2web/tests/: blueprint payloadconfigfield equals["exec server_overlay_<id_low>", "exec server_overlay_<id_high>", *blueprint_custom_lines]—execlines inBlueprintOverlay.positionascending order, custom lines last, no exec lines for unchecked overlays.l4d2web/tests/: form submit withexpose_server_cfg_ids=[6, 8]updates the matchingBlueprintOverlayrows; unchecked rows reset to false.- Run:
pytest l4d2host/tests -q,pytest l4d2web/tests -q.
-
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→ showsserver_overlay_<id>.cfgfor the checked overlay only. - Inspect the written
server.cfg:head -n 5 /var/lib/left4me/instances/<name>/server.cfg→ top lines areexec 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>.cfgno longer present in merged, and the correspondingexecline is no longer in the writtenserver.cfg.
- Deploy via
-
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).