left4me/docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md
mwiegand 2834ad4911
deploy: move scripts/{libexec,sbin}/ into deploy/scripts/
Layout consistency: everything ckn-bw deploys to the host now lives
under deploy/. ckn-bw's install_left4me_scripts copy-action goes away
in lockstep with this commit and is replaced by target-side symlinks.

Also updates all path references in docs, tests (conftest.py parents[]
depth, test_overlay_helper.py HELPER_SOURCE), and deploy/README.md.

Part of 2026-05-15-deployment-responsibility-design.md migration step 4.
2026-05-15 19:38:42 +02:00

7.8 KiB

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 -epip 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/left4meroot:root 0755 (was left4me:left4me)
    • directory:/opt/left4me/srcroot: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
  • deploy/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.pyLEFT4ME_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 l4d2webLocation: is /var/lib/left4me/.venv/lib/python3.13/site-packages, no Editable project location: line.
  3. systemctl is-active left4me-web.serviceactive.
  4. alembic current0012_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/