docs: add server hostname implementation plan

This commit is contained in:
mwiegand 2026-05-13 14:21:30 +02:00
parent fcf3143b39
commit f3f0a8927a
No known key found for this signature in database

View file

@ -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 `"<username> <server.name>"` 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/<id>` 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 "<username> <server.name>"."""
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 `<dd>` block (closing `</dd>` at line 14) and before the closing `</dl>` (line 15), add:
```html
<div><dt>Hostname</dt>
<dd>
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="hostname" value="{{ server.hostname }}" placeholder="{{ user.username }} {{ server.name }}" maxlength="128">
<button type="submit">Save</button>
<span class="field-hint">Leave empty for auto: "{{ user.username }} {{ server.name }}"</span>
</form>
</dd>
</div>
```
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.