diff --git a/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md b/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md new file mode 100644 index 0000000..39bdbce --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md @@ -0,0 +1,273 @@ +# Handoff — non-editable install + root-owned `/opt/left4me/src` + +## 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. + +## The task + +Change ckn-bw's `bundles/left4me/` so that: + +1. The production install uses **non-editable** pip installs + (`pip install /opt/left4me/src/l4d2host /opt/left4me/src/l4d2web`), + not `pip install -e …`. +2. `/opt/left4me/src/` is **owned by root:root**, not left4me:left4me. +3. The `left4me_chown_src` action and the `/opt/left4me/src` directory + item's `owner`/`group` flip accordingly. +4. The pip-install action moves from "runs every apply" to "triggered + by `git_deploy:/opt/left4me/src`" — non-editable installs always + rebuild a wheel, so running unconditionally is wasteful. + +Local-development install flows (direnv + `pip install -e ./l4d2host +-e ./l4d2web`) are **unchanged**. Editable installs remain correct on +developer machines; only the production install model on the host +changes. + +## Why + +Two reasons, listed in priority order. + +**Security.** The deployment-responsibility brainstorm wants to make +`left4me/deploy/files/` the live source of truth for systemd units, +drop-ins, sudoers, sysctl, and helpers, delivered by ckn-bw via +target-side symlinks (`/etc/foo` → `/opt/left4me/src/deploy/files/...`). +If the symlink target sits inside a left4me-writable directory, the +service can rewrite its own hardening drop-in and escape the sandbox +on next restart. Making `/opt/left4me/src/` root-owned closes that +hole at the filesystem layer, before symlinks ever come into the +picture. Defense-in-depth that costs us nothing the production +workflow actually used. + +**Operational honesty.** The only reason `/opt/left4me/src/` is +user-owned today is that `pip install -e` writes `.egg-info` into the +source tree. No production workflow ever edits files under +`/opt/left4me/src/` directly — code updates always come through +`git_deploy` + `pip_install`. Editable mode buys nothing on the host; +non-editable matches what the deploy actually does (rebuild + reinstall +wheel from new source). + +## What changes — concretely + +All edits are in `~/Projekte/ckn-bw/bundles/left4me/`. + +### `items.py` + +**Directory items** (`items.py:7-42`) — flip `/opt/left4me/src` to root: + +```python +directories = { + '/opt/left4me': { + 'owner': 'root', + 'group': 'root', + }, + '/opt/left4me/src': { + 'owner': 'root', + 'group': 'root', + # Was left4me:left4me before the non-editable install switch; + # production now installs wheels, so the source tree is read-only + # at runtime. Keeps left4me from being able to rewrite its own + # hardening drop-ins / unit files (see deployment-responsibility + # handoff for the full argument). + }, + # /var/lib/left4me/* and /opt/left4me/{steam,.venv} stay left4me:left4me. + ... +} +``` + +**`left4me_pip_install` action** (`items.py:247-263`) — drop `-e`, +become triggered: + +```python +actions['left4me_pip_install'] = { + # Non-editable install: builds wheels from the checkout, installs + # into the venv's site-packages. Source tree is no longer mutated by + # pip, so /opt/left4me/src/ stays root:root with read-only access for + # left4me at runtime. + 'command': 'sudo -u left4me /opt/left4me/.venv/bin/pip install /opt/left4me/src/l4d2host /opt/left4me/src/l4d2web', + 'triggered': True, # was: ran every apply + 'cascade_skip': False, + 'needs': [ + 'git_deploy:/opt/left4me/src', + 'action:left4me_create_venv', + # action:left4me_chown_src removed (deleted below). + ], + 'triggers': [ + 'action:left4me_alembic_upgrade', + ], +} +``` + +**`left4me_chown_src` action** (`items.py:207-219`) — **delete**. +The action exists to repair file ownership after each `git_deploy` +extracts the tarball as root and we needed it as left4me. With the new +model, root is the target ownership, which is also what `git_deploy` +already produces. Action becomes a no-op; remove it. + +**`git_deploy` triggers** (`items.py:157-183`) — ensure +`action:left4me_pip_install` is in `triggers`. Currently triggers +`left4me_alembic_upgrade` and `install_left4me_scripts`; add +`left4me_pip_install` so that a fresh checkout always rebuilds the +wheel and reinstalls. + +### `metadata.py` + +No changes. The `systemd/units` reactor's `WorkingDirectory` and +timer `working_dir` still point at `/opt/left4me/src` — that path is +still readable as left4me regardless of ownership (it's +world-readable by default after `git_deploy` extracts as root). + +### `README.md` + +Line 48 mentions `pip install -e`. Update to reflect non-editable +production install and add a one-line note that local dev still uses +`-e`. Two lines of edits. + +### `l4d2web.egg-info/`, `l4d2host.egg-info/` on the live host + +These directories exist today inside `/opt/left4me/src/l4d2{host,web}/` +as a side-effect of editable installs. After the switch they become +stale (pip installs a fresh wheel into the venv; the in-source egg-info +is unused). Clean-up options: + +- **Leave them**: harmless, ignored by Python. Eventually removed by + whoever next refactors the source layout. +- **One-shot remove on the live host**: `sudo find /opt/left4me/src + -name "*.egg-info" -type d -exec rm -rf {} +`. Cosmetic; do whatever. + +Either's fine. Document the choice in the commit message. + +## What does NOT change + +- **`l4d2host/` and `l4d2web/` `pyproject.toml`** — both already declare + `[build-system] requires = ["setuptools>=68", "wheel"]` and use the + flat `package-dir = {l4d2host = "."}` layout. Non-editable install + works out of the box; no packaging edits needed. +- **`alembic.ini` + migrations** — alembic reads + `/opt/left4me/src/l4d2web/alembic/versions/*.py` at runtime. Root + ownership + world-readable means left4me can still read; no change. +- **`examples/script-overlays/`** — same; read-only access by left4me + at seed time. +- **`/opt/left4me/.venv/`** — stays left4me:left4me (pip writes here + during the install action, run as left4me via sudo). +- **`/opt/left4me/steam/`** — stays left4me:left4me (steamcmd + self-updates). +- **`/var/lib/left4me/`** and all subdirs — stays left4me:left4me + (application runtime state). +- **Local-dev install instructions** in `README.md`, `AGENTS.md`, + `l4d2web/README.md` — keep `-e`. Developer machines need editable. +- **`install_left4me_scripts` action** — already copies from src as + root, target paths under `/usr/local/{libexec,sbin}/`. Source can be + root-owned now (no change in behavior). +- **Hardening composition + every deployed unit / drop-in / sudoers / + sysctl file** — out of scope for this change. Those move in the + deployment-responsibility brainstorm, after this lands. + +## Verification + +Run on left4.me (the production host) after `bw apply`: + +1. **Source ownership**: + ``` + stat -c '%U:%G %a %n' /opt/left4me/src /opt/left4me/.venv /opt/left4me/steam /var/lib/left4me + ``` + Expected: `/opt/left4me/src` → `root:root`; `.venv` and `steam` and + `/var/lib/left4me` → `left4me:left4me`. + +2. **Wheel installed, not editable**: + ``` + sudo -u left4me /opt/left4me/.venv/bin/pip show l4d2web l4d2host + ``` + Expected: `Location:` points inside + `/opt/left4me/.venv/lib/python*/site-packages/`, NOT inside + `/opt/left4me/src/`. (Editable installs report the source path as + `Location:`; non-editable reports site-packages.) + +3. **App runs**: + ``` + systemctl status left4me-web.service + ``` + Active, recent logs clean. + +4. **Alembic can still read migrations**: + ``` + sudo -u left4me sh -c 'cd /opt/left4me/src/l4d2web && /opt/left4me/.venv/bin/alembic current' + ``` + Returns the current head without errors. + +5. **A gameserver starts**: + ``` + sudo /usr/local/libexec/left4me/left4me-systemctl start left4me-server@test + journalctl -u left4me-server@test -n 50 + ``` + srcds_run starts cleanly. Stop it after verification. + +6. **Idempotent `bw apply`**: + Run `bw apply left4.me` a second time. Should report zero changes — + no chown action drifting back, no pip install re-firing. + +## Out of scope + +- **The deployment-responsibility reshape itself.** That brainstorm + resumes after this prereq lands on left4.me. Do not touch + `deploy/files/`, hardening drop-ins, sudoers location, etc. — those + are the *next* session's work. +- **Removing the `bundles/left4me/files/etc/{sudoers.d,sysctl.d}/` + verbatim mirrors.** Same; that's the deployment-responsibility + session. +- **Moving `scripts/{libexec,sbin}/` into `deploy/scripts/`.** Same. +- **Reviewing whether the editable install pattern should change for + developer machines.** It should not — local dev wants editable for + fast iteration; only the host install model changes. + +## Pointers + +- **Deployment-responsibility brainstorm handoff** (the parent + context): `docs/superpowers/specs/2026-05-15-handoff-deployment-responsibility.md` +- **ckn-bw left4me bundle**: + `~/Projekte/ckn-bw/bundles/left4me/` — + - `items.py:7-42` (directories) + - `items.py:157-183` (git_deploy) + - `items.py:207-219` (left4me_chown_src — delete) + - `items.py:247-263` (left4me_pip_install) + - `README.md:48` (docs update) +- **pyproject.toml layouts**: + `l4d2host/pyproject.toml`, `l4d2web/pyproject.toml`. Flat + `package-dir = { = "."}` layout. Non-editable wheel build works + with this layout without further changes. +- **Hardening test plan** (motivates the security argument): + `docs/superpowers/specs/2026-05-15-hardening-test-plan.md` +- **Original deployment design** (the shape we're working toward): + `docs/superpowers/specs/2026-05-06-left4me-deployment-design.md` + +## Commit messages (suggested) + +ckn-bw side (the actual change): + +``` +refactor(left4me): non-editable install + root-owned /opt/left4me/src + +Drop `pip install -e` for the production install; switch to wheel +install (`pip install /opt/left4me/src/l4d2{host,web}`). Source tree no +longer needs to be writable by left4me, so flip /opt/left4me/src to +root:root and delete the left4me_chown_src action. + +Prereq for the deployment-responsibility reshape: makes target-side +symlinks from /etc/... into /opt/left4me/src/deploy/files/... safe by +construction (left4me cannot rewrite its own hardening profile). + +Verified on left4.me: bw apply idempotent; pip show reports +site-packages location; web + gameserver units run clean. +``` + +left4me side (this handoff doc): + +``` +spec(noneditable-install): handoff for the install refactor prereq + +Self-contained spec for the next agent to land the editable→ +non-editable install switch and the root-ownership flip on +/opt/left4me/src. Prereq for the deployment-responsibility brainstorm. +```