diff --git a/docs/superpowers/plans/2026-05-13-server-hostname-v1.md b/docs/superpowers/plans/2026-05-13-server-hostname-v1.md new file mode 100644 index 0000000..762da87 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-server-hostname-v1.md @@ -0,0 +1,408 @@ +# Server Hostname (Source `hostname` cvar) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `hostname` column to the Server model so users can set the Source `hostname` cvar (server browser/MOTD name), with an ephemeral `" "` fallback resolved at deploy time. + +**Architecture:** New `hostname VARCHAR(128)` column on `servers` table (default `""`). Empty = auto-generate at deploy. The `build_server_spec_payload()` function gains a `resolved_hostname` kwarg; `initialize_server()` resolves the fallback ephemerally. The server detail page shows an inline form under RCON password. Same `POST /servers/` endpoint handles saving. + +**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, Alembic, pytest. + +--- + +### Task 1: Add `hostname` column to Server model and migration + +**Files:** +- Modify: `l4d2web/models.py` +- Create: `l4d2web/alembic/versions/0011_server_hostname.py` + +- [ ] **Step 1: Add hostname column to model** + +Add to the `Server` class in `l4d2web/models.py:131`: + +```python +hostname: Mapped[str] = mapped_column(String(128), default="", nullable=False) +``` + +Place it after `rcon_password` (line 148) and before `created_at` (line 149). + +- [ ] **Step 2: Create the migration** + +Create `l4d2web/alembic/versions/0011_server_hostname.py`: + +```python +"""add hostname column to servers + +Revision ID: 0011_server_hostname +Revises: 0010_server_live_state +Create Date: 2026-05-13 +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0011_server_hostname" +down_revision: Union[str, Sequence[str], None] = "0010_server_live_state" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("servers") as batch_op: + batch_op.add_column( + sa.Column("hostname", sa.String(length=128), nullable=False, server_default="") + ) + + +def downgrade() -> None: + with op.batch_alter_table("servers") as batch_op: + batch_op.drop_column("hostname") +``` + +- [ ] **Step 3: Verify migration applies cleanly** + +Run: `cd l4d2web && alembic upgrade head` +Expected: runs `0011_server_hostname` migration, adds the column. + +Run: `cd l4d2web && alembic downgrade -1` +Expected: drops the column. + +Run: `cd l4d2web && alembic upgrade head` +Expected: re-adds the column. + +- [ ] **Step 4: Commit model + migration** + +```bash +git add l4d2web/models.py l4d2web/alembic/versions/0011_server_hostname.py +git commit -m "feat(l4d2-web): add hostname column to Server model" +``` + +--- + +### Task 2: Accept and save `hostname` on server update + +**Files:** +- Modify: `l4d2web/routes/server_routes.py` +- Test: `l4d2web/tests/test_servers.py` + +- [ ] **Step 1: Write failing hostname update tests** + +Add to `l4d2web/tests/test_servers.py`: + +```python +def test_create_server_hostname_defaults_empty(user_client_with_blueprints) -> None: + from sqlalchemy import select + from l4d2web.models import Server + + client, data = user_client_with_blueprints + response = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + + with session_scope() as session: + server = session.scalar(select(Server).where(Server.name == "alpha")) + assert server is not None + assert server.hostname == "" + + +def test_update_server_hostname_via_form(user_client_with_blueprints) -> None: + from sqlalchemy import select + from l4d2web.models import Server + + client, data = user_client_with_blueprints + create = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + server_id = create.headers["Location"].rsplit("/", 1)[1] + + update = client.post( + f"/servers/{server_id}", + data={"name": "alpha", "hostname": "My Cool Server"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert update.status_code == 302 + + with session_scope() as session: + server = session.scalar(select(Server).where(Server.name == "alpha")) + assert server is not None + assert server.hostname == "My Cool Server" + + +def test_update_server_clears_hostname(user_client_with_blueprints) -> None: + from sqlalchemy import select + from l4d2web.models import Server + + client, data = user_client_with_blueprints + create = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + server_id = create.headers["Location"].rsplit("/", 1)[1] + + # Set hostname first + client.post( + f"/servers/{server_id}", + data={"name": "alpha", "hostname": "My Cool Server"}, + headers={"X-CSRF-Token": "test-token"}, + ) + + # Clear it + client.post( + f"/servers/{server_id}", + data={"name": "alpha", "hostname": ""}, + headers={"X-CSRF-Token": "test-token"}, + ) + + with session_scope() as session: + server = session.scalar(select(Server).where(Server.name == "alpha")) + assert server is not None + assert server.hostname == "" +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `pytest l4d2web/tests/test_servers.py -k "hostname" -v` +Expected: FAIL — `Server` object has no attribute `hostname`. + +- [ ] **Step 3: Save hostname from form in update route** + +In `l4d2web/routes/server_routes.py`, modify `update_server_form` (around line 130) to save hostname: + +```python + server.name = name + server.hostname = request.form.get("hostname", "") +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `pytest l4d2web/tests/test_servers.py -k "hostname" -v` +Expected: PASS. + +- [ ] **Step 5: Commit hostname update support** + +```bash +git add l4d2web/routes/server_routes.py l4d2web/tests/test_servers.py +git commit -m "feat(l4d2-web): accept hostname on server update, default empty on create" +``` + +--- + +### Task 3: Emit `hostname` in spec payload with ephemeral fallback + +**Files:** +- Modify: `l4d2web/services/l4d2_facade.py` +- Test: `l4d2web/tests/test_l4d2_facade.py` + +- [ ] **Step 1: Write failing hostname spec tests** + +Add to `l4d2web/tests/test_l4d2_facade.py`: + +```python +def test_build_server_spec_payload_injects_hostname() -> None: + from l4d2web.services.l4d2_facade import build_server_spec_payload + + bp = Blueprint(id=1, user_id=1, name="bp", arguments="[]", config='["sv_consistency 1"]') + srv = Server(id=1, user_id=1, blueprint_id=1, name="alpha", port=27015, rcon_password="sekret") + spec = build_server_spec_payload(srv, bp, [], resolved_hostname="My Server") + cfg = spec["config"] + assert "hostname \"My Server\"" in cfg + assert cfg[-1] == "rcon_password \"sekret\"" + + +def test_build_server_spec_payload_omits_hostname_when_empty() -> None: + from l4d2web.services.l4d2_facade import build_server_spec_payload + + bp = Blueprint(id=1, user_id=1, name="bp", arguments="[]", config="[]") + srv = Server(id=1, user_id=1, blueprint_id=1, name="alpha", port=27015, rcon_password="sekret") + spec = build_server_spec_payload(srv, bp, []) + for line in spec["config"]: + assert not line.startswith("hostname ") + + +def test_initialize_server_resolves_fallback_hostname( + monkeypatch: pytest.MonkeyPatch, server_with_blueprint, +) -> None: + """When server.hostname is empty, deploy emits hostname " ".""" + from l4d2web.services.l4d2_facade import initialize_server + + spec_contents: list[str] = [] + + def fake_run_command(cmd, **kwargs): + nonlocal spec_contents + spec_path = cmd[cmd.index("-f") + 1] + spec_contents.append(Path(spec_path).read_text()) + return CommandResult(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command) + + server_id, _ = server_with_blueprint + initialize_server(server_id) + + assert len(spec_contents) == 1 + assert "hostname" in spec_contents[0] + # The fixture creates user "alice" and server named "alpha" + assert '"alice alpha"' in spec_contents[0] +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `pytest l4d2web/tests/test_l4d2_facade.py -k "hostname" -v` +Expected: FAIL — `build_server_spec_payload()` got unexpected keyword `resolved_hostname`. + +- [ ] **Step 3: Add `resolved_hostname` kwarg and emit line** + +In `l4d2web/services/l4d2_facade.py`, modify `build_server_spec_payload` signature and add the hostname injection before `rcon_password`: + +```python +def build_server_spec_payload( + server: Server, + blueprint: Blueprint, + overlay_rows: list[tuple[int, str, bool]], + *, + resolved_hostname: str = "", +) -> dict: +``` + +Inside the function, after building `config_lines` and before the `if server.rcon_password:` block, add: + +```python + if resolved_hostname: + config_lines.append(f'hostname "{resolved_hostname}"') +``` + +Then in `initialize_server`, resolve the fallback. Add the `User` import at the top: + +```python +from l4d2web.models import ( + Blueprint, + BlueprintOverlay, + Overlay, + OverlayWorkshopItem, + Server, + User, + WorkshopItem, +) +``` + +In `initialize_server`, after `load_server_blueprint_bundle(server_id)`, add: + +```python + # Resolve hostname — explicit override or ephemeral fallback + if server.hostname: + resolved_hostname = server.hostname + else: + with session_scope() as db: + user = db.get(User, server.user_id) + resolved_hostname = f"{user.username} {server.name}" +``` + +Then pass it to `build_server_spec_payload`: + +```python + spec_path = write_temp_spec(build_server_spec_payload( + server, blueprint, overlay_rows, resolved_hostname=resolved_hostname, + )) +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `pytest l4d2web/tests/test_l4d2_facade.py -k "hostname" -v` +Expected: PASS. + +Also run full suite to check nothing broken: `pytest l4d2web/tests/test_l4d2_facade.py -v` + +- [ ] **Step 5: Commit hostname spec payload** + +```bash +git add l4d2web/services/l4d2_facade.py l4d2web/tests/test_l4d2_facade.py +git commit -m "feat(l4d2-web): emit hostname in spec config with ephemeral fallback" +``` + +--- + +### Task 4: Add hostname form to server detail page + +**Files:** +- Modify: `l4d2web/templates/server_detail.html` + +- [ ] **Step 1: Verify the current template renders correctly first** + +Run: `pytest l4d2web/tests -q` +Expected: PASS (baseline). + +- [ ] **Step 2: Add hostname form under RCON password** + +In `l4d2web/templates/server_detail.html`, after the RCON password `
` block (closing `
` at line 14) and before the closing `` (line 15), add: + +```html +
Hostname
+
+
+ + + + Leave empty for auto: "{{ user.username }} {{ server.name }}" +
+
+
+``` + +The `user` variable is already available in the template context (the server detail route passes it through the auth mechanism). + +- [ ] **Step 3: Run full test suite to verify nothing broken** + +Run: `pytest l4d2web/tests -q` +Expected: PASS. + +- [ ] **Step 4: Commit template change** + +```bash +git add l4d2web/templates/server_detail.html +git commit -m "feat(l4d2-web): add hostname edit form to server detail page" +``` + +--- + +### Task 5: Final integration verification + +**Files:** +- Run full test suites + +- [ ] **Step 1: Run all tests** + +Run: `pytest l4d2web/tests -q` +Expected: PASS. + +Run: `pytest l4d2host/tests -q` (host lib must not be affected) +Expected: PASS. + +- [ ] **Step 2: Run alembic check to ensure migration is the latest** + +Run: `cd l4d2web && alembic check` +Expected: "No new upgrade operations detected." + +- [ ] **Step 3: Commit any final touches needed** + +```bash +git add -A +git commit -m "chore: finalize server hostname feature" +``` + +--- + +## Self-Review + +- [ ] Spec coverage: model column, migration, update route saves hostname, spec payload emits hostname line, ephemeral fallback resolved in initialize_server, template has inline form. +- [ ] Placeholder scan: no TODOs or TBDs. +- [ ] Type/name consistency: `resolved_hostname` kwarg matches usage in both caller and callee. +- [ ] Verification: each task has exact test commands and expected outcomes.