Compare commits
6 commits
6cf4517a88
...
949f1bae78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
949f1bae78 | ||
|
|
672fd9660b | ||
|
|
ddf97b3a05 | ||
|
|
c446f6c8eb | ||
|
|
434ee20339 | ||
|
|
ff2b5c4c5a |
12 changed files with 1975 additions and 18 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,8 @@ net.core.default_qdisc = fq_codel
|
||||||
# backups, package fetches, web-app responses) so a long flow does not push
|
# backups, package fetches, web-app responses) so a long flow does not push
|
||||||
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
|
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
|
||||||
net.ipv4.tcp_congestion_control = bbr
|
net.ipv4.tcp_congestion_control = bbr
|
||||||
|
|
||||||
|
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces with
|
||||||
|
# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit.
|
||||||
|
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md.
|
||||||
|
kernel.yama.ptrace_scope = 2
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -194,6 +194,7 @@ def test_sysctl_conf_present_with_perf_settings():
|
||||||
"net.ipv4.udp_wmem_min = 16384",
|
"net.ipv4.udp_wmem_min = 16384",
|
||||||
"net.core.default_qdisc = fq_codel",
|
"net.core.default_qdisc = fq_codel",
|
||||||
"net.ipv4.tcp_congestion_control = bbr",
|
"net.ipv4.tcp_congestion_control = bbr",
|
||||||
|
"kernel.yama.ptrace_scope = 2",
|
||||||
):
|
):
|
||||||
assert line in text, f"missing {line!r} in 99-left4me.conf"
|
assert line in text, f"missing {line!r} in 99-left4me.conf"
|
||||||
|
|
||||||
|
|
|
||||||
1135
docs/superpowers/plans/2026-05-15-deployment-responsibility.md
Normal file
1135
docs/superpowers/plans/2026-05-15-deployment-responsibility.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,355 @@
|
||||||
|
# Deployment responsibility — design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Trace: `2026-05-06-left4me-deployment-design.md` established the original
|
||||||
|
model — left4me's `deploy/files/` mirrors target filesystem paths;
|
||||||
|
ckn-bw integrates. The hardening refactor
|
||||||
|
(`2026-05-15-hardening-refactor-design.md`) landed *inline-in-reactor*
|
||||||
|
as an explicit tradeoff and queued the responsibility question for this
|
||||||
|
brainstorm (handoff: `2026-05-15-handoff-deployment-responsibility.md`).
|
||||||
|
The runtime-state relocation
|
||||||
|
(`2026-05-15-runtime-state-relocation-design.md`) made
|
||||||
|
`/opt/left4me/src` root-owned, which is the prerequisite that makes
|
||||||
|
target-side symlinks into the checkout safe — left4me cannot rewrite
|
||||||
|
its own deployment artifacts at runtime.
|
||||||
|
|
||||||
|
This design picks a narrow, conservative line. Application-shape
|
||||||
|
artifacts that are static across hosts move to left4me's `deploy/`
|
||||||
|
tree and are delivered to the target via **target-side symlinks**.
|
||||||
|
Per-host shape (CPU pinning, gunicorn workers, env file values) stays
|
||||||
|
bw-managed. The base systemd unit bodies stay bw-managed too — they
|
||||||
|
encode per-host values (workers, threads, CPU set) that are awkward to
|
||||||
|
parameterize cleanly, and ckn-bw is already the right place for that
|
||||||
|
computation.
|
||||||
|
|
||||||
|
The wedge between "moves" and "stays" is **threat model knowledge vs.
|
||||||
|
host shape**. The hardening profile is the security knowledge of the
|
||||||
|
application; the base unit body is the operational shape of the host.
|
||||||
|
Different repos.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Moves to left4me/deploy/, delivered via target-side symlinks
|
||||||
|
|
||||||
|
| Artifact | Source path | Symlink target |
|
||||||
|
|---|---|---|
|
||||||
|
| Hardening drop-in for `left4me-web` | `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` (NEW) | `/etc/systemd/system/left4me-web.service.d/10-hardening.conf` |
|
||||||
|
| Hardening drop-in for `left4me-server@` | `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` (NEW) | same pattern |
|
||||||
|
| Sudoers | `deploy/files/etc/sudoers.d/left4me` (exists) | `/etc/sudoers.d/left4me` |
|
||||||
|
| Sysctl drop-in (absorbs `ptrace_scope`) | `deploy/files/etc/sysctl.d/99-left4me.conf` (exists; one line added) | `/etc/sysctl.d/99-left4me.conf` |
|
||||||
|
| Privileged helpers (`libexec/`) | `deploy/scripts/libexec/*` (relocated from `scripts/libexec/`) | `/usr/local/libexec/left4me/<name>` |
|
||||||
|
| Privileged helpers (`sbin/`) | `deploy/scripts/sbin/*` (relocated from `scripts/sbin/`) | `/usr/local/sbin/<name>` |
|
||||||
|
|
||||||
|
All symlinks are created by bw `symlinks{}` items in
|
||||||
|
`bundles/left4me/items.py`. `git_deploy:/opt/left4me/src` triggers
|
||||||
|
`systemctl daemon-reload` (for unit drop-ins) and `sysctl --system`
|
||||||
|
(for sysctl) so changes to the symlink-target content propagate even
|
||||||
|
though the symlink path itself doesn't change.
|
||||||
|
|
||||||
|
### Stays bw-managed
|
||||||
|
|
||||||
|
- **Base unit bodies** (`left4me-web.service`, `left4me-server@.service`):
|
||||||
|
emitted by the `systemd/units` reactor in
|
||||||
|
`bundles/left4me/metadata.py`. These encode per-host values
|
||||||
|
(gunicorn workers/threads, CPU pinning, instance bind paths). Pulling
|
||||||
|
them into left4me would require either templating or env-var
|
||||||
|
parameterization that doesn't cleanly cover everything (systemd
|
||||||
|
doesn't substitute env vars in non-Exec directives like
|
||||||
|
`SocketBindAllow=`).
|
||||||
|
- **Slice units** (`l4d2-game.slice`, `l4d2-build.slice`) and cpuset
|
||||||
|
drop-ins (`system.slice.d/99-left4me-cpuset.conf`,
|
||||||
|
`user.slice.d/99-left4me-cpuset.conf`): all encode per-host CPU
|
||||||
|
pinning. Reactor stays.
|
||||||
|
- **`host.env.mako`, `web.env.mako`**: per-host secret + scalar
|
||||||
|
templating. Stays.
|
||||||
|
- **`nginx/vhosts`, `nftables/input`, `nftables/output`**: bundle
|
||||||
|
abstractions (letsencrypt auto-population, set-merge) add real value
|
||||||
|
over raw files.
|
||||||
|
- **`systemd-timers/left4me-workshop-refresh`**: same — bundle
|
||||||
|
synthesizes the `.timer` + `.service` from the metadata dict.
|
||||||
|
- **Action chains**: `git_deploy`, `pip_install`, `alembic_upgrade`,
|
||||||
|
`seed_overlays`, `create_venv`, `pip_upgrade`, `install_steamcmd`.
|
||||||
|
Stays.
|
||||||
|
- **`directory`, `user`, `group`** items: must exist before
|
||||||
|
`git_deploy` runs.
|
||||||
|
- **`apt/packages`, `backup/paths`** defaults. Stays.
|
||||||
|
|
||||||
|
### Stays in left4me as reference fixtures (no change)
|
||||||
|
|
||||||
|
`deploy/files/usr/local/lib/systemd/system/*.{service,slice}` —
|
||||||
|
reference units matched against the live form by
|
||||||
|
`deploy/tests/test_deploy_artifacts.py`. Base units stay bw-emitted,
|
||||||
|
so reference-vs-live assertion stays valid. Reference units should
|
||||||
|
**not** include hardening directives once the drop-in extraction
|
||||||
|
lands; the live form's hardening lives in the drop-in, not the base
|
||||||
|
unit.
|
||||||
|
|
||||||
|
## Repo layout (left4me)
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy/
|
||||||
|
files/
|
||||||
|
etc/sudoers.d/left4me
|
||||||
|
etc/sysctl.d/99-left4me.conf
|
||||||
|
etc/systemd/system/left4me-web.service.d/10-hardening.conf # NEW
|
||||||
|
etc/systemd/system/left4me-server@.service.d/10-hardening.conf # NEW
|
||||||
|
etc/left4me/sandbox-resolv.conf # unchanged
|
||||||
|
usr/local/lib/systemd/system/*.{service,slice} # reference (unchanged shape)
|
||||||
|
scripts/ # moves in from scripts/
|
||||||
|
libexec/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox}
|
||||||
|
sbin/<wrappers>
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mechanism: target-side symlinks
|
||||||
|
|
||||||
|
bw `symlinks{}` item type. One entry per artifact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
symlinks = {
|
||||||
|
'/etc/sudoers.d/left4me': {
|
||||||
|
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
|
||||||
|
'owner': 'root',
|
||||||
|
'group': 'root',
|
||||||
|
'needs': ['git_deploy:/opt/left4me/src'],
|
||||||
|
},
|
||||||
|
'/etc/sysctl.d/99-left4me.conf': {
|
||||||
|
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
|
||||||
|
'owner': 'root',
|
||||||
|
'group': 'root',
|
||||||
|
'needs': ['git_deploy:/opt/left4me/src'],
|
||||||
|
'triggers': ['action:left4me_sysctl_reload'],
|
||||||
|
},
|
||||||
|
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
|
||||||
|
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
|
||||||
|
'needs': [
|
||||||
|
'directory:/etc/systemd/system/left4me-web.service.d',
|
||||||
|
'git_deploy:/opt/left4me/src',
|
||||||
|
],
|
||||||
|
'triggers': ['action:systemd_daemon_reload'],
|
||||||
|
},
|
||||||
|
# …same for left4me-server@.service.d/10-hardening.conf
|
||||||
|
# …same for each script in /usr/local/{libexec/left4me,sbin}/
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop-in directories (`*.service.d/`) need explicit `directory:` items
|
||||||
|
in `items.py` (the bw systemd bundle does not create them
|
||||||
|
automatically for symlink-only drop-ins). Mode `0755`, owner
|
||||||
|
`root:root`.
|
||||||
|
|
||||||
|
bw fires the symlink's `triggers:` when **the symlink itself
|
||||||
|
changes** (path/target update). It does *not* fire when the symlink's
|
||||||
|
*target content* changes — that's still a `git_deploy:` event. So
|
||||||
|
both wirings are needed: every symlink declares
|
||||||
|
`needs: ['git_deploy:/opt/left4me/src']`, and ckn-bw declares
|
||||||
|
`triggered_by: [git_deploy:/opt/left4me/src]` actions for the global
|
||||||
|
reloads (`daemon-reload`, `sysctl --system`).
|
||||||
|
|
||||||
|
## Per-artifact details
|
||||||
|
|
||||||
|
### Hardening drop-ins
|
||||||
|
|
||||||
|
Extract from `HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB`
|
||||||
|
Python dicts in `bundles/left4me/metadata.py` into static `.conf`
|
||||||
|
files:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
|
||||||
|
[Service]
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
…
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-directive comments documenting *why* each directive is set the
|
||||||
|
way it is (sudo-incompatibility carve-outs for web; i386 amendment
|
||||||
|
and `PrivatePIDs` rationale for server@) should live inline as `#`
|
||||||
|
comments. Today those rationale comments live in the Python source;
|
||||||
|
they need to come along.
|
||||||
|
|
||||||
|
After extraction, the reactor's emitted unit bodies drop the
|
||||||
|
`**HARDENING_WEB` / `**HARDENING_SERVER` splat. The reactor still
|
||||||
|
emits the base unit and is responsible for everything except the
|
||||||
|
hardening profile.
|
||||||
|
|
||||||
|
### Sudoers
|
||||||
|
|
||||||
|
Today: identical content in
|
||||||
|
`left4me/deploy/files/etc/sudoers.d/left4me` and
|
||||||
|
`ckn-bw/bundles/left4me/files/etc/sudoers.d/left4me`. The bw item
|
||||||
|
sources from the ckn-bw copy.
|
||||||
|
|
||||||
|
After: bw `symlinks{}` item; delete the ckn-bw copy and the bw
|
||||||
|
`files{}` entry. The `test_with: 'visudo -cf {}'` semantics don't
|
||||||
|
apply to symlinks; tested instead on commit in left4me CI (a
|
||||||
|
`test_sudoers.py` that runs `visudo -cf` against the live file).
|
||||||
|
|
||||||
|
### Sysctl drop-in + ptrace_scope absorption
|
||||||
|
|
||||||
|
Today: same dual-copy story as sudoers, plus `kernel.yama.ptrace_scope`
|
||||||
|
exists as a metadata default (`sysctl/kernel/yama/ptrace_scope: '2'`)
|
||||||
|
that gets deployed via `bundles/sysctl/` into a separate file.
|
||||||
|
|
||||||
|
After: append `kernel.yama.ptrace_scope = 2` to
|
||||||
|
`deploy/files/etc/sysctl.d/99-left4me.conf`. Delete the metadata
|
||||||
|
entry. Delete the bw `files{}` entry + ckn-bw mirror; replace with
|
||||||
|
symlink. `bundles/sysctl/` no longer renders anything for left4me;
|
||||||
|
all left4me sysctl tuning lives in the one drop-in.
|
||||||
|
|
||||||
|
### Privileged scripts
|
||||||
|
|
||||||
|
Today: `scripts/libexec/`, `scripts/sbin/` at left4me repo root.
|
||||||
|
`install_left4me_scripts` action copies them to
|
||||||
|
`/usr/local/libexec/left4me/` and `/usr/local/sbin/` as root on every
|
||||||
|
git_deploy update.
|
||||||
|
|
||||||
|
After:
|
||||||
|
- Move `left4me/scripts/` → `left4me/deploy/scripts/`. Update the few
|
||||||
|
references that point at the old path (search for
|
||||||
|
`/opt/left4me/src/scripts/` and `scripts/{libexec,sbin}/` in both
|
||||||
|
repos).
|
||||||
|
- Replace `install_left4me_scripts` action with one bw `symlinks{}`
|
||||||
|
item per script. Trigger semantics: each symlink declares
|
||||||
|
`triggers: ['action:systemd_daemon_reload']` only if the script is
|
||||||
|
referenced by a systemd unit (e.g. `left4me-overlay` is in
|
||||||
|
`ExecStartPre=` of `left4me-server@.service`; daemon-reload not
|
||||||
|
needed for script changes since systemd reads the script content at
|
||||||
|
exec time, not at unit-load time).
|
||||||
|
|
||||||
|
Sudo follows symlinks. With `/opt/left4me/src` root-owned, the
|
||||||
|
symlink target is root-owned, and sudo's `Cmnd_Alias` path matching
|
||||||
|
sees the original `/usr/local/{libexec,sbin}/<name>` path.
|
||||||
|
|
||||||
|
### Reference units in `deploy/files/`
|
||||||
|
|
||||||
|
No structural change. Remove hardening directives from the reference
|
||||||
|
files in lockstep with extracting them into the drop-ins (otherwise
|
||||||
|
`test_deploy_artifacts.py` sees the reference unit with hardening
|
||||||
|
inline but the live unit without). The reference file then represents
|
||||||
|
"the base unit ckn-bw emits"; the drop-in represents "the hardening
|
||||||
|
profile left4me ships".
|
||||||
|
|
||||||
|
## Migration order
|
||||||
|
|
||||||
|
Each step is an independent landable PR.
|
||||||
|
|
||||||
|
1. **Canary — sysctl consolidation.**
|
||||||
|
- Add `kernel.yama.ptrace_scope = 2` to
|
||||||
|
`deploy/files/etc/sysctl.d/99-left4me.conf`.
|
||||||
|
- Delete `defaults['sysctl']['kernel']['yama']['ptrace_scope']`
|
||||||
|
from `bundles/left4me/metadata.py`.
|
||||||
|
- Delete `bundles/left4me/files/etc/sysctl.d/99-left4me.conf`
|
||||||
|
(the verbatim mirror).
|
||||||
|
- Replace the bw `files{}` entry with a `symlinks{}` entry
|
||||||
|
pointing at the checkout.
|
||||||
|
- Verify: `sysctl kernel.yama.ptrace_scope` reads `2`;
|
||||||
|
`bw apply` idempotent.
|
||||||
|
|
||||||
|
2. **Hardening drop-ins.**
|
||||||
|
- Create `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf`
|
||||||
|
and `…/left4me-server@.service.d/10-hardening.conf` from the
|
||||||
|
`HARDENING_*` dicts.
|
||||||
|
- Remove `**HARDENING_WEB` / `**HARDENING_SERVER` splats from the
|
||||||
|
reactor; delete the three constants.
|
||||||
|
- Remove hardening directives from the reference units in
|
||||||
|
`deploy/files/usr/local/lib/systemd/system/`.
|
||||||
|
- Add `directory:/etc/systemd/system/left4me-{web,server@}.service.d`
|
||||||
|
items + symlinks for the drop-ins.
|
||||||
|
- Wire `systemctl daemon-reload` to fire on
|
||||||
|
`git_deploy:/opt/left4me/src`.
|
||||||
|
- Verify: `systemctl show -p ProtectSystem,ProtectKernelTunables,PrivateUsers,…
|
||||||
|
left4me-web.service left4me-server@1.service` matches the
|
||||||
|
pre-extraction values (full hardening test plan rerun is the
|
||||||
|
gold standard).
|
||||||
|
|
||||||
|
3. **Sudoers.**
|
||||||
|
- Replace bw `files{}` entry with `symlinks{}`.
|
||||||
|
- Delete `bundles/left4me/files/etc/sudoers.d/left4me`.
|
||||||
|
- Add left4me CI test running `visudo -cf` on the file.
|
||||||
|
- Verify: `sudo -l -U left4me` lists the expected commands;
|
||||||
|
gameserver start via the web app still works.
|
||||||
|
|
||||||
|
4. **Privileged scripts.**
|
||||||
|
- `git mv left4me/scripts left4me/deploy/scripts`.
|
||||||
|
- Update any references (commit hooks, docs).
|
||||||
|
- Replace `actions['install_left4me_scripts']` with `symlinks{}`
|
||||||
|
items, one per script. Drop the action.
|
||||||
|
- Update `git_deploy:` `triggers:` to remove
|
||||||
|
`action:install_left4me_scripts`.
|
||||||
|
- Verify: `sudo /usr/local/libexec/left4me/left4me-overlay status 1`
|
||||||
|
still works; gameserver lifecycle (start/stop) still works.
|
||||||
|
|
||||||
|
5. **Cleanup.**
|
||||||
|
- Prune `gunicorn_workers` / `gunicorn_threads` metadata defaults
|
||||||
|
if they end up referenced only by `web.env.mako` (they do today;
|
||||||
|
keep the metadata, they're real per-host values).
|
||||||
|
- Update `deploy/README.md` to describe the new layout
|
||||||
|
(deploy/files = symlink source-of-truth; deploy/scripts = same
|
||||||
|
for helpers).
|
||||||
|
- Update `bundles/left4me/README.md` to describe the new
|
||||||
|
symlink-based delivery model.
|
||||||
|
|
||||||
|
## Sequence vs. build-overlay-unit refactor
|
||||||
|
|
||||||
|
This design lands **before** the build-overlay-unit refactor
|
||||||
|
(`2026-05-15-build-overlay-unit-design.md`). Reasons:
|
||||||
|
|
||||||
|
- build-overlay-unit introduces a dispatcher unit template; its
|
||||||
|
hardening profile should live as a drop-in alongside the dispatcher
|
||||||
|
from the start, using the pattern this design establishes.
|
||||||
|
- The reactor surgery in step 2 (removing `HARDENING_*` splats) is
|
||||||
|
cleaner against today's reactor than against a reactor that's also
|
||||||
|
being reshaped for the build-overlay-unit work.
|
||||||
|
|
||||||
|
## Verification (end-to-end)
|
||||||
|
|
||||||
|
After all five steps land and `bw apply` is idempotent on
|
||||||
|
`ovh.left4me`:
|
||||||
|
|
||||||
|
1. `systemctl show -p ProtectSystem,PrivateUsers,SystemCallFilter,…
|
||||||
|
left4me-web.service left4me-server@1.service` matches the
|
||||||
|
hardening test plan's reference values (run the relevant tests
|
||||||
|
from `docs/superpowers/specs/2026-05-15-hardening-test-plan.md`).
|
||||||
|
2. `sysctl kernel.yama.ptrace_scope net.core.rmem_max
|
||||||
|
net.ipv4.tcp_congestion_control` returns expected values.
|
||||||
|
3. `sudo -l -U left4me` reports the same allowed commands as before.
|
||||||
|
4. `ls -la /etc/sudoers.d/left4me /etc/sysctl.d/99-left4me.conf
|
||||||
|
/etc/systemd/system/left4me-*.service.d/10-hardening.conf
|
||||||
|
/usr/local/libexec/left4me/* /usr/local/sbin/<wrappers>` shows
|
||||||
|
symlinks into `/opt/left4me/src/deploy/...`.
|
||||||
|
5. A gameserver round-trip (start via web app → cvar inspect → stop)
|
||||||
|
succeeds.
|
||||||
|
6. `bw verify ovh.left4me` reports no drift.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Moving base unit bodies into left4me. Per-host shape stays
|
||||||
|
reactor-emitted.
|
||||||
|
- AppArmor profiles (deferred from the defenses survey).
|
||||||
|
- Reshaping the bw `files{}` items for `host.env.mako` /
|
||||||
|
`web.env.mako` — they need mako templating with metadata context,
|
||||||
|
which ckn-bw is the right place for.
|
||||||
|
- The build-overlay-unit refactor itself. Lands separately on top of
|
||||||
|
this.
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- Handoff (this brainstorm's framing):
|
||||||
|
`docs/superpowers/specs/2026-05-15-handoff-deployment-responsibility.md`
|
||||||
|
- Prereq (runtime state relocation + non-editable install, shipped):
|
||||||
|
`docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md`
|
||||||
|
- Original deployment design (the model being reaffirmed for
|
||||||
|
application-shape artifacts):
|
||||||
|
`docs/superpowers/specs/2026-05-06-left4me-deployment-design.md`
|
||||||
|
- Hardening refactor design (the inline-in-reactor approach this
|
||||||
|
design supersedes for hardening):
|
||||||
|
`docs/superpowers/specs/2026-05-15-hardening-refactor-design.md`
|
||||||
|
- Hardening test plan (reference for step-2 verification):
|
||||||
|
`docs/superpowers/specs/2026-05-15-hardening-test-plan.md`
|
||||||
|
- ckn-bw left4me bundle: `~/Projekte/ckn-bw/bundles/left4me/`
|
||||||
|
|
@ -2,11 +2,17 @@
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Queued for a future session, **after the uid-collapse refactor lands**
|
**Resolved 2026-05-15** — the brainstorming session happened and produced
|
||||||
(`docs/superpowers/plans/2026-05-15-uid-collapse.md`). This is a
|
`docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`.
|
||||||
framing doc for a brainstorming session, not an implementation plan.
|
Read that for the answer. The runtime-state relocation
|
||||||
The brainstorming session should use `superpowers:brainstorming` and
|
(`2026-05-15-runtime-state-relocation-design.md`) shipped as a prereq;
|
||||||
exit with a design doc; implementation follows separately.
|
the design lands hardening drop-ins, sudoers, sysctl, and helpers as
|
||||||
|
symlinks into the (now root-owned) `/opt/left4me/src/deploy/...`
|
||||||
|
checkout, while base unit bodies and per-host shape stay bw-managed.
|
||||||
|
|
||||||
|
This doc is kept as the historical framing — the question that opened
|
||||||
|
the brainstorm, the operator's leaning, and the candidate options that
|
||||||
|
got evaluated. The actual landed answer is the design doc.
|
||||||
|
|
||||||
## The question
|
## The question
|
||||||
|
|
||||||
|
|
|
||||||
285
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
Normal file
285
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
# Handoff — non-editable install + root-owned `/opt/left4me/src`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**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 `<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
|
||||||
|
|
||||||
|
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 = {<pkg> = "."}` 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.
|
||||||
|
```
|
||||||
|
|
@ -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