feat(l4d2-web): server identity by id, name as display label
Host-side identifier (systemd unit name and /var/lib/left4me dirs) is now
str(server.id), centralized in services/server_identity.server_unit_name.
Server.name becomes a free-form display label, required and unique per
user (was [a-z0-9_-]{1,64} and globally unique).
Migration 0006 swaps the old global UNIQUE(name) for UNIQUE(user_id, name).
Web routes already keyed on id; templates only used name for display.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d906605e9
commit
1166e13e44
12 changed files with 246 additions and 37 deletions
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Server ID as Host Identifier Design
|
||||||
|
|
||||||
|
**Goal:** Decouple the user-facing server label from the host-side identifier. The systemd unit name and on-disk paths become functions of `Server.id`; `Server.name` becomes a free-form display label.
|
||||||
|
|
||||||
|
**Approval status:** User-approved 2026-05-08.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`Server.name` was doing two unrelated jobs. It was the human label rendered in the UI *and* the literal string fed to `l4d2ctl`, which became the systemd unit instance (`left4me-server@<name>.service`) and the directories under `/var/lib/left4me/{instances,runtime}/<name>/`. To stay safe as a unit-template parameter and a path component, the name was forced through `[a-z0-9][a-z0-9_-]{0,63}` and held globally unique. The cost was a UI that demanded machine-friendly slugs, no rename support, and an awkward divergence from overlays — which already separate identity (`id`) from label (`name`).
|
||||||
|
|
||||||
|
This change moves servers onto the same model as overlays. Web URLs already key on `id` (`/servers/<int:server_id>`), so the change is mostly local: pick an id-derived host identifier, pass that everywhere `server.name` was passed, and relax the `name` constraints.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
1. **Host-side identifier = plain numeric id.** `left4me-server@42.service`, `/var/lib/left4me/instances/42/`, `/var/lib/left4me/runtime/42/`. The host CLI's `validate_instance_name` regex (`[a-z0-9][a-z0-9_-]{0,63}`), the systemctl helper's argument check (`[A-Za-z0-9_.-]`), and the unit template (`%i`) all already accept digit-only strings — no host-side change.
|
||||||
|
2. **Name = free-form display label, unique per user, required (≤128 chars).** Whitespace is stripped on save. Two users can both have a server named "Practice"; one user cannot.
|
||||||
|
3. **No data preservation.** Dev-only deploy. Existing servers on the test host are not migrated; their old `left4me-server@<old-name>.service` units and `<old-name>/` directories become orphans and are cleaned up manually.
|
||||||
|
4. **Single source of truth for the id-to-host-name rule.** A one-line helper (`server_unit_name(server_id) -> str(server_id)`) lives in `l4d2web/services/server_identity.py`. Every callsite that used to pass `server.name` to `l4d2ctl` or `journalctl` calls this. Future format tweaks (e.g. `srv-{id}`) are a one-line edit.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
`servers` (Alembic 0006):
|
||||||
|
- Drop the (unnamed) global `UNIQUE (name)` from the original 0001 schema.
|
||||||
|
- Add `UNIQUE (user_id, name)` as `uq_servers_user_name`.
|
||||||
|
- Column stays `name VARCHAR(128) NOT NULL`.
|
||||||
|
|
||||||
|
The migration uses `batch_alter_table(recreate="always")` with a `naming_convention` so the originally-anonymous unique can be referenced as `uq_servers_name` for `drop_constraint`.
|
||||||
|
|
||||||
|
## Code touchpoints
|
||||||
|
|
||||||
|
- `l4d2web/services/server_identity.py` (new)
|
||||||
|
- `l4d2web/models.py` — drop `unique=True` on `Server.name`; add `__table_args__` with the per-user unique.
|
||||||
|
- `l4d2web/alembic/versions/0006_server_name_per_user.py` (new)
|
||||||
|
- `l4d2web/services/l4d2_facade.py` — five `l4d2ctl` invocations switched to `server_unit_name(server.id)`. Parameter renamed to `unit_name` on `server_status` / `stream_server_logs`.
|
||||||
|
- `l4d2web/services/job_worker.py` — status refresh uses `server_unit_name(server.id)`. The `server_name` log-label variable still holds `server.name` (the display label); that's correct now and shows up in job logs as e.g. "starting initialize for My Practice".
|
||||||
|
- `l4d2web/routes/log_routes.py` — SSE log stream feeds `server_unit_name(server.id)` to `journalctl`.
|
||||||
|
- `l4d2web/routes/server_routes.py` — replace `validate_instance_name` with `_validate_display_name` (strip + non-empty + length ≤128). Broaden the `IntegrityError` handler to disambiguate `servers.name` (409 "name already in use") from `servers.port` (409 "port already in use") via the underlying SQLite error string.
|
||||||
|
- `l4d2web/services/security.py` — `validate_instance_name` deleted (no remaining callers).
|
||||||
|
- `l4d2web/templates/servers.html` — name input gains `maxlength="128"`.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
- **Name with shell metacharacters reaches a host command.** Cannot happen — the host call now receives only `str(server.id)` (digits). The display name is never passed through `l4d2ctl`.
|
||||||
|
- **Two servers under the same user with the same name.** Blocked at the DB layer (`uq_servers_user_name`); surfaced as a 409 "name already in use" with no row written.
|
||||||
|
- **Migration on a DB with existing servers.** `batch_alter_table(recreate="always")` rebuilds the table preserving rows; the new per-user constraint is satisfied trivially since the old global constraint already enforced strict uniqueness.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `python -m pytest l4d2web l4d2host deploy` from the repo root — green.
|
||||||
|
2. Stepwise migration on a fresh sqlite (upgrade to 0005, insert two users + a server, upgrade to 0006): row preserved, second user can take the same name, same user cannot (UNIQUE constraint failed: servers.user_id, servers.name).
|
||||||
|
3. Post-deploy on the test host: create a server named `"My Practice"` (with the space), confirm the systemd unit is `left4me-server@<id>.service`, confirm `/var/lib/left4me/runtime/<id>/merged` is mounted on start, confirm log streaming still works.
|
||||||
|
|
||||||
|
## Operator note
|
||||||
|
|
||||||
|
After deploy, on the test host: stop and remove any pre-existing `left4me-server@<old-name>.service` units and their `/var/lib/left4me/{instances,runtime}/<old-name>/` directories. The web app no longer references them.
|
||||||
44
l4d2web/alembic/versions/0006_server_name_per_user.py
Normal file
44
l4d2web/alembic/versions/0006_server_name_per_user.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""server name unique per user
|
||||||
|
|
||||||
|
Revision ID: 0006_server_name_per_user
|
||||||
|
Revises: 0005_script_overlays
|
||||||
|
Create Date: 2026-05-08
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0006_server_name_per_user"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0005_script_overlays"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# 0001_initial defined `name` with column-level `unique=True`, which SQLite
|
||||||
|
# stored as an unnamed UNIQUE constraint. The naming_convention here lets
|
||||||
|
# batch_alter_table refer to it as "uq_servers_name" so we can drop it before
|
||||||
|
# recreating the table with the new (user_id, name) composite.
|
||||||
|
_NAMING_CONVENTION = {"uq": "uq_%(table_name)s_%(column_0_name)s"}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table(
|
||||||
|
"servers",
|
||||||
|
naming_convention=_NAMING_CONVENTION,
|
||||||
|
recreate="always",
|
||||||
|
) as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_servers_name", type_="unique")
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_servers_user_name", ["user_id", "name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table(
|
||||||
|
"servers",
|
||||||
|
naming_convention=_NAMING_CONVENTION,
|
||||||
|
recreate="always",
|
||||||
|
) as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_servers_user_name", type_="unique")
|
||||||
|
batch_op.create_unique_constraint("uq_servers_name", ["name"])
|
||||||
|
|
@ -123,11 +123,14 @@ class BlueprintOverlay(Base):
|
||||||
|
|
||||||
class Server(Base):
|
class Server(Base):
|
||||||
__tablename__ = "servers"
|
__tablename__ = "servers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_servers_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
|
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||||
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
|
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
|
||||||
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Server
|
from l4d2web.models import Server
|
||||||
from l4d2web.services import l4d2_facade as facade
|
from l4d2web.services import l4d2_facade as facade
|
||||||
|
from l4d2web.services.server_identity import server_unit_name
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__)
|
bp = Blueprint("logs", __name__)
|
||||||
|
|
@ -27,7 +28,7 @@ def stream_server_logs(server_id: int) -> Response:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
for line in facade.stream_server_logs(server.name, lines=200, follow=True):
|
for line in facade.stream_server_logs(server_unit_name(server.id), lines=200, follow=True):
|
||||||
if line == "":
|
if line == "":
|
||||||
yield ": keepalive\n\n"
|
yield ": keepalive\n\n"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,25 @@ from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
from l4d2web.models import Blueprint as BlueprintModel
|
||||||
from l4d2web.models import Job, Server
|
from l4d2web.models import Job, Server
|
||||||
from l4d2web.services.security import validate_instance_name
|
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("server", __name__)
|
bp = Blueprint("server", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_MAX_LENGTH = 128
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_display_name(raw: object) -> str:
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise ValueError("name must be a string")
|
||||||
|
cleaned = raw.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("name must not be empty")
|
||||||
|
if len(cleaned) > _NAME_MAX_LENGTH:
|
||||||
|
raise ValueError("name too long")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _allocate_next_port(db) -> int | None:
|
def _allocate_next_port(db) -> int | None:
|
||||||
start = int(current_app.config["PORT_RANGE_START"])
|
start = int(current_app.config["PORT_RANGE_START"])
|
||||||
end = int(current_app.config["PORT_RANGE_END"])
|
end = int(current_app.config["PORT_RANGE_END"])
|
||||||
|
|
@ -51,7 +64,7 @@ def create_server() -> Response:
|
||||||
payload = request.get_json(silent=True) if json_response else request.form
|
payload = request.get_json(silent=True) if json_response else request.form
|
||||||
|
|
||||||
try:
|
try:
|
||||||
name = validate_instance_name(str(payload["name"]))
|
name = _validate_display_name(payload["name"])
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
return Response("invalid server name", status=400)
|
return Response("invalid server name", status=400)
|
||||||
|
|
||||||
|
|
@ -82,8 +95,11 @@ def create_server() -> Response:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.flush()
|
db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
detail = str(exc.orig) if exc.orig is not None else str(exc)
|
||||||
|
if "servers.name" in detail:
|
||||||
|
return Response("name already in use", status=409)
|
||||||
return Response("port already in use", status=409)
|
return Response("port already in use", status=409)
|
||||||
|
|
||||||
server_id = server.id
|
server_id = server.id
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from l4d2web.models import (
|
||||||
WorkshopItem,
|
WorkshopItem,
|
||||||
)
|
)
|
||||||
from l4d2web.services.host_commands import CommandCancelledError
|
from l4d2web.services.host_commands import CommandCancelledError
|
||||||
|
from l4d2web.services.server_identity import server_unit_name
|
||||||
|
|
||||||
|
|
||||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||||
|
|
@ -570,7 +571,7 @@ def refresh_server_actual_state(server_id: int) -> str:
|
||||||
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:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
status = l4d2_facade.server_status(server.name)
|
status = l4d2_facade.server_status(server_unit_name(server.id))
|
||||||
server.actual_state = status.state
|
server.actual_state = status.state
|
||||||
server.actual_state_updated_at = now
|
server.actual_state_updated_at = now
|
||||||
server.updated_at = now
|
server.updated_at = now
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from l4d2web.models import (
|
||||||
WorkshopItem,
|
WorkshopItem,
|
||||||
)
|
)
|
||||||
from l4d2web.services import host_commands
|
from l4d2web.services import host_commands
|
||||||
|
from l4d2web.services.server_identity import server_unit_name
|
||||||
from l4d2web.services.spec_yaml import write_temp_spec
|
from l4d2web.services.spec_yaml import write_temp_spec
|
||||||
from l4d2web.services.workshop_paths import cache_path
|
from l4d2web.services.workshop_paths import cache_path
|
||||||
|
|
||||||
|
|
@ -83,7 +84,7 @@ def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_can
|
||||||
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_refs))
|
||||||
try:
|
try:
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "initialize", server.name, "-f", str(spec_path)],
|
["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)],
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
should_cancel=should_cancel,
|
should_cancel=should_cancel,
|
||||||
|
|
@ -176,7 +177,7 @@ 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:
|
||||||
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.name],
|
["l4d2ctl", "start", server_unit_name(server.id)],
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
should_cancel=should_cancel,
|
should_cancel=should_cancel,
|
||||||
|
|
@ -186,7 +187,7 @@ def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=N
|
||||||
def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
||||||
server, _, _ = load_server_blueprint_bundle(server_id)
|
server, _, _ = load_server_blueprint_bundle(server_id)
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "stop", server.name],
|
["l4d2ctl", "stop", server_unit_name(server.id)],
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
should_cancel=should_cancel,
|
should_cancel=should_cancel,
|
||||||
|
|
@ -196,7 +197,7 @@ def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=No
|
||||||
def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
||||||
server, _, _ = load_server_blueprint_bundle(server_id)
|
server, _, _ = load_server_blueprint_bundle(server_id)
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "delete", server.name],
|
["l4d2ctl", "delete", server_unit_name(server.id)],
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
should_cancel=should_cancel,
|
should_cancel=should_cancel,
|
||||||
|
|
@ -206,15 +207,15 @@ def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=
|
||||||
def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
|
||||||
server, _, _ = load_server_blueprint_bundle(server_id)
|
server, _, _ = load_server_blueprint_bundle(server_id)
|
||||||
host_commands.run_command(
|
host_commands.run_command(
|
||||||
["l4d2ctl", "reset", server.name],
|
["l4d2ctl", "reset", server_unit_name(server.id)],
|
||||||
on_stdout=on_stdout,
|
on_stdout=on_stdout,
|
||||||
on_stderr=on_stderr,
|
on_stderr=on_stderr,
|
||||||
should_cancel=should_cancel,
|
should_cancel=should_cancel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def server_status(server_name: str) -> ServerStatus:
|
def server_status(unit_name: str) -> ServerStatus:
|
||||||
result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"])
|
result = host_commands.run_command(["l4d2ctl", "status", unit_name, "--json"])
|
||||||
payload = json.loads(result.stdout or "{}")
|
payload = json.loads(result.stdout or "{}")
|
||||||
return ServerStatus(
|
return ServerStatus(
|
||||||
state=str(payload.get("state", "unknown")),
|
state=str(payload.get("state", "unknown")),
|
||||||
|
|
@ -223,7 +224,7 @@ def server_status(server_name: str) -> ServerStatus:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def stream_server_logs(server_name: str, *, lines: int = 200, follow: bool = True):
|
def stream_server_logs(unit_name: str, *, lines: int = 200, follow: bool = True):
|
||||||
command = ["l4d2ctl", "logs", server_name, "--lines", str(lines)]
|
command = ["l4d2ctl", "logs", unit_name, "--lines", str(lines)]
|
||||||
command.append("--follow" if follow else "--no-follow")
|
command.append("--follow" if follow else "--no-follow")
|
||||||
return host_commands.stream_command(command)
|
return host_commands.stream_command(command)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,3 @@
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
_INSTANCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_instance_name(raw: str) -> str:
|
|
||||||
if not _INSTANCE_NAME_RE.fullmatch(raw):
|
|
||||||
raise ValueError(
|
|
||||||
"instance name must match [a-z0-9][a-z0-9_-]{0,63}"
|
|
||||||
)
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def validate_overlay_ref(raw: str) -> str:
|
def validate_overlay_ref(raw: str) -> str:
|
||||||
if raw != raw.strip():
|
if raw != raw.strip():
|
||||||
raise ValueError("overlay ref must not have leading or trailing whitespace")
|
raise ValueError("overlay ref must not have leading or trailing whitespace")
|
||||||
|
|
|
||||||
5
l4d2web/services/server_identity.py
Normal file
5
l4d2web/services/server_identity.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
def server_unit_name(server_id: int) -> str:
|
||||||
|
"""Host-side identifier for a server (systemd unit suffix and on-disk
|
||||||
|
directory name). Lives in one place so a future format change is a
|
||||||
|
single-line edit."""
|
||||||
|
return str(server_id)
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<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" required></label>
|
<label>Name <input name="name" required maxlength="128"></label>
|
||||||
<label>Port
|
<label>Port
|
||||||
<input name="port" type="number" min="1" max="65535" placeholder="auto">
|
<input name="port" type="number" min="1" max="65535" placeholder="auto">
|
||||||
<span class="field-hint">Leave empty for the next available port.</span>
|
<span class="field-hint">Leave empty for the next available port.</span>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data(
|
||||||
server_id, _ = server_with_blueprint
|
server_id, _ = server_with_blueprint
|
||||||
initialize_server(server_id)
|
initialize_server(server_id)
|
||||||
|
|
||||||
assert calls[0][:3] == ["l4d2ctl", "initialize", "alpha"]
|
assert calls[0][:3] == ["l4d2ctl", "initialize", str(server_id)]
|
||||||
assert calls[0][3] == "-f"
|
assert calls[0][3] == "-f"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,12 +122,13 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
|
||||||
reset_server(server_id)
|
reset_server(server_id)
|
||||||
delete_server(server_id)
|
delete_server(server_id)
|
||||||
|
|
||||||
|
unit = str(server_id)
|
||||||
assert calls == [
|
assert calls == [
|
||||||
["l4d2ctl", "install"],
|
["l4d2ctl", "install"],
|
||||||
["l4d2ctl", "start", "alpha"],
|
["l4d2ctl", "start", unit],
|
||||||
["l4d2ctl", "stop", "alpha"],
|
["l4d2ctl", "stop", unit],
|
||||||
["l4d2ctl", "reset", "alpha"],
|
["l4d2ctl", "reset", unit],
|
||||||
["l4d2ctl", "delete", "alpha"],
|
["l4d2ctl", "delete", unit],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,8 @@ def test_create_server_duplicate_port(user_client_with_blueprints) -> None:
|
||||||
assert servers[0].name == "server-1"
|
assert servers[0].name == "server-1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_name", ["..", "../escape", "foo/bar", " foo", "Foo"])
|
@pytest.mark.parametrize("bad_name", ["", " ", "x" * 129])
|
||||||
def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_name: str) -> None:
|
def test_create_server_rejects_invalid_display_names(user_client_with_blueprints, bad_name: str) -> None:
|
||||||
client, data = user_client_with_blueprints
|
client, data = user_client_with_blueprints
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/servers",
|
"/servers",
|
||||||
|
|
@ -158,6 +158,102 @@ def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_nam
|
||||||
assert session.scalars(select(Server)).all() == []
|
assert session.scalars(select(Server)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["My Practice", "räumlich", "alpha/beta", "..", "Foo"])
|
||||||
|
def test_create_server_accepts_free_form_display_names(user_client_with_blueprints, name: str) -> None:
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
response = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": name, "blueprint_id": str(data["blueprint_id"])},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.models import Server
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
server = session.scalars(select(Server)).one()
|
||||||
|
assert server.name == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_strips_surrounding_whitespace_in_name(user_client_with_blueprints) -> None:
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
response = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": " spaced ", "blueprint_id": str(data["blueprint_id"])},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.models import Server
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
server = session.scalars(select(Server)).one()
|
||||||
|
assert server.name == "spaced"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_rejects_duplicate_name_for_same_user(user_client_with_blueprints) -> None:
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
first = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": "practice", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert first.status_code == 302
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": "practice", "port": "27016", "blueprint_id": str(data["blueprint_id"])},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert second.status_code == 409
|
||||||
|
assert b"name already in use" in second.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_server_allows_same_name_for_different_users(tmp_path, monkeypatch) -> None:
|
||||||
|
db_url = f"sqlite:///{tmp_path/'two_users.db'}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
with session_scope() as session:
|
||||||
|
alice = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||||
|
bob = User(username="bob", password_digest=hash_password("secret"), admin=False)
|
||||||
|
session.add_all([alice, bob])
|
||||||
|
session.flush()
|
||||||
|
alice_bp = Blueprint(user_id=alice.id, name="bp", arguments="[]", config="[]")
|
||||||
|
bob_bp = Blueprint(user_id=bob.id, name="bp", arguments="[]", config="[]")
|
||||||
|
session.add_all([alice_bp, bob_bp])
|
||||||
|
session.flush()
|
||||||
|
alice_id, bob_id = alice.id, bob.id
|
||||||
|
alice_bp_id, bob_bp_id = alice_bp.id, bob_bp.id
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["user_id"] = alice_id
|
||||||
|
sess["csrf_token"] = "test-token"
|
||||||
|
alice_resp = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": "practice", "port": "27015", "blueprint_id": str(alice_bp_id)},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert alice_resp.status_code == 302
|
||||||
|
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["user_id"] = bob_id
|
||||||
|
sess["csrf_token"] = "test-token"
|
||||||
|
bob_resp = client.post(
|
||||||
|
"/servers",
|
||||||
|
data={"name": "practice", "port": "27016", "blueprint_id": str(bob_bp_id)},
|
||||||
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
|
)
|
||||||
|
assert bob_resp.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None:
|
def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None:
|
||||||
client, data = user_client_with_blueprints
|
client, data = user_client_with_blueprints
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue