# 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.