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>
5.3 KiB
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
- Host-side identifier = plain numeric id.
left4me-server@42.service,/var/lib/left4me/instances/42/,/var/lib/left4me/runtime/42/. The host CLI'svalidate_instance_nameregex ([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. - 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.
- No data preservation. Dev-only deploy. Existing servers on the test host are not migrated; their old
left4me-server@<old-name>.serviceunits and<old-name>/directories become orphans and are cleaned up manually. - Single source of truth for the id-to-host-name rule. A one-line helper (
server_unit_name(server_id) -> str(server_id)) lives inl4d2web/services/server_identity.py. Every callsite that used to passserver.nametol4d2ctlorjournalctlcalls 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)asuq_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— dropunique=TrueonServer.name; add__table_args__with the per-user unique.l4d2web/alembic/versions/0006_server_name_per_user.py(new)l4d2web/services/l4d2_facade.py— fivel4d2ctlinvocations switched toserver_unit_name(server.id). Parameter renamed tounit_nameonserver_status/stream_server_logs.l4d2web/services/job_worker.py— status refresh usesserver_unit_name(server.id). Theserver_namelog-label variable still holdsserver.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 feedsserver_unit_name(server.id)tojournalctl.l4d2web/routes/server_routes.py— replacevalidate_instance_namewith_validate_display_name(strip + non-empty + length ≤128). Broaden theIntegrityErrorhandler to disambiguateservers.name(409 "name already in use") fromservers.port(409 "port already in use") via the underlying SQLite error string.l4d2web/services/security.py—validate_instance_namedeleted (no remaining callers).l4d2web/templates/servers.html— name input gainsmaxlength="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 throughl4d2ctl. - 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
python -m pytest l4d2web l4d2host deployfrom the repo root — green.- 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).
- Post-deploy on the test host: create a server named
"My Practice"(with the space), confirm the systemd unit isleft4me-server@<id>.service, confirm/var/lib/left4me/runtime/<id>/mergedis 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.