diff --git a/docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md b/docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md new file mode 100644 index 0000000..1486f08 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md @@ -0,0 +1,140 @@ +# 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). diff --git a/l4d2host/instances.py b/l4d2host/instances.py index 60f9847..63a3ac4 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -41,7 +41,7 @@ def initialize_instance( (runtime_dir / "merged").mkdir(parents=True, exist_ok=True) instance_dir.mkdir(parents=True, exist_ok=True) - lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays] + lowerdirs = [str(overlay_path(o.path, root=root)) for o in spec.overlays] lowerdirs.append(str(root / "installation")) emit_step("writing instance.env...", on_stdout, passthrough) @@ -57,6 +57,9 @@ def initialize_instance( emit_step("writing server.cfg...", on_stdout, passthrough) server_cfg = "\n".join(spec.config) if spec.config else "" (instance_dir / "server.cfg").write_text(server_cfg) + + emit_step("persisting spec...", on_stdout, passthrough) + shutil.copy2(spec_path, instance_dir / "spec.yaml") emit_step("initialization complete.", on_stdout, passthrough) @@ -97,6 +100,27 @@ def start_instance( stderr=f"runtime overlay already mounted at {merged}; refusing to double-mount", ) + # Stage cfg files in the upper layer BEFORE mounting. Writing through + # merged after the mount triggers overlayfs copy-up, which preserves the + # lower file's ownership — and a script-sandbox-built `server.cfg` is + # owned by `l4d2-sandbox`, not the worker. Pre-mount writes go straight to + # upper with the worker's uid; the kernel just shows them at the top of + # the merged stack once mounted. + emit_step("staging server.cfg + per-overlay aliases in upper layer...", on_stdout, passthrough) + upper_cfg_dir = runtime_dir / "upper" / "left4dead2" / "cfg" + upper_cfg_dir.mkdir(parents=True, exist_ok=True) + for stale in upper_cfg_dir.glob("server*.cfg"): + stale.unlink() + shutil.copy2(instance_dir / "server.cfg", upper_cfg_dir / "server.cfg") + spec = load_spec(instance_dir / "spec.yaml") + 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, upper_cfg_dir / f"server_{o.alias}.cfg") + emit_step("mounting runtime overlay...", on_stdout, passthrough) _mounter.mount( lowerdirs=env["L4D2_LOWERDIRS"], @@ -109,11 +133,6 @@ def start_instance( should_cancel=should_cancel, ) - emit_step("copying server.cfg to runtime...", on_stdout, passthrough) - target_cfg = runtime_dir / "merged" / "left4dead2" / "cfg" / "server.cfg" - target_cfg.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(instance_dir / "server.cfg", target_cfg) - emit_step("starting systemd service...", on_stdout, passthrough) start_service( name, diff --git a/l4d2host/spec.py b/l4d2host/spec.py index 5f16ccc..8b11eed 100644 --- a/l4d2host/spec.py +++ b/l4d2host/spec.py @@ -4,19 +4,38 @@ from pathlib import Path import yaml +@dataclass(slots=True) +class OverlayRef: + path: str + alias: str | None = None + + @dataclass(slots=True) class InstanceSpec: port: int - overlays: list[str] = field(default_factory=list) + overlays: list[OverlayRef] = field(default_factory=list) arguments: list[str] = field(default_factory=list) config: list[str] = field(default_factory=list) +def _parse_overlay(item) -> OverlayRef: + if isinstance(item, str): + return OverlayRef(path=item) + if isinstance(item, dict): + path = item.get("path") + if not isinstance(path, str) or not path: + raise ValueError(f"overlay entry missing 'path': {item!r}") + raw_alias = item.get("alias") + alias = str(raw_alias) if raw_alias not in (None, "") else None + return OverlayRef(path=path, alias=alias) + raise ValueError(f"unsupported overlay entry type: {type(item).__name__}") + + def load_spec(path: Path) -> InstanceSpec: raw = yaml.safe_load(path.read_text()) or {} return InstanceSpec( port=int(raw["port"]), - overlays=[str(item) for item in raw.get("overlays", [])], + overlays=[_parse_overlay(item) for item in raw.get("overlays", [])], arguments=[str(item) for item in raw.get("arguments", [])], config=[str(item) for item in raw.get("config", [])], ) diff --git a/l4d2host/tests/test_initialize.py b/l4d2host/tests/test_initialize.py index 53d23a3..7b9ce38 100644 --- a/l4d2host/tests/test_initialize.py +++ b/l4d2host/tests/test_initialize.py @@ -46,5 +46,22 @@ def test_initialize_instance_emits_steps(tmp_path: Path) -> None: "Step: creating instance directories...", "Step: writing instance.env...", "Step: writing server.cfg...", + "Step: persisting spec...", "Step: initialization complete.", ] + + +def test_initialize_persists_spec_to_instance_dir(tmp_path: Path) -> None: + spec = tmp_path / "spec.yaml" + spec.write_text( + "port: 27015\n" + "overlays:\n" + " - {path: '5', alias: overlay_5}\n" + " - path: '6'\n" + ) + + initialize_instance("alpha", spec, root=tmp_path) + + persisted = tmp_path / "instances" / "alpha" / "spec.yaml" + assert persisted.exists() + assert "overlay_5" in persisted.read_text() diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index bf814b0..ea61fac 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -27,6 +27,7 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: "L4D2_PORT=27015\nL4D2_ARGS=-tickrate 100\nL4D2_LOWERDIRS=/x:/y\n" ) (instance_dir / "server.cfg").write_text("sv_consistency 1") + (instance_dir / "spec.yaml").write_text("port: 27015\noverlays: [x, y]\n") monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) @@ -43,6 +44,49 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"] +def test_start_copies_per_overlay_aliases_and_sweeps_stale( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_run_command(cmd, **kwargs): + del cmd, kwargs + + instance_dir = tmp_path / "instances" / "alpha" + runtime_dir = tmp_path / "runtime" / "alpha" + upper_cfg_dir = runtime_dir / "upper" / "left4dead2" / "cfg" + upper_cfg_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "merged").mkdir(parents=True, exist_ok=True) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "instance.env").write_text( + "L4D2_PORT=27015\nL4D2_ARGS=\nL4D2_LOWERDIRS=/x\n" + ) + (instance_dir / "server.cfg").write_text("exec server_overlay_5\n") + (instance_dir / "spec.yaml").write_text( + "port: 27015\n" + "overlays:\n" + " - {path: '5', alias: overlay_5}\n" + " - {path: '6', alias: overlay_6}\n" + " - path: '7'\n" + ) + src_5 = tmp_path / "overlays" / "5" / "left4dead2" / "cfg" + src_5.mkdir(parents=True, exist_ok=True) + (src_5 / "server.cfg").write_text("sv_consistency 1\n") + src_7 = tmp_path / "overlays" / "7" / "left4dead2" / "cfg" + src_7.mkdir(parents=True, exist_ok=True) + (src_7 / "server.cfg").write_text("ignored: alias not set\n") + (upper_cfg_dir / "server_orphan.cfg").write_text("from previous start\n") + + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + + start_instance("alpha", root=tmp_path) + + assert (upper_cfg_dir / "server.cfg").read_text() == "exec server_overlay_5\n" + assert (upper_cfg_dir / "server_overlay_5.cfg").read_text() == "sv_consistency 1\n" + assert not (upper_cfg_dir / "server_overlay_6.cfg").exists(), "no source server.cfg → no alias" + assert not (upper_cfg_dir / "server_orphan.cfg").exists(), "stale alias must be swept" + assert not (upper_cfg_dir / "server_overlay_7.cfg").exists(), "no alias in spec → no copy" + + def test_start_refuses_to_double_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls: list[list[str]] = [] diff --git a/l4d2host/tests/test_spec.py b/l4d2host/tests/test_spec.py index 44dd13f..5d6765e 100644 --- a/l4d2host/tests/test_spec.py +++ b/l4d2host/tests/test_spec.py @@ -2,15 +2,30 @@ from pathlib import Path import pytest -from l4d2host.spec import load_spec +from l4d2host.spec import OverlayRef, load_spec -def test_minimal_spec_parses(tmp_path: Path) -> None: +def test_minimal_spec_parses_string_shorthand(tmp_path: Path) -> None: path = tmp_path / "server.yaml" path.write_text("port: 27015\noverlays: [standard]\n") spec = load_spec(path) assert spec.port == 27015 - assert spec.overlays == ["standard"] + assert spec.overlays == [OverlayRef(path="standard")] + + +def test_overlay_dict_with_alias(tmp_path: Path) -> None: + path = tmp_path / "server.yaml" + path.write_text( + "port: 27015\n" + "overlays:\n" + " - {path: '6', alias: overlay_6}\n" + " - path: '7'\n" + ) + spec = load_spec(path) + assert spec.overlays == [ + OverlayRef(path="6", alias="overlay_6"), + OverlayRef(path="7", alias=None), + ] def test_defaults_are_empty_lists(tmp_path: Path) -> None: @@ -34,3 +49,10 @@ def test_unknown_keys_ignored(tmp_path: Path) -> None: path.write_text("port: 27015\nfoo: bar\n") spec = load_spec(path) assert spec.port == 27015 + + +def test_overlay_dict_missing_path_rejected(tmp_path: Path) -> None: + path = tmp_path / "server.yaml" + path.write_text("port: 27015\noverlays:\n - {alias: overlay_6}\n") + with pytest.raises(ValueError): + load_spec(path) diff --git a/l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py b/l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py new file mode 100644 index 0000000..973669e --- /dev/null +++ b/l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py @@ -0,0 +1,33 @@ +"""blueprint_overlays.expose_server_cfg + +Revision ID: 0007_blueprint_overlay_expose_server_cfg +Revises: 0006_server_name_per_user +Create Date: 2026-05-08 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0007_blueprint_overlay_expose_server_cfg" +down_revision: Union[str, Sequence[str], None] = "0006_server_name_per_user" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "blueprint_overlays", + sa.Column( + "expose_server_cfg", + sa.Boolean(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + +def downgrade() -> None: + with op.batch_alter_table("blueprint_overlays") as batch_op: + batch_op.drop_column("expose_server_cfg") diff --git a/l4d2web/models.py b/l4d2web/models.py index b3a5478..398543e 100644 --- a/l4d2web/models.py +++ b/l4d2web/models.py @@ -117,6 +117,9 @@ class BlueprintOverlay(Base): blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False) overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False) position: Mapped[int] = mapped_column(Integer, nullable=False) + expose_server_cfg: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=text("0") + ) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) diff --git a/l4d2web/routes/blueprint_routes.py b/l4d2web/routes/blueprint_routes.py index d18cff9..b68b63a 100644 --- a/l4d2web/routes/blueprint_routes.py +++ b/l4d2web/routes/blueprint_routes.py @@ -31,10 +31,35 @@ def ordered_overlay_ids_from_form() -> list[int]: return [overlay_id for _, _, overlay_id in sorted(ordered)] -def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None: +def replace_blueprint_overlays( + db, + blueprint_id: int, + overlay_ids: list[int], + expose_ids: set[int] | None = None, +) -> None: + expose_ids = expose_ids or set() db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id)) for position, overlay_id in enumerate(overlay_ids): - db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position)) + db.add( + BlueprintOverlay( + blueprint_id=blueprint_id, + overlay_id=overlay_id, + position=position, + expose_server_cfg=overlay_id in expose_ids, + ) + ) + + +def expose_overlay_ids_from_form() -> set[int]: + out: set[int] = set() + for value in request.form.getlist("expose_server_cfg_ids"): + if not value: + continue + try: + out.add(int(value)) + except ValueError: + continue + return out def overlay_ids_authorized(db, overlay_ids: list[int], user_id: int) -> bool: @@ -62,12 +87,14 @@ def create_blueprint() -> Response: arguments = [str(item) for item in payload.get("arguments", [])] config = [str(item) for item in payload.get("config", [])] overlay_ids = [int(item) for item in payload.get("overlay_ids", [])] + expose_ids = {int(item) for item in payload.get("expose_server_cfg_ids", [])} json_response = True else: name = request.form.get("name", "").strip() arguments = split_textarea_lines(request.form.get("arguments", "")) config = split_textarea_lines(request.form.get("config", "")) overlay_ids = ordered_overlay_ids_from_form() + expose_ids = expose_overlay_ids_from_form() json_response = False if not name: @@ -79,7 +106,7 @@ def create_blueprint() -> Response: blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config)) db.add(blueprint) db.flush() - replace_blueprint_overlays(db, blueprint.id, overlay_ids) + replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids) blueprint_id = blueprint.id if json_response: @@ -103,13 +130,14 @@ def update_blueprint_form(blueprint_id: int) -> Response: if blueprint is None: return Response(status=404) overlay_ids = ordered_overlay_ids_from_form() + expose_ids = expose_overlay_ids_from_form() if not overlay_ids_authorized(db, overlay_ids, user.id): return Response("overlay not authorized", status=403) blueprint.name = name blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", ""))) blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", ""))) - replace_blueprint_overlays(db, blueprint.id, overlay_ids) + replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids) return redirect(f"/blueprints/{blueprint_id}") diff --git a/l4d2web/services/l4d2_facade.py b/l4d2web/services/l4d2_facade.py index 929b375..eaf8c21 100644 --- a/l4d2web/services/l4d2_facade.py +++ b/l4d2web/services/l4d2_facade.py @@ -26,16 +26,35 @@ class ServerStatus: raw_sub_state: str -def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_refs: list[str]) -> dict: +def build_server_spec_payload( + server: Server, + blueprint: Blueprint, + overlay_rows: list[tuple[int, str, bool]], +) -> dict: + overlays: list[dict] = [] + for overlay_id, path, expose in overlay_rows: + if expose: + overlays.append({"path": path, "alias": f"overlay_{overlay_id}"}) + else: + overlays.append({"path": path}) + # Source `exec` is last-wins. First list entry = topmost overlay = highest + # precedence, so its exec runs LAST. Emit in reverse position order. + exec_lines = [ + f"exec server_overlay_{overlay_id}" + for overlay_id, _, expose in reversed(overlay_rows) + if expose + ] return { "port": server.port, - "overlays": overlay_refs, + "overlays": overlays, "arguments": json.loads(blueprint.arguments), - "config": json.loads(blueprint.config), + "config": exec_lines + json.loads(blueprint.config), } -def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, list[str]]: +def load_server_blueprint_bundle( + server_id: int, +) -> tuple[Server, Blueprint, list[tuple[int, str, bool]]]: with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id)) if server is None: @@ -46,13 +65,13 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis raise ValueError("blueprint not found") rows = db.execute( - select(Overlay.path) + select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .where(BlueprintOverlay.blueprint_id == blueprint.id) .order_by(BlueprintOverlay.position) ).all() - overlay_refs = [row[0] for row in rows] - return server, blueprint, overlay_refs + overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows] + return server, blueprint, overlay_rows def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: @@ -65,23 +84,16 @@ def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: - server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id) + server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id) - # Run each overlay's builder synchronously so symlinks/dirs are present - # before l4d2ctl initialize composes the lowerdirs. - _run_blueprint_builders( - blueprint_id=blueprint.id, - on_stdout=on_stdout, - on_stderr=on_stderr, - should_cancel=should_cancel, - ) + # Builders are NOT run here. Overlays rebuild from their own save/build + # flows; doing it on every Start is expensive and redundant. - # Workshop overlays may have items not yet downloaded. The builders skip - # them, but we don't want to mount a partial overlay silently — fail - # loudly with the missing IDs. + # Workshop overlays may have items not yet downloaded. Fail fast rather + # than mount a partial overlay (would silently leave maps missing in-game). _check_workshop_overlay_caches(blueprint_id=blueprint.id) - spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs)) + spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_rows)) try: host_commands.run_command( ["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)], @@ -175,6 +187,9 @@ def _check_workshop_overlay_caches(*, blueprint_id: int) -> None: def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: + # Always initialize before starting so blueprint edits and overlay rebuilds + # take effect on the next start without a manual two-step. + initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) server, _, _ = load_server_blueprint_bundle(server_id) host_commands.run_command( ["l4d2ctl", "start", server_unit_name(server.id)], diff --git a/l4d2web/static/js/blueprint-overlay-picker.js b/l4d2web/static/js/blueprint-overlay-picker.js index ede8855..1bdd060 100644 --- a/l4d2web/static/js/blueprint-overlay-picker.js +++ b/l4d2web/static/js/blueprint-overlay-picker.js @@ -41,6 +41,18 @@ name.className = "overlay-picker-name"; name.textContent = overlayName; + const exposeLabel = document.createElement("label"); + exposeLabel.className = "overlay-picker-expose"; + exposeLabel.title = "Auto-load this overlay's server.cfg before your blueprint config"; + const exposeInput = document.createElement("input"); + exposeInput.type = "checkbox"; + exposeInput.name = "expose_server_cfg_ids"; + exposeInput.value = overlayId; + const exposeText = document.createTextNode(" exec "); + const exposeCode = document.createElement("code"); + exposeCode.textContent = "server.cfg"; + exposeLabel.append(exposeInput, exposeText, exposeCode); + const remove = document.createElement("button"); remove.type = "button"; remove.className = "overlay-picker-remove"; @@ -48,7 +60,7 @@ remove.setAttribute("aria-label", `Remove ${overlayName}`); remove.textContent = "×"; - li.append(handle, name, remove, buildHiddenInput(overlayId)); + li.append(handle, name, exposeLabel, remove, buildHiddenInput(overlayId)); return li; }; diff --git a/l4d2web/templates/blueprint_detail.html b/l4d2web/templates/blueprint_detail.html index c30612f..88fb05f 100644 --- a/l4d2web/templates/blueprint_detail.html +++ b/l4d2web/templates/blueprint_detail.html @@ -6,40 +6,60 @@

    Blueprint: {{ blueprint.name }}

    -
    - -

    Overlay order matters: the first overlay has highest precedence.

    + + + Overlays
      {% for overlay in selected_overlays %}
    1. {{ overlay.name }} +
    2. {% endfor %}

    No overlays selected. Pick one below to add.

    - +
    - - + {% set exposed = [] %} + {# Source `exec` is last-wins. First overlay in the list = topmost = + highest precedence, so its exec runs LAST. Iterate the picker list in + reverse to render the preview in actual execution order. #} + {% for overlay in selected_overlays | reverse %}{% if overlay_expose_state.get(overlay.id) %}{{ exposed.append(overlay) or '' }}{% endif %}{% endfor %} +
    + +