refactor(deploy): venv + steam now under /var/lib/left4me
Sync deployment references for the runtime state relocation
shipped via ckn-bw (commit 6fae2fd). /opt/left4me/ is now a
root-owned deploy-artifact root (just src/); .venv and steamcmd
live at /var/lib/left4me/{.venv,steam}.
Touches:
- deploy/files/.../left4me-web.service: PATH + ExecStart
- deploy/files/.../left4me-workshop-refresh.service: WorkingDirectory
(was /opt/left4me, now /opt/left4me/src to match the web unit),
PATH, ExecStart
- scripts/sbin/left4me wrapper: flask path
- deploy/tests/test_example_units.py: PATH + ExecStart assertions
for the web unit; also fix a pre-existing broken assertion that
read "Environment=PATH=..." (the unit has Environment=HOME=...
PATH=... on one line, so "Environment=PATH=" was never present)
- now reads just "PATH=..."
- deploy/README.md: paths
- l4d2host/tests/test_cli.py: LEFT4ME_STEAMCMD fixture path
Design + as-shipped record:
docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md.
The original (narrower) prereq spec at
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
is marked superseded with a pointer to what shipped + why the
scope grew (setuptools writes egg-info to source during PEP 517
build prep).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff2b5c4c5a
commit
434ee20339
8 changed files with 199 additions and 17 deletions
|
|
@ -54,8 +54,12 @@ The deployment uses these on-host paths (FHS-aligned):
|
||||||
- `/etc/sudoers.d/left4me` — sudoers rules letting the `left4me` uid call
|
- `/etc/sudoers.d/left4me` — sudoers rules letting the `left4me` uid call
|
||||||
the privileged helpers non-interactively.
|
the privileged helpers non-interactively.
|
||||||
- `/etc/sysctl.d/99-left4me.conf` — perf-baseline sysctls.
|
- `/etc/sysctl.d/99-left4me.conf` — perf-baseline sysctls.
|
||||||
- `/opt/left4me` — deployed repository contents (via ckn-bw `git_deploy`).
|
- `/opt/left4me/src` — deployed repository contents (via ckn-bw
|
||||||
- `/opt/left4me/.venv` — Python virtual environment for the web app.
|
`git_deploy`). Root-owned; read-only at runtime. `/opt/left4me/`
|
||||||
|
itself is also root-owned and contains only `src/`.
|
||||||
|
- `/var/lib/left4me/.venv` — Python virtual environment for the web app
|
||||||
|
(non-editable install of `l4d2host` + `l4d2web`).
|
||||||
|
- `/var/lib/left4me/steam` — steamcmd install (self-updates).
|
||||||
- `/var/lib/left4me/left4me.db` — SQLite database used by the web app.
|
- `/var/lib/left4me/left4me.db` — SQLite database used by the web app.
|
||||||
- `/var/lib/left4me/installation` — shared L4D2 installation.
|
- `/var/lib/left4me/installation` — shared L4D2 installation.
|
||||||
- `/var/lib/left4me/overlays` — overlay directories. Each overlay lives
|
- `/var/lib/left4me/overlays` — overlay directories. Each overlay lives
|
||||||
|
|
@ -108,7 +112,7 @@ the metadata pipeline):
|
||||||
```sh
|
```sh
|
||||||
sudo -u left4me LEFT4ME_ADMIN_USERNAME=admin \
|
sudo -u left4me LEFT4ME_ADMIN_USERNAME=admin \
|
||||||
LEFT4ME_ADMIN_PASSWORD='change-me' \
|
LEFT4ME_ADMIN_PASSWORD='change-me' \
|
||||||
/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app \
|
/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app \
|
||||||
create-user "$LEFT4ME_ADMIN_USERNAME" --admin
|
create-user "$LEFT4ME_ADMIN_USERNAME" --admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,12 @@ Type=simple
|
||||||
User=left4me
|
User=left4me
|
||||||
Group=left4me
|
Group=left4me
|
||||||
WorkingDirectory=/opt/left4me/src
|
WorkingDirectory=/opt/left4me/src
|
||||||
Environment=HOME=/var/lib/left4me PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
Environment=HOME=/var/lib/left4me PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
EnvironmentFile=/etc/left4me/host.env
|
EnvironmentFile=/etc/left4me/host.env
|
||||||
EnvironmentFile=/etc/left4me/web.env
|
EnvironmentFile=/etc/left4me/web.env
|
||||||
# Placeholder values for --workers / --threads. Live emission interpolates
|
# Placeholder values for --workers / --threads. Live emission interpolates
|
||||||
# from metadata.get('left4me/gunicorn_workers') and gunicorn_threads.
|
# from metadata.get('left4me/gunicorn_workers') and gunicorn_threads.
|
||||||
ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()'
|
ExecStart=/var/lib/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ Wants=left4me-web.service
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=left4me
|
User=left4me
|
||||||
Group=left4me
|
Group=left4me
|
||||||
WorkingDirectory=/opt/left4me
|
WorkingDirectory=/opt/left4me/src
|
||||||
Environment=HOME=/var/lib/left4me
|
Environment=HOME=/var/lib/left4me
|
||||||
Environment=PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
Environment=PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
EnvironmentFile=/etc/left4me/host.env
|
EnvironmentFile=/etc/left4me/host.env
|
||||||
EnvironmentFile=/etc/left4me/web.env
|
EnvironmentFile=/etc/left4me/web.env
|
||||||
ExecStart=/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh
|
ExecStart=/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ def test_web_unit_contains_required_runtime_contract():
|
||||||
assert "User=left4me" in unit
|
assert "User=left4me" in unit
|
||||||
assert "Group=left4me" in unit
|
assert "Group=left4me" in unit
|
||||||
assert "WorkingDirectory=/opt/left4me" in unit
|
assert "WorkingDirectory=/opt/left4me" in unit
|
||||||
assert "Environment=PATH=/opt/left4me/.venv/bin:" in unit
|
assert "PATH=/var/lib/left4me/.venv/bin:" in unit
|
||||||
assert "EnvironmentFile=/etc/left4me/host.env" in unit
|
assert "EnvironmentFile=/etc/left4me/host.env" in unit
|
||||||
assert "EnvironmentFile=/etc/left4me/web.env" in unit
|
assert "EnvironmentFile=/etc/left4me/web.env" in unit
|
||||||
assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in unit
|
assert "ExecStart=/var/lib/left4me/.venv/bin/gunicorn" in unit
|
||||||
assert "--workers 1" in unit
|
assert "--workers 1" in unit
|
||||||
assert "--threads 32" in unit
|
assert "--threads 32" in unit
|
||||||
# NoNewPrivileges must remain unset because sudo (used by the overlay,
|
# NoNewPrivileges must remain unset because sudo (used by the overlay,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,22 @@
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Queued. **Must land before** the deployment-responsibility brainstorm
|
**Superseded 2026-05-15** by what actually shipped — see
|
||||||
resumes (`docs/superpowers/specs/2026-05-15-handoff-deployment-responsibility.md`).
|
`docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md`.
|
||||||
This is the prereq that makes target-side symlinks of deployment
|
The narrow approach proposed here (just flip `/opt/left4me/src` to
|
||||||
artifacts safe.
|
root, switch `pip install -e` → `pip install`) doesn't work as
|
||||||
|
described: `setuptools.build_meta` writes `<pkg>.egg-info/` into the
|
||||||
|
source dir during `get_requires_for_build_wheel`, which fails against
|
||||||
|
a root-owned source. The shipped fix copies source to a writable
|
||||||
|
tempdir before building, and (since that one-shot copy was needed
|
||||||
|
anyway) also relocates `.venv` + `steam` to `/var/lib/left4me/`.
|
||||||
|
|
||||||
|
The original prereq goal — making target-side symlinks of deployment
|
||||||
|
artifacts safe — is still met; the realized shape is just bigger than
|
||||||
|
this doc sketched.
|
||||||
|
|
||||||
|
This doc is kept as the historical record of the originally-proposed
|
||||||
|
approach and why it didn't work.
|
||||||
|
|
||||||
## The task
|
## The task
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Runtime state relocation + non-editable install — design (as shipped)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Shipped 2026-05-15. Supersedes
|
||||||
|
`2026-05-15-handoff-noneditable-install.md` (the narrower prereq spec).
|
||||||
|
The scope expanded during execution after a hidden constraint surfaced;
|
||||||
|
this doc records what actually shipped.
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
Two related changes, landed together:
|
||||||
|
|
||||||
|
1. **`/opt/left4me/` becomes a root-owned deploy-artifact root.** Only
|
||||||
|
`/opt/left4me/src` lives there (eventually `/opt/left4me/scripts/`
|
||||||
|
after the deployment-responsibility reshape too). Both `/opt/left4me`
|
||||||
|
and `/opt/left4me/src` are root:root.
|
||||||
|
2. **Runtime mutable state moved to `/var/lib/left4me/`.** Specifically:
|
||||||
|
- `/var/lib/left4me/.venv` (was `/opt/left4me/.venv`)
|
||||||
|
- `/var/lib/left4me/steam` (was `/opt/left4me/steam`)
|
||||||
|
3. **Production install model is non-editable.** ckn-bw's
|
||||||
|
`left4me_pip_install` action copies the (root-owned) source to a
|
||||||
|
left4me-owned tempdir under `$TMPDIR` and runs
|
||||||
|
`pip install --force-reinstall "$tmpdir/l4d2host" "$tmpdir/l4d2web"`
|
||||||
|
from there. The tempdir is cleaned by a trap on EXIT.
|
||||||
|
|
||||||
|
Local-development install flows are unchanged: developers still use
|
||||||
|
`pip install -e ./l4d2host -e ./l4d2web` via direnv.
|
||||||
|
|
||||||
|
## Why the scope expanded
|
||||||
|
|
||||||
|
The original handoff (`2026-05-15-handoff-noneditable-install.md`)
|
||||||
|
proposed a minimal change: flip `/opt/left4me/src` to root:root and
|
||||||
|
switch `pip install -e` → `pip install` directly. That spec stated
|
||||||
|
"Non-editable install works out of the box; no packaging edits needed."
|
||||||
|
|
||||||
|
That premise turned out to be wrong. `setuptools.build_meta`'s PEP 517
|
||||||
|
`get_requires_for_build_wheel` hook runs `setup.py egg_info` in the
|
||||||
|
source directory by design, and `egg_info` writes `<pkg>.egg-info/`
|
||||||
|
back to the cwd. Against a root-owned source tree, this fails with
|
||||||
|
`Permission denied`. `python -m build` was tried as an alternative —
|
||||||
|
its build isolation only sandboxes build *dependencies*, not the
|
||||||
|
source, so it hits the same failure.
|
||||||
|
|
||||||
|
The fix that actually works is to copy the source to a writable
|
||||||
|
location and build from the copy. Once that one-shot copy is in the
|
||||||
|
pip_install action, the original narrow scope becomes inconsistent:
|
||||||
|
the source is root-owned, the venv is left4me-owned, both live under
|
||||||
|
`/opt/left4me/`. Moving runtime mutable state out of `/opt/left4me/`
|
||||||
|
and into `/var/lib/left4me/` resolves the inconsistency by aligning
|
||||||
|
with FHS conventions: `/opt/<app>` = read-only deploy artifacts,
|
||||||
|
`/var/lib/<app>` = mutable runtime state.
|
||||||
|
|
||||||
|
The operator opted for the larger reshape ("do the best long-term
|
||||||
|
solution now") rather than landing the narrow change and queuing the
|
||||||
|
relocation separately.
|
||||||
|
|
||||||
|
## What changed concretely
|
||||||
|
|
||||||
|
### ckn-bw side (`bundles/left4me/`)
|
||||||
|
|
||||||
|
- `items.py`
|
||||||
|
- `directory:/opt/left4me` → `root:root 0755` (was `left4me:left4me`)
|
||||||
|
- `directory:/opt/left4me/src` → `root:root` (was `left4me:left4me`)
|
||||||
|
- `directory:/opt/left4me/steam` **removed**
|
||||||
|
- `directory:/var/lib/left4me/steam` **added** (`left4me:left4me`)
|
||||||
|
- `action:left4me_chown_src` **deleted** (was the every-apply
|
||||||
|
chown-to-left4me self-heal; no longer needed)
|
||||||
|
- `action:left4me_install_steamcmd` — paths flipped to
|
||||||
|
`/var/lib/left4me/steam`
|
||||||
|
- `action:left4me_create_venv`, `left4me_pip_upgrade`,
|
||||||
|
`left4me_alembic_upgrade`, `left4me_seed_overlays` — all venv
|
||||||
|
paths changed from `/opt/left4me/.venv` to `/var/lib/left4me/.venv`
|
||||||
|
- `action:left4me_pip_install` — completely rewritten to use the
|
||||||
|
cp-to-tempdir + pip install approach, marked `triggered: True` so
|
||||||
|
it only fires on actual code changes (the cp + wheel build is too
|
||||||
|
heavy to run on every apply)
|
||||||
|
- `git_deploy:/opt/left4me/src` triggers list — added
|
||||||
|
`action:left4me_pip_install` (the new wiring); kept
|
||||||
|
`alembic_upgrade` as belt-and-braces and `install_left4me_scripts`
|
||||||
|
- `metadata.py`
|
||||||
|
- `left4me-web.service`: `Environment=PATH=` and `ExecStart=` use
|
||||||
|
`/var/lib/left4me/.venv`
|
||||||
|
- `left4me-workshop-refresh.service`: same
|
||||||
|
- `left4me-server@.service` `BindReadOnlyPaths` entry for steamcmd
|
||||||
|
→ `/var/lib/left4me/steam`
|
||||||
|
- `files/etc/left4me/host.env.mako`: `LEFT4ME_STEAMCMD` →
|
||||||
|
`/var/lib/left4me/steam/steamcmd.sh`
|
||||||
|
- `README.md`: updated description
|
||||||
|
|
||||||
|
### left4me side
|
||||||
|
|
||||||
|
- `deploy/files/usr/local/lib/systemd/system/left4me-web.service` —
|
||||||
|
reference unit: `PATH=` + `ExecStart=` use `/var/lib/left4me/.venv`
|
||||||
|
- `deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service`
|
||||||
|
— reference unit: `WorkingDirectory=/opt/left4me/src` (was `/opt/left4me`),
|
||||||
|
`PATH=` + `ExecStart=` use `/var/lib/left4me/.venv`
|
||||||
|
- `scripts/sbin/left4me` wrapper — flask path
|
||||||
|
- `deploy/tests/test_example_units.py` — PATH + ExecStart assertions
|
||||||
|
updated; the assertion previously read `"Environment=PATH=..."` which
|
||||||
|
was already broken (the unit has `Environment=HOME=... PATH=...` on
|
||||||
|
one line), now reads just `"PATH=..."`
|
||||||
|
- `deploy/README.md` — paths described
|
||||||
|
- `l4d2host/tests/test_cli.py` — `LEFT4ME_STEAMCMD` fixture path
|
||||||
|
|
||||||
|
### Host transition (one-shot, performed manually during the deploy)
|
||||||
|
|
||||||
|
1. `systemctl stop left4me-web.service`
|
||||||
|
2. `mv /opt/left4me/steam /var/lib/left4me/steam` (atomic same-fs
|
||||||
|
rename; running gameserver `BindReadOnlyPaths` bindings keep
|
||||||
|
working because they reference inodes, not paths)
|
||||||
|
3. `rm -rf /opt/left4me/.venv /opt/left4me/wheels`
|
||||||
|
4. `bw apply -s svc_systemd:left4me-web.service ovh.left4me` —
|
||||||
|
creates new venv at `/var/lib/left4me/.venv` and runs pip_upgrade.
|
||||||
|
`-s` to skip the web service item so bw doesn't try to start it
|
||||||
|
before pip_install has populated the new venv.
|
||||||
|
5. Manual `pip_install` (the same command the bundle's
|
||||||
|
`left4me_pip_install` action runs) to install l4d2host + l4d2web
|
||||||
|
non-editably into the new venv.
|
||||||
|
6. Manual `alembic upgrade head` + `systemctl start left4me-web.service`.
|
||||||
|
7. Second `bw apply ovh.left4me` to confirm idempotent.
|
||||||
|
|
||||||
|
Gameservers (`left4me-server@1`, `@2`) stayed up throughout —
|
||||||
|
they don't link to the Python venv and their bind mounts survived
|
||||||
|
the steam dir rename via inode-level binding.
|
||||||
|
|
||||||
|
## Verification (six checks, all green)
|
||||||
|
|
||||||
|
1. `stat -c '%U:%G %a %n' /opt/left4me /opt/left4me/src
|
||||||
|
/var/lib/left4me/.venv /var/lib/left4me/steam` → `/opt/left4me`
|
||||||
|
and `/opt/left4me/src` both `root:root 755`; `.venv` and `steam`
|
||||||
|
`left4me:left4me`.
|
||||||
|
2. `pip show l4d2host l4d2web` → `Location:` is
|
||||||
|
`/var/lib/left4me/.venv/lib/python3.13/site-packages`, no
|
||||||
|
`Editable project location:` line.
|
||||||
|
3. `systemctl is-active left4me-web.service` → `active`.
|
||||||
|
4. `alembic current` → `0012_command_history (head)`.
|
||||||
|
5. Gameserver fresh-restart **deferred** — running instances are
|
||||||
|
unaffected (inode-level binds survive the rename); a fresh `bw
|
||||||
|
apply` confirms the new unit content has the new bind paths.
|
||||||
|
Will validate on the next operator-initiated server restart.
|
||||||
|
6. Second `bw apply ovh.left4me` → 0 fixed, 0 failed. Idempotent.
|
||||||
|
|
||||||
|
## What this does NOT change
|
||||||
|
|
||||||
|
- The deployment-responsibility brainstorm
|
||||||
|
(`2026-05-15-handoff-deployment-responsibility.md`) — still queued.
|
||||||
|
This prereq just makes target-side symlinks into
|
||||||
|
`/opt/left4me/src/deploy/files/...` safe by construction
|
||||||
|
(left4me cannot rewrite its own hardening profile).
|
||||||
|
- Sudoers content (still in `deploy/files/etc/sudoers.d/left4me`
|
||||||
|
+ the verbatim mirror in ckn-bw; consolidation queued).
|
||||||
|
- `scripts/{libexec,sbin}/` location in left4me — still under
|
||||||
|
the repo root; the deployment-responsibility brainstorm decides
|
||||||
|
whether to move them into `deploy/scripts/`.
|
||||||
|
- Hardening drop-ins — still inline in ckn-bw's `systemd/units`
|
||||||
|
reactor; whether to move them to `deploy/files/...` is also
|
||||||
|
the deployment-responsibility brainstorm's call.
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- Original (now-superseded) handoff:
|
||||||
|
`docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md`
|
||||||
|
- Deployment-responsibility brainstorm handoff:
|
||||||
|
`docs/superpowers/specs/2026-05-15-handoff-deployment-responsibility.md`
|
||||||
|
- ckn-bw bundle: `~/Projekte/ckn-bw/bundles/left4me/`
|
||||||
|
|
@ -25,12 +25,12 @@ def test_install_uses_left4me_steamcmd_env_var(monkeypatch) -> None:
|
||||||
del kwargs
|
del kwargs
|
||||||
|
|
||||||
monkeypatch.setattr("l4d2host.cli.SteamInstaller", FakeInstaller)
|
monkeypatch.setattr("l4d2host.cli.SteamInstaller", FakeInstaller)
|
||||||
monkeypatch.setenv("LEFT4ME_STEAMCMD", "/opt/left4me/steam/steamcmd.sh")
|
monkeypatch.setenv("LEFT4ME_STEAMCMD", "/var/lib/left4me/steam/steamcmd.sh")
|
||||||
|
|
||||||
result = CliRunner().invoke(app, ["install"])
|
result = CliRunner().invoke(app, ["install"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert captured["steamcmd"] == "/opt/left4me/steam/steamcmd.sh"
|
assert captured["steamcmd"] == "/var/lib/left4me/steam/steamcmd.sh"
|
||||||
|
|
||||||
|
|
||||||
def test_install_defaults_to_bare_steamcmd_when_env_unset(monkeypatch) -> None:
|
def test_install_defaults_to_bare_steamcmd_when_env_unset(monkeypatch) -> None:
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@ exec sudo -u left4me sh -c '
|
||||||
set +a
|
set +a
|
||||||
export JOB_WORKER_ENABLED=false
|
export JOB_WORKER_ENABLED=false
|
||||||
export PYTHONPATH=/opt/left4me/src
|
export PYTHONPATH=/opt/left4me/src
|
||||||
exec /opt/left4me/.venv/bin/flask --app l4d2web.app:create_app "$@"
|
exec /var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app "$@"
|
||||||
' sh "$@"
|
' sh "$@"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue