# 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@.service`) and the directories under `/var/lib/left4me/{instances,runtime}//`. 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/`), 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@.service` units and `/` 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@.service`, confirm `/var/lib/left4me/runtime//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@.service` units and their `/var/lib/left4me/{instances,runtime}//` directories. The web app no longer references them.