Compare commits
7 commits
fe43f67b51
...
6cc1736f17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc1736f17 | ||
|
|
963851c0e1 | ||
|
|
d42383dc37 | ||
|
|
69d93dda4f | ||
|
|
0a7f48f174 | ||
|
|
f3f0a8927a | ||
|
|
fcf3143b39 |
10 changed files with 707 additions and 3 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,3 +10,5 @@ l4d2web.db*
|
||||||
# CocoIndex Code (ccc)
|
# CocoIndex Code (ccc)
|
||||||
/.cocoindex_code/
|
/.cocoindex_code/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
*.db
|
||||||
|
opencode.json
|
||||||
|
|
|
||||||
408
docs/superpowers/plans/2026-05-13-server-hostname-v1.md
Normal file
408
docs/superpowers/plans/2026-05-13-server-hostname-v1.md
Normal 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.
|
||||||
77
docs/superpowers/specs/2026-05-13-server-hostname-design.md
Normal file
77
docs/superpowers/specs/2026-05-13-server-hostname-design.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Server Hostname (Source `hostname` cvar) Design
|
||||||
|
|
||||||
|
**Goal:** Allow users to set the L4D2 server name (`hostname` cvar) that players see in the server browser and MOTD, with an ephemeral auto-generated fallback.
|
||||||
|
|
||||||
|
**Architecture:** A new `hostname VARCHAR(128)` column on the `servers` table. Empty string means "auto-generate at deploy time." The fallback is resolved ephemerally in `initialize_server` — computed fresh from `user.username + server.name` on each deploy, never persisted. Explicit overrides are stored and emitted verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
Add one column to `Server` in `l4d2web/models.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
hostname: Mapped[str] = mapped_column(String(128), default="", nullable=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default `""` means auto-generate. Non-empty means explicit override.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
| `hostname` value | Deploy result |
|
||||||
|
|---|---|
|
||||||
|
| `""` (empty) | Emit `hostname "<username> <server.name>"` — computed fresh each deploy, never written to DB |
|
||||||
|
| `"My Server"` | Emit `hostname "My Server"` verbatim |
|
||||||
|
| User clears the field | Resets to `""`, next deploy auto-generates |
|
||||||
|
|
||||||
|
The fallback is ephemeral — `initialize_server` resolves it in-memory for the spec YAML. The DB row stays empty. This means renames auto-propagate to the hostname on the next deploy without manual updates.
|
||||||
|
|
||||||
|
## Spec Payload
|
||||||
|
|
||||||
|
`build_server_spec_payload()` gains an optional `resolved_hostname: str = ""` keyword parameter. When non-empty, a `hostname "..."` line is inserted into the config array, before the `rcon_password` line (so rcon remains last-wins).
|
||||||
|
|
||||||
|
`initialize_server()` resolves the hostname:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with session_scope() as db:
|
||||||
|
user = db.get(User, server.user_id)
|
||||||
|
resolved = server.hostname or f"{user.username} {server.name}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
On `server_detail.html`, a new row in the info `<dl>` block, placed after the RCON password row:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hostname: [ _______________ ] [Save]
|
||||||
|
Leave empty for auto: "alice alpha"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Input `name="hostname"`, `maxlength="128"`
|
||||||
|
- `value="{{ server.hostname }}"` (empty when not set)
|
||||||
|
- `placeholder="{{ user.username }} {{ server.name }}"` (previews auto-generated value)
|
||||||
|
- Form submits to `POST /servers/<id>` — same endpoint as the rename form
|
||||||
|
- No hostname field in the create-server modal; new servers always start with `hostname=""`
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
**`POST /servers/<int:server_id>`** (update_server_form) — unchanged signature; just also saves `request.form.get("hostname", "")` to `server.hostname`.
|
||||||
|
|
||||||
|
**`POST /servers`** (create_server) — unchanged; `hostname` defaults to `""` from the model default.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `l4d2web/models.py` | Add `hostname` column to `Server` |
|
||||||
|
| `l4d2web/alembic/versions/0011_server_hostname.py` | Migration — `ADD COLUMN hostname VARCHAR(128) NOT NULL DEFAULT ''` |
|
||||||
|
| `l4d2web/routes/server_routes.py` | `update_server_form` saves `hostname` from form |
|
||||||
|
| `l4d2web/services/l4d2_facade.py` | `build_server_spec_payload` accepts `resolved_hostname=`, emits `hostname "..."` line. `initialize_server` resolves fallback. |
|
||||||
|
| `l4d2web/templates/server_detail.html` | Hostname form row in info `<dl>` |
|
||||||
|
| `l4d2web/tests/test_servers.py` | Tests for create default, update, clear |
|
||||||
|
| `l4d2web/tests/test_l4d2_facade.py` | Tests for hostname in spec, fallback resolution |
|
||||||
|
|
||||||
|
## Open / Closed
|
||||||
|
|
||||||
|
- **Explicit vs ephemeral:** Explicit overrides persist; empty means auto at deploy time. No toggle, no "locked" mode needed in v1.
|
||||||
|
- **No hostname in create modal:** Simplifies the form. Hostname is configured post-creation on the detail page.
|
||||||
30
l4d2web/alembic/versions/0011_server_hostname.py
Normal file
30
l4d2web/alembic/versions/0011_server_hostname.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -146,6 +146,9 @@ class Server(Base):
|
||||||
rcon_password: Mapped[str] = mapped_column(
|
rcon_password: Mapped[str] = mapped_column(
|
||||||
String(64), nullable=False, default="", server_default=""
|
String(64), nullable=False, default="", server_default=""
|
||||||
)
|
)
|
||||||
|
hostname: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, default="", server_default=""
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ def update_server_form(server_id: int) -> Response:
|
||||||
if server is None:
|
if server is None:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
server.name = name
|
server.name = name
|
||||||
|
if "hostname" in request.form:
|
||||||
|
server.hostname = request.form["hostname"]
|
||||||
try:
|
try:
|
||||||
db.flush()
|
db.flush()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from l4d2web.models import (
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayWorkshopItem,
|
OverlayWorkshopItem,
|
||||||
Server,
|
Server,
|
||||||
|
User,
|
||||||
WorkshopItem,
|
WorkshopItem,
|
||||||
)
|
)
|
||||||
from l4d2web.services import host_commands
|
from l4d2web.services import host_commands
|
||||||
|
|
@ -30,6 +31,8 @@ def build_server_spec_payload(
|
||||||
server: Server,
|
server: Server,
|
||||||
blueprint: Blueprint,
|
blueprint: Blueprint,
|
||||||
overlay_rows: list[tuple[int, str, bool]],
|
overlay_rows: list[tuple[int, str, bool]],
|
||||||
|
*,
|
||||||
|
resolved_hostname: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
overlays: list[dict] = []
|
overlays: list[dict] = []
|
||||||
for overlay_id, path, expose in overlay_rows:
|
for overlay_id, path, expose in overlay_rows:
|
||||||
|
|
@ -45,8 +48,10 @@ def build_server_spec_payload(
|
||||||
if expose
|
if expose
|
||||||
]
|
]
|
||||||
config_lines: list[str] = exec_lines + json.loads(blueprint.config)
|
config_lines: list[str] = exec_lines + json.loads(blueprint.config)
|
||||||
# rcon_password is appended LAST so neither overlays nor user blueprint
|
if resolved_hostname:
|
||||||
# config can override it (Source's cvar semantics are last-wins).
|
config_lines.append(f'hostname "{resolved_hostname}"')
|
||||||
|
# rcon_password is appended LAST so neither overlays, blueprint config,
|
||||||
|
# nor hostname can override it (Source's cvar semantics are last-wins).
|
||||||
if server.rcon_password:
|
if server.rcon_password:
|
||||||
config_lines.append(f'rcon_password "{server.rcon_password}"')
|
config_lines.append(f'rcon_password "{server.rcon_password}"')
|
||||||
return {
|
return {
|
||||||
|
|
@ -98,7 +103,17 @@ def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_can
|
||||||
# than mount a partial overlay (would silently leave maps missing in-game).
|
# than mount a partial overlay (would silently leave maps missing in-game).
|
||||||
_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_rows))
|
# 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}"
|
||||||
|
|
||||||
|
spec_path = write_temp_spec(build_server_spec_payload(
|
||||||
|
server, blueprint, overlay_rows, resolved_hostname=resolved_hostname,
|
||||||
|
))
|
||||||
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)],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,16 @@
|
||||||
<div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
|
<div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
|
||||||
<div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
|
<div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
|
||||||
<div><dt>RCON Password</dt><dd><span class="password-mask" data-password-field="{{ server.id }}">••••••••••••</span><span class="password-value" data-password-field="{{ server.id }}" hidden>{{ server.rcon_password }}</span> <button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button></dd></div>
|
<div><dt>RCON Password</dt><dd><span class="password-mask" data-password-field="{{ server.id }}">••••••••••••</span><span class="password-value" data-password-field="{{ server.id }}" hidden>{{ server.rcon_password }}</span> <button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button></dd></div>
|
||||||
|
<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="{{ g.user.username }} {{ server.name }}" maxlength="128">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<span class="field-hint">Leave empty for auto: "{{ g.user.username }} {{ server.name }}"</span>
|
||||||
|
</form>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h2 class="section-title">Actions</h2>
|
<h2 class="section-title">Actions</h2>
|
||||||
|
|
|
||||||
|
|
@ -385,3 +385,53 @@ def test_build_server_spec_payload_omits_rcon_password_when_empty() -> None:
|
||||||
assert not line.startswith("rcon_password ")
|
assert not line.startswith("rcon_password ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_server_spec_payload — hostname injection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# The fixture creates user "alice" and server named "alpha"
|
||||||
|
assert 'hostname "alice alpha"' in spec_contents[0]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -608,3 +608,110 @@ def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_b
|
||||||
).all()
|
).all()
|
||||||
assert len(jobs) == 1
|
assert len(jobs) == 1
|
||||||
assert jobs[0].state == "queued"
|
assert jobs[0].state == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
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_rename_preserves_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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rename without sending hostname (simulates rename modal)
|
||||||
|
client.post(
|
||||||
|
f"/servers/{server_id}",
|
||||||
|
data={"name": "beta"},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
server = session.scalar(select(Server).where(Server.name == "beta"))
|
||||||
|
assert server is not None
|
||||||
|
assert server.hostname == "My Cool Server", "rename must not wipe hostname"
|
||||||
|
|
||||||
|
|
||||||
|
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 == ""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue