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>
140 lines
9.3 KiB
Markdown
140 lines
9.3 KiB
Markdown
# 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.cfg` → `merged/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.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_<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`:
|
|
```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 `<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_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_<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).
|