From 434ee203395b03398a43d869681e9f9dc912c438 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 15 May 2026 17:56:32 +0200 Subject: [PATCH] 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) --- deploy/README.md | 10 +- .../lib/systemd/system/left4me-web.service | 4 +- .../system/left4me-workshop-refresh.service | 6 +- deploy/tests/test_example_units.py | 4 +- .../2026-05-15-handoff-noneditable-install.md | 20 ++- ...6-05-15-runtime-state-relocation-design.md | 166 ++++++++++++++++++ l4d2host/tests/test_cli.py | 4 +- scripts/sbin/left4me | 2 +- 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md diff --git a/deploy/README.md b/deploy/README.md index deb04ad..23ef151 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -54,8 +54,12 @@ The deployment uses these on-host paths (FHS-aligned): - `/etc/sudoers.d/left4me` — sudoers rules letting the `left4me` uid call the privileged helpers non-interactively. - `/etc/sysctl.d/99-left4me.conf` — perf-baseline sysctls. -- `/opt/left4me` — deployed repository contents (via ckn-bw `git_deploy`). -- `/opt/left4me/.venv` — Python virtual environment for the web app. +- `/opt/left4me/src` — deployed repository contents (via ckn-bw + `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/installation` — shared L4D2 installation. - `/var/lib/left4me/overlays` — overlay directories. Each overlay lives @@ -108,7 +112,7 @@ the metadata pipeline): ```sh sudo -u left4me LEFT4ME_ADMIN_USERNAME=admin \ 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 ``` diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-web.service b/deploy/files/usr/local/lib/systemd/system/left4me-web.service index ef034b0..6d8d013 100644 --- a/deploy/files/usr/local/lib/systemd/system/left4me-web.service +++ b/deploy/files/usr/local/lib/systemd/system/left4me-web.service @@ -30,12 +30,12 @@ Type=simple User=left4me Group=left4me 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/web.env # Placeholder values for --workers / --threads. Live emission interpolates # 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 RestartSec=3 diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service b/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service index bc82367..8e194a3 100644 --- a/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service +++ b/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service @@ -7,9 +7,9 @@ Wants=left4me-web.service Type=oneshot User=left4me Group=left4me -WorkingDirectory=/opt/left4me +WorkingDirectory=/opt/left4me/src 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/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 diff --git a/deploy/tests/test_example_units.py b/deploy/tests/test_example_units.py index 71f06bc..4a1e606 100644 --- a/deploy/tests/test_example_units.py +++ b/deploy/tests/test_example_units.py @@ -32,10 +32,10 @@ def test_web_unit_contains_required_runtime_contract(): assert "User=left4me" in unit assert "Group=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/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 "--threads 32" in unit # NoNewPrivileges must remain unset because sudo (used by the overlay, diff --git a/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md b/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md index 39bdbce..af0761b 100644 --- a/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md +++ b/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md @@ -2,10 +2,22 @@ ## Status -Queued. **Must land before** the deployment-responsibility brainstorm -resumes (`docs/superpowers/specs/2026-05-15-handoff-deployment-responsibility.md`). -This is the prereq that makes target-side symlinks of deployment -artifacts safe. +**Superseded 2026-05-15** by what actually shipped — see +`docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md`. +The narrow approach proposed here (just flip `/opt/left4me/src` to +root, switch `pip install -e` → `pip install`) doesn't work as +described: `setuptools.build_meta` writes `.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 diff --git a/docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md b/docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md new file mode 100644 index 0000000..37bbee9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md @@ -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 `.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/` = read-only deploy artifacts, +`/var/lib/` = 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/` diff --git a/l4d2host/tests/test_cli.py b/l4d2host/tests/test_cli.py index a57ce29..1f8cf9f 100644 --- a/l4d2host/tests/test_cli.py +++ b/l4d2host/tests/test_cli.py @@ -25,12 +25,12 @@ def test_install_uses_left4me_steamcmd_env_var(monkeypatch) -> None: del kwargs 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"]) 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: diff --git a/scripts/sbin/left4me b/scripts/sbin/left4me index 7a106d3..c9e9a55 100755 --- a/scripts/sbin/left4me +++ b/scripts/sbin/left4me @@ -13,5 +13,5 @@ exec sudo -u left4me sh -c ' set +a export JOB_WORKER_ENABLED=false 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 "$@"