left4me/docs/superpowers/specs/2026-05-08-server-id-as-host-identifier-design.md
mwiegand 1166e13e44
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>
2026-05-08 19:22:09 +02:00

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

  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.pyvalidate_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.