diff --git a/bundles/left4me/README.md b/bundles/left4me/README.md
index f6f4308..097f96c 100644
--- a/bundles/left4me/README.md
+++ b/bundles/left4me/README.md
@@ -79,12 +79,18 @@ from defaults. None of these need to be declared per-node.
metadata pipeline. The same `left4me` wrapper accepts any other flask
subcommand: `sudo left4me seed-script-overlays
`,
`sudo left4me routes`, `sudo left4me shell`, etc.
-- **CPU isolation drop-ins are not managed by this bundle.** The
- upstream shell deploy generated `/etc/systemd/system/system.slice.d/
- 99-left4me-cpuset.conf` (and siblings for user/build/game slices)
- dynamically based on `nproc --all`. That logic is incompatible with
- static bundle metadata and is out of scope here. Apply CPU isolation
- manually post-deploy if needed.
+- **CPU isolation is managed by this bundle**, driven by one knob:
+ `left4me/system_core_count` (default `1`). The first N cores starting
+ at 0 are pinned to `system.slice` / `user.slice` / `l4d2-build.slice`;
+ the rest (up to `vm/threads - 1`) are pinned to `l4d2-game.slice`.
+ `l4d2-game.slice` and `l4d2-build.slice` carry `AllowedCPUs=` inline
+ on their unit definitions; `system.slice` and `user.slice` are pinned
+ via drop-ins registered under `systemd/units` with the
+ `'.d/.conf'` key convention (same shape nginx and
+ autologin use), landing at
+ `/usr/local/lib/systemd/system/.d/99-left4me-cpuset.conf`.
+ The reactor raises if `vm/threads < 2` or if `system_core_count`
+ 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
diff --git a/bundles/left4me/items.py b/bundles/left4me/items.py
index 767dfec..6f2f39e 100644
--- a/bundles/left4me/items.py
+++ b/bundles/left4me/items.py
@@ -1,6 +1,8 @@
# Items for the left4me bundle.
# Systemd units come from metadata via bundles/systemd/ — there are no
-# .service or .slice files in this bundle's files/ tree.
+# .service or .slice files in this bundle's files/ tree. Cpuset drop-ins
+# for system.slice / user.slice are likewise emitted via systemd/units
+# in metadata.py (key: '.d/.conf').
directories = {
'/opt/left4me': {
diff --git a/bundles/left4me/metadata.py b/bundles/left4me/metadata.py
index c55ba28..6faa4ea 100644
--- a/bundles/left4me/metadata.py
+++ b/bundles/left4me/metadata.py
@@ -11,8 +11,18 @@ defaults = {
'gunicorn_workers': 1,
'gunicorn_threads': 32,
'job_worker_threads': 4,
- 'port_range_start': 27015,
- 'port_range_end': 27115,
+ # Whole 27000-block: covers Steam's defaults (27015 game, 27005
+ # client/RCON) plus headroom for ad-hoc ports without further
+ # nftables changes. Mirrored into LEFT4ME_PORT_RANGE_{START,END}
+ # by web.env.mako and into the nftables input rule by the
+ # nftables_input reactor below.
+ 'port_range_start': 27000,
+ 'port_range_end': 27999,
+ # Cgroup-v2 cpuset isolation. The first `system_core_count` cores
+ # (starting at 0) go to system / user / l4d2-build; the rest (up to
+ # vm/threads - 1) go to l4d2-game. Bundle refuses to apply on hosts
+ # with < 2 cores or when system_core_count leaves none for games.
+ 'system_core_count': 1,
},
'apt': {
'packages': {
@@ -120,6 +130,26 @@ def systemd_units(metadata):
workers = metadata.get('left4me/gunicorn_workers')
threads = metadata.get('left4me/gunicorn_threads')
+ # cgroup-v2 cpuset. First `system_core_count` cores → system/user/build;
+ # the rest → game. Refuse to apply if there's no useful split.
+ vm_threads = metadata.get('vm/threads', metadata.get('vm/cores', 1))
+ if vm_threads < 2:
+ raise Exception(
+ f'left4me cpu isolation needs at least 2 cores/threads, host has {vm_threads}'
+ )
+ system_core_count = metadata.get('left4me/system_core_count')
+ game_core_count = vm_threads - system_core_count
+ if system_core_count < 1 or game_core_count < 1:
+ raise Exception(
+ f'left4me/system_core_count={system_core_count} on {vm_threads}-thread host '
+ f'leaves {game_core_count} cores for games; both must be >= 1'
+ )
+ system_cpus = '0' if system_core_count == 1 else f'0-{system_core_count - 1}'
+ game_cpus = (
+ str(system_core_count) if game_core_count == 1
+ else f'{system_core_count}-{vm_threads - 1}'
+ )
+
web_service = {
'Unit': {
'Description': 'left4me web application',
@@ -222,6 +252,7 @@ def systemd_units(metadata):
'Slice': {
'CPUWeight': '1000',
'IOWeight': '1000',
+ 'AllowedCPUs': game_cpus,
},
}
@@ -233,16 +264,25 @@ def systemd_units(metadata):
'Slice': {
'CPUWeight': '10',
'IOWeight': '10',
+ 'AllowedCPUs': system_cpus,
},
}
+ units = {
+ 'left4me-web.service': web_service,
+ 'left4me-server@.service': server_template,
+ 'l4d2-game.slice': game_slice,
+ 'l4d2-build.slice': build_slice,
+ }
+ # Drop-ins on the upstream system.slice / user.slice (units we don't
+ # own). Same '.d/.conf' convention as nginx and
+ # autologin use elsewhere in this repo.
+ cpuset_dropin = {'Slice': {'AllowedCPUs': system_cpus}}
+ units['system.slice.d/99-left4me-cpuset.conf'] = cpuset_dropin
+ units['user.slice.d/99-left4me-cpuset.conf'] = cpuset_dropin
+
return {
'systemd': {
- 'units': {
- 'left4me-web.service': web_service,
- 'left4me-server@.service': server_template,
- 'l4d2-game.slice': game_slice,
- 'l4d2-build.slice': build_slice,
- },
+ 'units': units,
},
}