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>
This commit is contained in:
parent
c2cf723911
commit
985df970f8
14 changed files with 610 additions and 49 deletions
140
docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md
Normal file
140
docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md
Normal file
|
|
@ -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_<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).
|
||||||
|
|
@ -41,7 +41,7 @@ def initialize_instance(
|
||||||
(runtime_dir / "merged").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.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"))
|
lowerdirs.append(str(root / "installation"))
|
||||||
|
|
||||||
emit_step("writing instance.env...", on_stdout, passthrough)
|
emit_step("writing instance.env...", on_stdout, passthrough)
|
||||||
|
|
@ -57,6 +57,9 @@ def initialize_instance(
|
||||||
emit_step("writing server.cfg...", on_stdout, passthrough)
|
emit_step("writing server.cfg...", on_stdout, passthrough)
|
||||||
server_cfg = "\n".join(spec.config) if spec.config else ""
|
server_cfg = "\n".join(spec.config) if spec.config else ""
|
||||||
(instance_dir / "server.cfg").write_text(server_cfg)
|
(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)
|
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",
|
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)
|
emit_step("mounting runtime overlay...", on_stdout, passthrough)
|
||||||
_mounter.mount(
|
_mounter.mount(
|
||||||
lowerdirs=env["L4D2_LOWERDIRS"],
|
lowerdirs=env["L4D2_LOWERDIRS"],
|
||||||
|
|
@ -109,11 +133,6 @@ def start_instance(
|
||||||
should_cancel=should_cancel,
|
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)
|
emit_step("starting systemd service...", on_stdout, passthrough)
|
||||||
start_service(
|
start_service(
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,38 @@ from pathlib import Path
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class OverlayRef:
|
||||||
|
path: str
|
||||||
|
alias: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class InstanceSpec:
|
class InstanceSpec:
|
||||||
port: int
|
port: int
|
||||||
overlays: list[str] = field(default_factory=list)
|
overlays: list[OverlayRef] = field(default_factory=list)
|
||||||
arguments: list[str] = field(default_factory=list)
|
arguments: list[str] = field(default_factory=list)
|
||||||
config: 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:
|
def load_spec(path: Path) -> InstanceSpec:
|
||||||
raw = yaml.safe_load(path.read_text()) or {}
|
raw = yaml.safe_load(path.read_text()) or {}
|
||||||
return InstanceSpec(
|
return InstanceSpec(
|
||||||
port=int(raw["port"]),
|
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", [])],
|
arguments=[str(item) for item in raw.get("arguments", [])],
|
||||||
config=[str(item) for item in raw.get("config", [])],
|
config=[str(item) for item in raw.get("config", [])],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,5 +46,22 @@ def test_initialize_instance_emits_steps(tmp_path: Path) -> None:
|
||||||
"Step: creating instance directories...",
|
"Step: creating instance directories...",
|
||||||
"Step: writing instance.env...",
|
"Step: writing instance.env...",
|
||||||
"Step: writing server.cfg...",
|
"Step: writing server.cfg...",
|
||||||
|
"Step: persisting spec...",
|
||||||
"Step: initialization complete.",
|
"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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
"L4D2_PORT=27015\nL4D2_ARGS=-tickrate 100\nL4D2_LOWERDIRS=/x:/y\n"
|
||||||
)
|
)
|
||||||
(instance_dir / "server.cfg").write_text("sv_consistency 1")
|
(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.fs.kernel_overlayfs.run_command", fake_run_command)
|
||||||
monkeypatch.setattr("l4d2host.service_control.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"]
|
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:
|
def test_start_refuses_to_double_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,30 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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 = tmp_path / "server.yaml"
|
||||||
path.write_text("port: 27015\noverlays: [standard]\n")
|
path.write_text("port: 27015\noverlays: [standard]\n")
|
||||||
spec = load_spec(path)
|
spec = load_spec(path)
|
||||||
assert spec.port == 27015
|
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:
|
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")
|
path.write_text("port: 27015\nfoo: bar\n")
|
||||||
spec = load_spec(path)
|
spec = load_spec(path)
|
||||||
assert spec.port == 27015
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -117,6 +117,9 @@ class BlueprintOverlay(Base):
|
||||||
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
|
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
|
||||||
overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False)
|
overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,35 @@ def ordered_overlay_ids_from_form() -> list[int]:
|
||||||
return [overlay_id for _, _, overlay_id in sorted(ordered)]
|
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))
|
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
|
||||||
for position, overlay_id in enumerate(overlay_ids):
|
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:
|
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", [])]
|
arguments = [str(item) for item in payload.get("arguments", [])]
|
||||||
config = [str(item) for item in payload.get("config", [])]
|
config = [str(item) for item in payload.get("config", [])]
|
||||||
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])]
|
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
|
json_response = True
|
||||||
else:
|
else:
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
arguments = split_textarea_lines(request.form.get("arguments", ""))
|
arguments = split_textarea_lines(request.form.get("arguments", ""))
|
||||||
config = split_textarea_lines(request.form.get("config", ""))
|
config = split_textarea_lines(request.form.get("config", ""))
|
||||||
overlay_ids = ordered_overlay_ids_from_form()
|
overlay_ids = ordered_overlay_ids_from_form()
|
||||||
|
expose_ids = expose_overlay_ids_from_form()
|
||||||
json_response = False
|
json_response = False
|
||||||
|
|
||||||
if not name:
|
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))
|
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
|
||||||
db.add(blueprint)
|
db.add(blueprint)
|
||||||
db.flush()
|
db.flush()
|
||||||
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
|
replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids)
|
||||||
blueprint_id = blueprint.id
|
blueprint_id = blueprint.id
|
||||||
|
|
||||||
if json_response:
|
if json_response:
|
||||||
|
|
@ -103,13 +130,14 @@ def update_blueprint_form(blueprint_id: int) -> Response:
|
||||||
if blueprint is None:
|
if blueprint is None:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
overlay_ids = ordered_overlay_ids_from_form()
|
overlay_ids = ordered_overlay_ids_from_form()
|
||||||
|
expose_ids = expose_overlay_ids_from_form()
|
||||||
if not overlay_ids_authorized(db, overlay_ids, user.id):
|
if not overlay_ids_authorized(db, overlay_ids, user.id):
|
||||||
return Response("overlay not authorized", status=403)
|
return Response("overlay not authorized", status=403)
|
||||||
|
|
||||||
blueprint.name = name
|
blueprint.name = name
|
||||||
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
|
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
|
||||||
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", "")))
|
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}")
|
return redirect(f"/blueprints/{blueprint_id}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,35 @@ class ServerStatus:
|
||||||
raw_sub_state: str
|
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 {
|
return {
|
||||||
"port": server.port,
|
"port": server.port,
|
||||||
"overlays": overlay_refs,
|
"overlays": overlays,
|
||||||
"arguments": json.loads(blueprint.arguments),
|
"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:
|
with session_scope() as db:
|
||||||
server = db.scalar(select(Server).where(Server.id == server_id))
|
server = db.scalar(select(Server).where(Server.id == server_id))
|
||||||
if server is None:
|
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")
|
raise ValueError("blueprint not found")
|
||||||
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(Overlay.path)
|
select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)
|
||||||
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
||||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||||
.order_by(BlueprintOverlay.position)
|
.order_by(BlueprintOverlay.position)
|
||||||
).all()
|
).all()
|
||||||
overlay_refs = [row[0] for row in rows]
|
overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows]
|
||||||
return server, blueprint, overlay_refs
|
return server, blueprint, overlay_rows
|
||||||
|
|
||||||
|
|
||||||
def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
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:
|
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
|
# Builders are NOT run here. Overlays rebuild from their own save/build
|
||||||
# before l4d2ctl initialize composes the lowerdirs.
|
# flows; doing it on every Start is expensive and redundant.
|
||||||
_run_blueprint_builders(
|
|
||||||
blueprint_id=blueprint.id,
|
|
||||||
on_stdout=on_stdout,
|
|
||||||
on_stderr=on_stderr,
|
|
||||||
should_cancel=should_cancel,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Workshop overlays may have items not yet downloaded. The builders skip
|
# Workshop overlays may have items not yet downloaded. Fail fast rather
|
||||||
# them, but we don't want to mount a partial overlay silently — fail
|
# than mount a partial overlay (would silently leave maps missing in-game).
|
||||||
# loudly with the missing IDs.
|
|
||||||
_check_workshop_overlay_caches(blueprint_id=blueprint.id)
|
_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:
|
try:
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)],
|
["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:
|
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)
|
server, _, _ = load_server_blueprint_bundle(server_id)
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "start", server_unit_name(server.id)],
|
["l4d2ctl", "start", server_unit_name(server.id)],
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@
|
||||||
name.className = "overlay-picker-name";
|
name.className = "overlay-picker-name";
|
||||||
name.textContent = overlayName;
|
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");
|
const remove = document.createElement("button");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.className = "overlay-picker-remove";
|
remove.className = "overlay-picker-remove";
|
||||||
|
|
@ -48,7 +60,7 @@
|
||||||
remove.setAttribute("aria-label", `Remove ${overlayName}`);
|
remove.setAttribute("aria-label", `Remove ${overlayName}`);
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
|
|
||||||
li.append(handle, name, remove, buildHiddenInput(overlayId));
|
li.append(handle, name, exposeLabel, remove, buildHiddenInput(overlayId));
|
||||||
return li;
|
return li;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,40 +6,60 @@
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h1>Blueprint: {{ blueprint.name }}</h1>
|
<h1>Blueprint: {{ blueprint.name }}</h1>
|
||||||
<button type="button" class="danger" data-modal-open="delete-blueprint-modal">Delete</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
|
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
<label><span class="section-title">Name</span><input name="name" value="{{ blueprint.name }}" required></label>
|
||||||
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
|
<label><span class="section-title">Arguments</span><textarea name="arguments" rows="2" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
|
||||||
|
<span class="section-title">Overlays</span>
|
||||||
<div class="overlay-picker">
|
<div class="overlay-picker">
|
||||||
<ol class="overlay-picker-list" data-overlay-list>
|
<ol class="overlay-picker-list" data-overlay-list>
|
||||||
{% for overlay in selected_overlays %}
|
{% for overlay in selected_overlays %}
|
||||||
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
|
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
|
||||||
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
|
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
|
||||||
<span class="overlay-picker-name">{{ overlay.name }}</span>
|
<span class="overlay-picker-name">{{ overlay.name }}</span>
|
||||||
|
<label class="overlay-picker-expose" title="Auto-load this overlay's server.cfg before your blueprint config">
|
||||||
|
<input type="checkbox" name="expose_server_cfg_ids" value="{{ overlay.id }}"
|
||||||
|
{% if overlay_expose_state.get(overlay.id) %}checked{% endif %}>
|
||||||
|
exec <code>server.cfg</code>
|
||||||
|
</label>
|
||||||
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
|
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
|
||||||
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
|
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
|
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
|
||||||
<label class="overlay-picker-add">
|
<div class="overlay-picker-add">
|
||||||
<span>Add overlay</span>
|
<select data-overlay-add aria-label="Add overlay">
|
||||||
<select data-overlay-add>
|
<option value="">Add overlay…</option>
|
||||||
<option value="">Pick a name…</option>
|
|
||||||
{% for overlay in available_overlays %}
|
{% for overlay in available_overlays %}
|
||||||
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
|
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
|
</div>
|
||||||
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
|
{% 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 %}
|
||||||
|
<label><span class="section-title">Config</span>
|
||||||
|
<div class="config-shell">
|
||||||
|
{% if exposed %}
|
||||||
|
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
||||||
|
{% endfor %}</pre>
|
||||||
|
{% endif %}
|
||||||
|
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<button type="submit">Save blueprint</button>
|
<button type="submit">Save blueprint</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="page-footer-actions">
|
||||||
|
<button type="button" class="danger-outline" data-modal-open="delete-blueprint-modal">Delete blueprint</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
|
<dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>
|
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,121 @@ def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
||||||
assert '<option value="1"' not in body
|
assert '<option value="1"' not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_save_persists_expose_server_cfg_set(user_client) -> None:
|
||||||
|
create = user_client.post(
|
||||||
|
"/blueprints",
|
||||||
|
data={
|
||||||
|
"name": "comp",
|
||||||
|
"arguments": "",
|
||||||
|
"config": "",
|
||||||
|
"overlay_ids": ["1", "2"],
|
||||||
|
"overlay_position_1": "1",
|
||||||
|
"overlay_position_2": "2",
|
||||||
|
"expose_server_cfg_ids": ["2"],
|
||||||
|
},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert create.status_code == 302
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
|
||||||
|
.order_by(BlueprintOverlay.position)
|
||||||
|
).all()
|
||||||
|
assert sorted(rows) == [(1, False), (2, True)]
|
||||||
|
|
||||||
|
update = user_client.post(
|
||||||
|
"/blueprints/1",
|
||||||
|
data={
|
||||||
|
"name": "comp",
|
||||||
|
"arguments": "",
|
||||||
|
"config": "",
|
||||||
|
"overlay_ids": ["1", "2"],
|
||||||
|
"overlay_position_1": "1",
|
||||||
|
"overlay_position_2": "2",
|
||||||
|
"expose_server_cfg_ids": ["1"],
|
||||||
|
},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert update.status_code == 302
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
|
||||||
|
.order_by(BlueprintOverlay.position)
|
||||||
|
).all()
|
||||||
|
assert sorted(rows) == [(1, True), (2, False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_detail_renders_expose_checkbox_compact_label(user_client) -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
user = session.scalar(select(User).where(User.username == "alice"))
|
||||||
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||||||
|
session.add(blueprint)
|
||||||
|
session.flush()
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=True,
|
||||||
|
))
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=2, position=1, expose_server_cfg=False,
|
||||||
|
))
|
||||||
|
blueprint_id = blueprint.id
|
||||||
|
|
||||||
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert 'name="expose_server_cfg_ids" value="1"' in body
|
||||||
|
assert 'name="expose_server_cfg_ids" value="2"' in body
|
||||||
|
assert "exec <code>server.cfg</code>" in body
|
||||||
|
# The technical alias must NOT appear in the row label anymore.
|
||||||
|
assert "server_overlay_1" not in body
|
||||||
|
assert "server_overlay_2" not in body
|
||||||
|
assert "checked" in body # row 1 is exposed
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_detail_shows_config_preview_for_exposed_overlays(user_client) -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
user = session.scalar(select(User).where(User.username == "alice"))
|
||||||
|
comp = Overlay(name="comp", path="/opt/l4d2/overlays/comp", user_id=user.id)
|
||||||
|
plain = Overlay(name="plain", path="/opt/l4d2/overlays/plain", user_id=user.id)
|
||||||
|
session.add_all([comp, plain])
|
||||||
|
session.flush()
|
||||||
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||||||
|
session.add(blueprint)
|
||||||
|
session.flush()
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=comp.id, position=0, expose_server_cfg=True,
|
||||||
|
))
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=plain.id, position=1, expose_server_cfg=False,
|
||||||
|
))
|
||||||
|
blueprint_id = blueprint.id
|
||||||
|
|
||||||
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert 'class="config-preview"' in body
|
||||||
|
assert "exec comp.cfg" in body
|
||||||
|
assert "exec plain.cfg" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_detail_omits_preview_when_nothing_exposed(user_client) -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
user = session.scalar(select(User).where(User.username == "alice"))
|
||||||
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||||||
|
session.add(blueprint)
|
||||||
|
session.flush()
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=False,
|
||||||
|
))
|
||||||
|
blueprint_id = blueprint.id
|
||||||
|
|
||||||
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert 'class="config-preview"' not in body
|
||||||
|
|
||||||
|
|
||||||
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
|
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
user = session.scalar(select(User).where(User.username == "alice"))
|
user = session.scalar(select(User).where(User.username == "alice"))
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,12 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
|
||||||
delete_server(server_id)
|
delete_server(server_id)
|
||||||
|
|
||||||
unit = str(server_id)
|
unit = str(server_id)
|
||||||
assert calls == [
|
# start now auto-initializes before launching, so the call list interleaves
|
||||||
|
# an `initialize` before the `start`.
|
||||||
|
initialize_call = [c for c in calls if c[:2] == ["l4d2ctl", "initialize"]]
|
||||||
|
assert len(initialize_call) == 1
|
||||||
|
operational = [c for c in calls if c[:2] != ["l4d2ctl", "initialize"]]
|
||||||
|
assert operational == [
|
||||||
["l4d2ctl", "install"],
|
["l4d2ctl", "install"],
|
||||||
["l4d2ctl", "start", unit],
|
["l4d2ctl", "start", unit],
|
||||||
["l4d2ctl", "stop", unit],
|
["l4d2ctl", "stop", unit],
|
||||||
|
|
@ -220,9 +225,78 @@ def _attach_workshop_overlay_to_blueprint(
|
||||||
return overlay_id, "1001"
|
return overlay_id, "1001"
|
||||||
|
|
||||||
|
|
||||||
def test_initialize_runs_overlay_builders_synchronously(
|
def test_build_payload_emits_alias_and_exec_lines_for_exposed_overlays(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
db_url = f"sqlite:///{tmp_path/'payload.db'}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with session_scope() as session:
|
||||||
|
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
low = Overlay(name="low", path="42", type="script", user_id=user.id)
|
||||||
|
mid = Overlay(name="mid", path="43", type="script", user_id=user.id)
|
||||||
|
high = Overlay(name="high", path="44", type="script", user_id=user.id)
|
||||||
|
session.add_all([low, mid, high])
|
||||||
|
session.flush()
|
||||||
|
blueprint = Blueprint(
|
||||||
|
user_id=user.id,
|
||||||
|
name="bp",
|
||||||
|
arguments='[]',
|
||||||
|
config='["sv_consistency 1", "mp_disable_autokick 1"]',
|
||||||
|
)
|
||||||
|
session.add(blueprint)
|
||||||
|
session.flush()
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=low.id, position=0, expose_server_cfg=True,
|
||||||
|
))
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=mid.id, position=1, expose_server_cfg=False,
|
||||||
|
))
|
||||||
|
session.add(BlueprintOverlay(
|
||||||
|
blueprint_id=blueprint.id, overlay_id=high.id, position=2, expose_server_cfg=True,
|
||||||
|
))
|
||||||
|
server = Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27020)
|
||||||
|
session.add(server)
|
||||||
|
session.flush()
|
||||||
|
server_id = server.id
|
||||||
|
low_id, high_id = low.id, high.id
|
||||||
|
|
||||||
|
from l4d2web.services.l4d2_facade import (
|
||||||
|
build_server_spec_payload,
|
||||||
|
load_server_blueprint_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id)
|
||||||
|
payload = build_server_spec_payload(server, blueprint, overlay_rows)
|
||||||
|
|
||||||
|
assert payload["overlays"] == [
|
||||||
|
{"path": "42", "alias": f"overlay_{low_id}"},
|
||||||
|
{"path": "43"},
|
||||||
|
{"path": "44", "alias": f"overlay_{high_id}"},
|
||||||
|
]
|
||||||
|
# First in list = topmost = highest precedence, so its exec runs LAST.
|
||||||
|
# Position 0 (low_id) is first in list → exec'd last; position 2 (high_id)
|
||||||
|
# is last in list → exec'd first.
|
||||||
|
assert payload["config"] == [
|
||||||
|
f"exec server_overlay_{high_id}",
|
||||||
|
f"exec server_overlay_{low_id}",
|
||||||
|
"sv_consistency 1",
|
||||||
|
"mp_disable_autokick 1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_does_not_run_overlay_builders(
|
||||||
monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
|
monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Initialize must NOT run overlay builders — those run on overlay save.
|
||||||
|
Running them on every server Start is expensive and redundant.
|
||||||
|
"""
|
||||||
server_id, user_id = server_with_blueprint
|
server_id, user_id = server_with_blueprint
|
||||||
overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint(
|
overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint(
|
||||||
server_id, user_id, item_cached=True, tmp_path=tmp_path
|
server_id, user_id, item_cached=True, tmp_path=tmp_path
|
||||||
|
|
@ -238,7 +312,7 @@ def test_initialize_runs_overlay_builders_synchronously(
|
||||||
initialize_server(server_id)
|
initialize_server(server_id)
|
||||||
|
|
||||||
addons = tmp_path / "overlays" / str(overlay_id) / "left4dead2" / "addons"
|
addons = tmp_path / "overlays" / str(overlay_id) / "left4dead2" / "addons"
|
||||||
assert (addons / "1001.vpk").is_symlink(), "workshop builder must run before l4d2ctl initialize"
|
assert not (addons / "1001.vpk").exists(), "initialize must not trigger workshop builder"
|
||||||
|
|
||||||
|
|
||||||
def test_initialize_fails_fast_on_uncached_workshop_items(
|
def test_initialize_fails_fast_on_uncached_workshop_items(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue