bundlewrap/bundles/left4me
CroneKorkN 77b5e01198
refactor(left4me): collapse venv chain into uv sync
Replace left4me_create_venv + left4me_pip_upgrade + left4me_pip_install
(the tempdir-copy dance) with a single left4me_uv_sync action driven by
left4me's committed uv.lock. Deterministic dep versions, no source-tree
mutation during build (hatchling PEP 660 editable installs don't write
to source), one action instead of three.

uv is not in Trixie's apt archive (experimental/sid only), so a new
left4me_install_uv action downloads a pinned 0.11.8 binary tarball
from astral-sh/uv releases, SHA256-verifies against the published
.sha256 sibling, and installs into /usr/local/bin. Idempotent via
`unless` on the version string — only re-runs when the pin is bumped.
Pattern matches left4me_install_steamcmd elsewhere in this bundle.

apt.packages: drop python3-pip and python3-venv (uv replaces both;
no other consumer in the bundle). Keep python3 and python3-dev — uv
shells out to the system Python interpreter.

PYTHONPATH=/opt/left4me/src removed from left4me_alembic_upgrade and
left4me_seed_overlays — was a workaround for the previous layout's
package-dir indirection; with the new standard layout + editable
install, the venv resolves both members natively.

Per left4me/docs/superpowers/plans/2026-05-15-uv-workspace-execution.md.
Requires the matching commit on left4me's master.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:07:47 +02:00
..
files/etc/left4me left4me: symlink /etc/sudoers.d/left4me to the checkout 2026-05-15 19:30:23 +02:00
items.py refactor(left4me): collapse venv chain into uv sync 2026-05-15 22:07:47 +02:00
metadata.py refactor(left4me): collapse venv chain into uv sync 2026-05-15 22:07:47 +02:00
README.md refactor(left4me): collapse venv chain into uv sync 2026-05-15 22:07:47 +02:00

left4me

L4D2 game-server management platform: a Flask web UI on gunicorn that provisions per-instance srcds servers via templated systemd units, with kernel-overlayfs layering for shared installations + per-overlay maps, and uid-based DSCP/priority marking on the egress path so CAKE on the external interface prioritizes srcds UDP over bulk traffic.

Metadata

'metadata': {
    'left4me': {
        'domain': 'whatever.tld',  # required — the only per-node knob
        # Everything below is optional and has a sensible default in the
        # bundle. Override per-node only if the default is wrong:
        # 'git_url': 'git@git.sublimity.de:cronekorkn/left4me',
        # 'git_branch': 'master',
        # 'gunicorn_workers': 1,
        # 'gunicorn_threads': 32,
        # 'job_worker_threads': 4,
        # 'port_range_start': 27015,
        # 'port_range_end': 27115,
        # secret_key is auto-derived per node
        # (repo.vault.random_bytes_as_base64_for f'{node.name} left4me secret_key').
    },
},

The bundle's derived_from_domain reactor reads left4me/domain and emits the corresponding nginx/vhosts, letsencrypt/domains, monitoring/services/left4me-web (HTTPS health check), and the game- port nftables/input accept rules. Backup paths (/var/lib/left4me, /etc/left4me) are set-merged into backup/paths from defaults. None of these need to be declared per-node.

What this bundle does

The bundle delivers to ovh.left4me a mix of:

After git_deploy:/opt/left4me/src (root-owned — left4me cannot rewrite its own deployment artifacts at runtime), ckn-bw creates symlinks from canonical on-host paths into the checkout:

On-host path Source in checkout
/etc/sudoers.d/left4me deploy/files/etc/sudoers.d/left4me
/etc/sysctl.d/99-left4me.conf deploy/files/etc/sysctl.d/99-left4me.conf
/etc/systemd/system/left4me-web.service.d/10-hardening.conf deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
/etc/systemd/system/left4me-server@.service.d/10-hardening.conf deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
/usr/local/libexec/left4me/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox} deploy/scripts/libexec/*
/usr/local/sbin/left4me deploy/scripts/sbin/left4me

The hardening drop-ins and sudoers are the application's own security knowledge — they live in the left4me repo and are version-controlled there. The privileged helpers are also application code. The symlink pattern lets bw manage placement without duplicating content.

Design rationale: left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md.

Reactor-emitted units (per-host shape)

Via systemd/units metadata in metadata.py (consumed by bundles/systemd/):

  • left4me-web.service — gunicorn on 127.0.0.1:8000; worker/thread counts from web.env.mako. TLS terminates upstream.
  • left4me-server@.service — per-instance srcds template; SocketBindAllow= ranges from metadata.
  • l4d2-game.slice / l4d2-build.slice — cgroup slices with per-host AllowedCPUs= from left4me/system_cpus.
  • system.slice.d/99-left4me-cpuset.conf + user.slice.d/99-left4me-cpuset.conf — host CPU-set drop-ins, same source.

bw files{} — templated env files

  • host.env.mako/etc/left4me/host.env
  • web.env.mako/etc/left4me/web.env
  • sandbox-resolv.conf/etc/left4me/sandbox-resolv.conf

Action chains — deploy lifecycle

  • git_deployuv_sync (uv sync --frozen against the workspace's committed uv.lock; hatchling PEP 660 editable, doesn't touch source) → alembic_upgradeseed_overlays + web restart.
  • One-shot bootstrap: install_uv downloads a pinned uv binary (SHA256-verified) into /usr/local/bin because uv isn't in Trixie's apt archive. unless-gated, so it's a no-op once the version pin is installed; re-runs only when the constant is bumped.
  • Idempotent gates: chmod-sudoers (0440 root:root), chmod-scripts (0755 root:root).
  • Post-git-deploy reloads: systemctl daemon-reload, sysctl --system.
  • Post-apply self-test: verify-hardening-dropins (asserts the drop-ins are loaded by the live units before declaring apply done).

System user

left4me (uid/gid 980, home /var/lib/left4me, mode 0755) — the same uid hosts the web app, gameservers, and the script-overlay sandbox unit (which drops privileges via systemd-run with a fully hardened transient service). Runtime mutable state lives under /var/lib/left4me/; /opt/left4me/ stays as a root-owned deploy-artifact root.

nftables / nginx / monitoring

  • Contributes uid-based DSCP/priority marks for srcds UDP egress to nftables/output (via defaults).
  • derived_from_domain reactor emits the corresponding nginx/vhosts, letsencrypt/domains, and monitoring/services/left4me-web (HTTPS health check).

Gotchas

  • Requires bundles/nftables and bundles/systemd on the node. The bundle asserts membership at bw test time. On Debian-13 these ride in via the debian-13 group, so attaching the bundle to a Debian-13 node is enough.
  • left4me-web.service does not have NoNewPrivileges=true. This is intentional — workers sudo the privileged helpers; NoNewPrivileges would block setuid escalation. Per-instance server@.service units do have it.
  • CAKE shaping is configured separately, via network/<iface>/cake on the node (consumed by bundles/network/), not by this bundle.
  • First-run admin user is manual. After bw apply, ssh to the host and bootstrap the admin via the left4me wrapper (it sources the env files, drops to the left4me user, and runs the flask CLI): sudo left4me create-user <username> --admin (prompts for password via the flask CLI, or set LEFT4ME_ADMIN_PASSWORD first). The bundle deliberately doesn't seed an admin to keep credentials out of the metadata pipeline. The same left4me wrapper accepts any other flask subcommand: sudo left4me seed-script-overlays <dir>, sudo left4me routes, sudo left4me shell, etc.
  • CPU isolation is managed by this bundle, driven by one required per-node knob: left4me/system_cpus — a set of int CPU ids that pins system.slice / user.slice / l4d2-build.slice. The complement (set(range(vm/threads)) - system_cpus) pins l4d2-game.slice. On HT hosts, list both SMT siblings of every physical core you want to reserve for system, otherwise games end up sharing L1/L2 with system. Find pairings via /sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list. On the prod node (ovh.left4me, 4 physical / 8 threads, pairings (0,4) (1,5) (2,6) (3,7)) the node sets 'system_cpus': {0, 4} to reserve physical core 0 entirely. l4d2-game.slice and l4d2-build.slice carry AllowedCPUs= inline on their unit definitions; system.slice and user.slice get drop-ins registered under systemd/units with the '<parent>.d/<basename>.conf' key convention (same shape nginx and autologin use), landing at /usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf. The reactor raises if system_cpus includes CPUs outside [0, vm/threads) or leaves no cores for games.
  • Kernel feature requirement: kernel-overlayfs (CONFIG_OVERLAY_FS). Standard on debian-13.
  • Game ports open by the web app on demand in the range 27015-27115 (UDP+TCP). Add corresponding accept rules to nftables/input per node if the host's policy is default-drop on input.
  • Pinned UIDs/GIDs (980/981). Chosen for deterministic ownership across rebuilds and backup restores. If you add another bundle that pins UIDs in this repo, make sure it doesn't collide.

Slice support requires bundles/systemd ≥ commit cc1c6a5

This bundle's l4d2-game.slice and l4d2-build.slice units rely on bundles/systemd/items.py accepting the .slice extension. Older revisions raised Exception(f'unknown type slice') at apply time. The repo-wide bw test will catch this if it regresses.