Compare commits
No commits in common. "f231ebcb0d38c7d20c1f468369c64ded7e12125a" and "bbb2b983bc899a348143f5ea7cf91db7d5070bff" have entirely different histories.
f231ebcb0d
...
bbb2b983bc
5 changed files with 8 additions and 635 deletions
|
|
@ -19,12 +19,6 @@
|
|||
> ckn-bw** — the two would fight over file ownership, sudoers, and unit
|
||||
> definitions. The script remains useful as concrete documentation of the
|
||||
> install steps the bundle now performs declaratively.
|
||||
>
|
||||
> **Verbatim-shipped files**: when a row in the table below says "shipped
|
||||
> verbatim by the bundle," the only deploy step for changes to that file is
|
||||
> to re-sync the matching path in `ckn-bw/bundles/left4me/files/…` and run
|
||||
> `bw apply ovh.left4me`. No new group / user / unit work needed unless the
|
||||
> change itself introduces one.
|
||||
|
||||
## What lives here (and what corresponds to it in ckn-bw)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ shell-quoted) and exit 0 instead of execv. Used by tests.
|
|||
"""
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
|
|
@ -55,49 +54,6 @@ def die(msg: str) -> None:
|
|||
sys.exit(1)
|
||||
|
||||
|
||||
def _lookup_uid(username: str) -> tuple[int, int]:
|
||||
"""Return (uid, gid) for *username*, dying with a clear message if missing."""
|
||||
try:
|
||||
entry = pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
die(
|
||||
f"required system user {username!r} does not exist; "
|
||||
"this is a deploy misconfiguration"
|
||||
)
|
||||
return entry.pw_uid, entry.pw_gid
|
||||
|
||||
|
||||
def _get_user_ids() -> tuple[int, int, int, int]:
|
||||
"""Return (sandbox_uid, sandbox_gid, left4me_uid, left4me_gid).
|
||||
|
||||
In normal operation, looks up the real system users. In PRINT_ONLY
|
||||
(test) mode the env vars LEFT4ME_TEST_SANDBOX_UID/LEFT4ME_TEST_SANDBOX_GID/
|
||||
LEFT4ME_TEST_LEFT4ME_UID/LEFT4ME_TEST_LEFT4ME_GID may be used to inject
|
||||
synthetic uids so tests can run without root and without real system
|
||||
users present. The stubs are intentionally ignored outside PRINT_ONLY
|
||||
mode so that a misconfigured systemd unit override cannot influence the
|
||||
real uid mapping.
|
||||
"""
|
||||
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||
sandbox_uid_env = os.environ.get("LEFT4ME_TEST_SANDBOX_UID")
|
||||
sandbox_gid_env = os.environ.get("LEFT4ME_TEST_SANDBOX_GID")
|
||||
left4me_uid_env = os.environ.get("LEFT4ME_TEST_LEFT4ME_UID")
|
||||
left4me_gid_env = os.environ.get("LEFT4ME_TEST_LEFT4ME_GID")
|
||||
|
||||
if all(v is not None for v in (sandbox_uid_env, sandbox_gid_env,
|
||||
left4me_uid_env, left4me_gid_env)):
|
||||
return (
|
||||
int(sandbox_uid_env), # type: ignore[arg-type]
|
||||
int(sandbox_gid_env), # type: ignore[arg-type]
|
||||
int(left4me_uid_env), # type: ignore[arg-type]
|
||||
int(left4me_gid_env), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
sandbox_uid, sandbox_gid = _lookup_uid("l4d2-sandbox")
|
||||
left4me_uid, left4me_gid = _lookup_uid("left4me")
|
||||
return sandbox_uid, sandbox_gid, left4me_uid, left4me_gid
|
||||
|
||||
|
||||
def root() -> Path:
|
||||
return Path(os.environ.get("LEFT4ME_ROOT") or DEFAULT_ROOT)
|
||||
|
||||
|
|
@ -172,14 +128,9 @@ def assert_no_fuse_xattrs(upper: Path) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _print_argv(argv: list[str]) -> None:
|
||||
"""Emit one shell-quoted argv line to stdout (PRINT_ONLY helper, no exit)."""
|
||||
print(" ".join(shlex.quote(a) for a in argv))
|
||||
|
||||
|
||||
def exec_or_print(argv: list[str]) -> None:
|
||||
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||
_print_argv(argv)
|
||||
print(" ".join(shlex.quote(a) for a in argv))
|
||||
sys.exit(0)
|
||||
os.execv(argv[0], argv)
|
||||
|
||||
|
|
@ -217,79 +168,7 @@ def cmd_mount(name: str) -> None:
|
|||
|
||||
assert_no_fuse_xattrs(upper)
|
||||
|
||||
# Resolve user ids now (fails fast on deploy misconfiguration).
|
||||
sandbox_uid, sandbox_gid, left4me_uid, left4me_gid = _get_user_ids()
|
||||
|
||||
# Build the final lowerdir list, substituting idmap bind-mount paths for
|
||||
# any lowerdir owned by l4d2-sandbox. An idmap bind mount makes the kernel
|
||||
# see the l4d2-sandbox-owned tree as if it were owned by left4me, so that
|
||||
# overlayfs copy-up produces left4me-owned upperdir entries.
|
||||
idmap_dir = runtime_name_dir / "idmap"
|
||||
final_lowerdirs: list[str] = []
|
||||
bind_argvs: list[list[str]] = []
|
||||
seen_idmap_targets: dict[Path, str] = {}
|
||||
|
||||
for lowerdir in canonical_lowerdirs:
|
||||
try:
|
||||
st = os.stat(lowerdir)
|
||||
except OSError as exc:
|
||||
die(f"failed to stat lowerdir {shlex.quote(lowerdir)}: {exc}")
|
||||
if st.st_uid == sandbox_uid:
|
||||
# This lowerdir needs idmap remapping.
|
||||
# Include the parent dirname to avoid basename collisions between
|
||||
# lowerdirs from different allowlist roots (e.g. overlays/foo and
|
||||
# workshop_cache/foo would otherwise map to the same idmap target).
|
||||
p = Path(lowerdir)
|
||||
lowerdir_basename = f"{p.parent.name}_{p.name}"
|
||||
idmap_target = idmap_dir / lowerdir_basename
|
||||
|
||||
# Belt-and-braces: detect if two different lowerdirs would collide
|
||||
# on the same idmap target after the parent+name derivation.
|
||||
if idmap_target in seen_idmap_targets:
|
||||
die(
|
||||
f"idmap target collision: lowerdirs {shlex.quote(seen_idmap_targets[idmap_target])}"
|
||||
f" and {shlex.quote(lowerdir)} both map to {idmap_target}"
|
||||
)
|
||||
seen_idmap_targets[idmap_target] = lowerdir
|
||||
|
||||
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") != "1":
|
||||
idmap_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
idmap_target.mkdir(mode=0o700, exist_ok=True)
|
||||
|
||||
if not os.path.ismount(idmap_target) or \
|
||||
os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||
# --map-users / --map-groups argument format:
|
||||
# <on-disk-uid>:<in-mount-uid>:<count>
|
||||
# The util-linux man page calls these <inner>:<outer>, which is
|
||||
# misleading. Empirically (verified on left4.me, kernel 6.12,
|
||||
# ext4) the FIRST number is the on-disk uid and the SECOND is
|
||||
# the uid exposed inside the mount. Don't swap them.
|
||||
bind_argv = [
|
||||
MOUNT_BIN,
|
||||
"--bind",
|
||||
f"--map-users={sandbox_uid}:{left4me_uid}:1",
|
||||
f"--map-groups={sandbox_gid}:{left4me_gid}:1",
|
||||
lowerdir,
|
||||
str(idmap_target),
|
||||
]
|
||||
bind_argvs.append(bind_argv)
|
||||
|
||||
final_lowerdirs.append(str(idmap_target))
|
||||
else:
|
||||
final_lowerdirs.append(lowerdir)
|
||||
|
||||
print_only = os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1"
|
||||
|
||||
if print_only:
|
||||
# Emit each bind-mount argv first, then fall through to the overlay argv.
|
||||
for bind_argv in bind_argvs:
|
||||
_print_argv(bind_argv)
|
||||
else:
|
||||
# Actually exec each bind mount before the overlay mount.
|
||||
for bind_argv in bind_argvs:
|
||||
subprocess.run(bind_argv, check=True)
|
||||
|
||||
options = f"lowerdir={':'.join(final_lowerdirs)},upperdir={upper},workdir={work}"
|
||||
options = f"lowerdir={':'.join(canonical_lowerdirs)},upperdir={upper},workdir={work}"
|
||||
argv = [
|
||||
MOUNT_BIN,
|
||||
"-t", "overlay",
|
||||
|
|
@ -307,26 +186,17 @@ def cmd_umount(name: str) -> None:
|
|||
merged_path = runtime_name_dir / "merged"
|
||||
work_inner = runtime_name_dir / "work" / "work"
|
||||
|
||||
overlay_umount_argv = [
|
||||
argv = [
|
||||
UMOUNT_BIN,
|
||||
# Resolve only if it exists; PRINT_ONLY tests always pre-create it.
|
||||
str(merged_path.resolve(strict=True) if merged_path.exists() else merged_path),
|
||||
]
|
||||
|
||||
# Collect idmap bind-umount argvs: one per direct subdir of runtime/<name>/idmap/.
|
||||
idmap_dir = runtime_name_dir / "idmap"
|
||||
bind_umount_argvs: list[list[str]] = []
|
||||
if idmap_dir.is_dir():
|
||||
for entry in sorted(idmap_dir.iterdir()):
|
||||
if entry.is_dir():
|
||||
bind_umount_argvs.append([UMOUNT_BIN, str(entry)])
|
||||
|
||||
# PRINT_ONLY: emit the overlay umount argv, then each bind-umount argv, then exit.
|
||||
# Order matches real execution (overlay first, then idmap binds underneath).
|
||||
# PRINT_ONLY: emit the umount argv and exit. Tests assert exact shape
|
||||
# of this dry-run; the post-umount cleanup of work_inner is a runtime
|
||||
# behaviour exercised on the host, not in unit tests.
|
||||
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||
_print_argv(overlay_umount_argv)
|
||||
for bind_umount_argv in bind_umount_argvs:
|
||||
_print_argv(bind_umount_argv)
|
||||
print(" ".join(shlex.quote(a) for a in argv))
|
||||
sys.exit(0)
|
||||
|
||||
if merged_path.exists():
|
||||
|
|
@ -344,7 +214,7 @@ def cmd_umount(name: str) -> None:
|
|||
# reaped → umount-clears sequence happens without any race
|
||||
# window for us to ride out. EBUSY here is a real error.
|
||||
if os.path.ismount(merged):
|
||||
subprocess.run(overlay_umount_argv, check=True)
|
||||
subprocess.run(argv, check=True)
|
||||
|
||||
# Kernel-overlayfs creates work_inner during mount with root:root mode
|
||||
# 0/0. After unmount it's an orphan that the unit's User= (left4me)
|
||||
|
|
@ -357,15 +227,6 @@ def cmd_umount(name: str) -> None:
|
|||
if work_inner.exists():
|
||||
shutil.rmtree(work_inner)
|
||||
|
||||
# Unwind idmap bind mounts, then remove the idmap directory. Each bind
|
||||
# is only umounted if it is still a mountpoint (idempotent across partial
|
||||
# teardowns).
|
||||
for bind_umount_argv in bind_umount_argvs:
|
||||
target = Path(bind_umount_argv[-1])
|
||||
if os.path.ismount(target):
|
||||
subprocess.run(bind_umount_argv, check=True)
|
||||
shutil.rmtree(idmap_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> None:
|
||||
if len(argv) != 3 or argv[1] not in ("mount", "umount"):
|
||||
|
|
|
|||
|
|
@ -394,28 +394,6 @@ def test_overlay_helper_is_python_with_strict_validation():
|
|||
assert '"unmount"' not in text
|
||||
|
||||
|
||||
def test_overlay_helper_idmaps_sandbox_owned_lowerdirs():
|
||||
"""Script-built overlay lowerdirs are owned by l4d2-sandbox. Without an
|
||||
idmap bind mount, kernel-overlayfs copy-up preserves that ownership and
|
||||
the gameserver (uid left4me) can't write to copied-up directories like
|
||||
addons/sourcemod/logs/. The helper must inject an idmap bind for each
|
||||
sandbox-owned lowerdir before the overlay mount and tear it down after.
|
||||
"""
|
||||
text = OVERLAY_HELPER.read_text()
|
||||
# The bind-mount argv uses --map-users / --map-groups (numeric uids).
|
||||
assert "--map-users=" in text
|
||||
assert "--map-groups=" in text
|
||||
# Idmapped paths live under runtime/<name>/idmap/ and are substituted
|
||||
# into the lowerdir= string.
|
||||
assert 'runtime_name_dir / "idmap"' in text
|
||||
# Test-mode uid stubs are namespaced LEFT4ME_TEST_* and gated on
|
||||
# PRINT_ONLY=1 so a misconfigured systemd unit can't inject uids.
|
||||
assert "LEFT4ME_TEST_SANDBOX_UID" in text
|
||||
assert "LEFT4ME_TEST_LEFT4ME_UID" in text
|
||||
# Collision guard: two lowerdirs deriving the same idmap target die loudly.
|
||||
assert "seen_idmap_targets" in text
|
||||
|
||||
|
||||
def test_deploy_script_installs_overlay_helper_with_executable_mode():
|
||||
script = DEPLOY_SCRIPT.read_text()
|
||||
assert "/usr/local/libexec/left4me/left4me-overlay" in script
|
||||
|
|
|
|||
|
|
@ -1,325 +0,0 @@
|
|||
# Idmapped lowerdirs for left4me kernel-overlayfs
|
||||
|
||||
## Context
|
||||
|
||||
Kernel-overlayfs copy-up preserves the lower-layer file's owner and mode in the
|
||||
upperdir. Script overlays today are built by `left4me-script-sandbox` running as
|
||||
uid `l4d2-sandbox`, and the helper finalizes them as `l4d2-sandbox:l4d2-sandbox
|
||||
0755`. When the L4D2 server (uid `left4me`) tries to write into a directory
|
||||
that exists only in the lower layer — e.g. SourceMod's `addons/sourcemod/logs/`
|
||||
for log rotation — copy-up succeeds but the result is `l4d2-sandbox`-owned, so
|
||||
the write fails `EACCES`. Workshop overlays are unaffected because the web app
|
||||
(uid `left4me`) builds them as `left4me`-owned with symlinks into a `left4me`-
|
||||
owned cache.
|
||||
|
||||
We considered four fixes (chown-flip on every rebuild, shared group, collapse
|
||||
sandbox uid into `left4me`, idmapped lowerdir bind mounts). The user chose the
|
||||
idmap path: disk state stays untouched, the mount stack remaps `l4d2-sandbox →
|
||||
left4me` at mount time, kernel-overlayfs sees a `left4me`-owned lower layer,
|
||||
and copy-up creates upperdir entries owned by `left4me` naturally. No
|
||||
ownership flipping, no shared group, no security regression.
|
||||
|
||||
Outcome: `sm_cvar` writes succeed, SM logs land in `runtime/<n>/upper/...`,
|
||||
and any future "writes into a sandbox-built lower layer" works without
|
||||
left4me-specific plumbing.
|
||||
|
||||
## Environment confirmed
|
||||
|
||||
Test server: Debian Trixie, kernel `6.12.86+deb13-amd64`, util-linux supports
|
||||
`mount --map-users <on_disk>:<in_mount>:<count>`. Idmapped lowerdirs for
|
||||
overlayfs landed mainline in 6.6, so 6.12 is fine. Verified end-to-end on
|
||||
`/var/lib/left4me/` (ext4) in a temp dir on 2026-05-14:
|
||||
|
||||
1. Source dir owned `l4d2-sandbox:l4d2-sandbox` (uid 981).
|
||||
2. `mount --bind --map-users=981:980:1 --map-groups=981:980:1 src dst` — `dst`
|
||||
view shows uid 980 (left4me).
|
||||
3. Overlay mount with the idmapped path as `lowerdir=` — merged view also
|
||||
shows uid 980.
|
||||
4. `sudo -u left4me touch merged/addons/sourcemod/logs/L_test.log` — succeeds.
|
||||
`sudo -u left4me bash -c "echo x >> merged/file-from-sandbox.txt"` —
|
||||
succeeds (copy-up of existing file).
|
||||
5. `upper/` after writes is entirely `left4me`-owned (uid 980).
|
||||
|
||||
Caveat surfaced during testing: `--map-users` direction is **on-disk uid
|
||||
first**, not "inner-namespace uid". The util-linux man page calls it
|
||||
`<inner>:<outer>:<count>` but `<inner>` means "the filesystem's native view"
|
||||
(on disk) and `<outer>` means "what the mount exposes outward". Easy to get
|
||||
wrong; do not trust the man page wording.
|
||||
|
||||
## Approach
|
||||
|
||||
The privileged mount helper grows one step before the overlay mount: for each
|
||||
lowerdir whose owning uid is `l4d2-sandbox`, create an idmapped bind mount at
|
||||
`runtime/<n>/idmap/<basename>` that remaps that uid to `left4me`. Use the
|
||||
idmapped paths (instead of raw paths) in the `lowerdir=` string passed to
|
||||
`mount -t overlay`. On umount, tear the idmap binds down after the overlay
|
||||
itself is unmounted.
|
||||
|
||||
Lowerdirs already owned by `left4me` (workshop builds, `installation/`,
|
||||
caches) bypass the idmap step and are used as-is, so workshop overlays keep
|
||||
working without behavior change.
|
||||
|
||||
## Execution shape
|
||||
|
||||
Implementation will be driven by `superpowers:subagent-driven-development`:
|
||||
fresh implementer subagent per task, followed by a spec-compliance reviewer
|
||||
then a code-quality reviewer. The project's `AGENTS.md` forbids git
|
||||
worktrees, so all work happens in the live tree. Commits land directly on
|
||||
`master` per the user-confirmed project pattern.
|
||||
|
||||
Tasks ordered for review-friendly progression. Each task is independently
|
||||
committable; the deploy/verify step at the end exercises the whole chain on
|
||||
the real test server.
|
||||
|
||||
### Task 1 — Idmap bind mounts in `left4me-overlay`
|
||||
|
||||
Edit `deploy/files/usr/local/libexec/left4me/left4me-overlay` and
|
||||
`l4d2host/tests/test_overlay_helper.py` together (TDD: write failing
|
||||
PRINT_ONLY-mode tests first, then make them pass).
|
||||
|
||||
Behavior to add:
|
||||
- Resolve `l4d2_sandbox_uid` and `left4me_uid` (and gids) via `pwd.getpwnam`
|
||||
/ `grp.getgrnam`. Hard fail with a clear message if either is missing.
|
||||
- On `mount <name>`: before constructing the `lowerdir=` string, for each
|
||||
resolved lowerdir, stat it; if the top-level dir's `st_uid` equals
|
||||
`l4d2_sandbox_uid`, create `runtime/<n>/idmap/<basename>` (mode `0700`,
|
||||
root-owned), and if it's not already a mountpoint, exec `mount --bind
|
||||
--map-users=<l4d2_sandbox_uid>:<left4me_uid>:1
|
||||
--map-groups=<l4d2_sandbox_gid>:<left4me_gid>:1 <src> <target>`. Use
|
||||
numeric uids/gids in the argv. Substitute the idmap path into the
|
||||
`lowerdir=` colon string in place of the original path.
|
||||
- On `umount <name>`: after the existing `umount` of `merged`, iterate
|
||||
`runtime/<n>/idmap/*`, `umount` each that is a mountpoint, then
|
||||
`shutil.rmtree(runtime/<n>/idmap, ignore_errors=True)`. Idempotent.
|
||||
- PRINT_ONLY mode emits the bind-mount argv (one line per bind) before the
|
||||
overlay-mount argv, same shell-quoting style.
|
||||
|
||||
Tests to add to `test_overlay_helper.py` (reuse PRINT_ONLY harness):
|
||||
- `test_mount_idmaps_sandbox_owned_lowerdir` — tmp lower owned by
|
||||
faked-sandbox uid, assert helper emits `mount --bind --map-users=...`
|
||||
argv and the overlay `lowerdir=` references the idmap path.
|
||||
- `test_mount_skips_idmap_for_left4me_owned_lowerdir` — assert no bind
|
||||
argv, raw path in `lowerdir=`.
|
||||
- `test_umount_unwinds_idmap_binds` — pre-seed an idmap subdir as a
|
||||
mountpoint sentinel; assert the umount sequence in PRINT_ONLY includes
|
||||
the bind teardown after the overlay umount.
|
||||
|
||||
Uid lookup in tests: monkeypatch `pwd.getpwnam` to return synthetic uids
|
||||
matching what the test's `chown` set up. (No root required.)
|
||||
|
||||
### Task 2 — Deploy-artifact regression test
|
||||
|
||||
Edit `deploy/tests/test_deploy_artifacts.py`. Add a single test that opens
|
||||
`deploy/files/usr/local/libexec/left4me/left4me-overlay` and asserts the
|
||||
strings `--map-users` and `runtime/` followed by `idmap` (or similar
|
||||
identifying marker) are present. Cheap guard against silent regression of
|
||||
the deploy artifact.
|
||||
|
||||
### Task 3 — Deploy README mirror note
|
||||
|
||||
Edit `deploy/README.md`. Add one line under the existing ckn-bw mirror
|
||||
notes flagging that the helper file change must be picked up by
|
||||
`bundles/left4me/` in ckn-bw (no new group, user, or unit needed).
|
||||
|
||||
### Task 4 — Persist the plan in-repo
|
||||
|
||||
Per `AGENTS.md`: "the persisted artifact must end up under
|
||||
`docs/superpowers/` and be committed." Copy this scratch plan to
|
||||
`docs/superpowers/plans/2026-05-14-overlay-idmap.md` and commit it. Do
|
||||
this as a separate commit from Task 1 so the plan lands before the
|
||||
implementation.
|
||||
|
||||
### Task 5 — Deploy and verify on `left4.me`
|
||||
|
||||
Out-of-band, after the code tasks land:
|
||||
1. ckn-bw apply (or scp the helper into place on the test server) to get
|
||||
the new helper deployed.
|
||||
2. Stop server 2: `sudo systemctl stop left4me-server@2`.
|
||||
3. Clear the stale `l4d2-sandbox`-owned upperdir SM dirs:
|
||||
`sudo rm -rf /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}`.
|
||||
4. Start server 2: `sudo systemctl start left4me-server@2`.
|
||||
5. Confirm `journalctl -u left4me-server@2 -o cat -n 50` shows the new
|
||||
`mount --bind --map-users=...` line.
|
||||
6. RCON `sm_cvar nb_update_frequency 0.0333`. Expect no `Platform returned
|
||||
error: "Permission denied"` log line.
|
||||
7. `sudo ls -ln /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/`.
|
||||
Expect uid 980 (left4me).
|
||||
8. Restart again to confirm idempotency: idmap binds set up fresh, no
|
||||
leftover mounts from prior start.
|
||||
|
||||
## Files to modify
|
||||
|
||||
### 1. `deploy/files/usr/local/libexec/left4me/left4me-overlay`
|
||||
|
||||
Single privileged code path; everything else flows from here.
|
||||
|
||||
**Add a helper function** to decide whether a path needs idmapping. Stat the
|
||||
directory; if its `st_uid` matches the resolved `l4d2-sandbox` uid, return the
|
||||
idmapped path under `runtime/<n>/idmap/`; otherwise return the input path
|
||||
unchanged.
|
||||
|
||||
**In `cmd_mount`**, before constructing `lowerdir=`:
|
||||
1. `os.makedirs(runtime_dir / "idmap", exist_ok=True)` (root-owned, mode
|
||||
`0o700`; only the helper writes here).
|
||||
2. Resolve `l4d2_sandbox_uid = pwd.getpwnam("l4d2-sandbox").pw_uid` and
|
||||
`left4me_uid = pwd.getpwnam("left4me").pw_uid`. Cache. Fail fast with a
|
||||
clear message if either user is missing.
|
||||
3. For each `lowerdir` in the resolved list, compute
|
||||
`idmapped_path(lowerdir)`. If remapping is required:
|
||||
- Create the target directory under `runtime/<n>/idmap/<basename>` if
|
||||
missing.
|
||||
- Skip the bind if it's already mounted there (`os.path.ismount`).
|
||||
- Otherwise exec `mount --bind
|
||||
--map-users=<l4d2_sandbox_uid>:<left4me_uid>:1
|
||||
--map-groups=<l4d2_sandbox_gid>:<left4me_gid>:1 <src> <target>`.
|
||||
**Direction note**: first arg is the on-disk uid, second arg is the
|
||||
uid the mount exposes. We verified empirically that this is what the
|
||||
kernel honors despite ambiguous man-page wording.
|
||||
4. Pass the (possibly idmapped) paths into the `lowerdir=` colon string in
|
||||
the same order.
|
||||
|
||||
**In `cmd_umount`**, after the existing overlay umount:
|
||||
- For each subdirectory under `runtime/<n>/idmap/`, if it's a mountpoint,
|
||||
`umount` it.
|
||||
- `shutil.rmtree(runtime_dir / "idmap", ignore_errors=True)` after all binds
|
||||
are gone.
|
||||
- Idempotent: re-running after the dir is gone is a no-op.
|
||||
|
||||
**PRINT_ONLY mode**: emit the bind-mount argv before the overlay-mount argv,
|
||||
on separate lines, so test assertions can match. Same shell-quoting.
|
||||
|
||||
**Allowlist**: no change needed — the idmap binds land under `runtime/`,
|
||||
which is already write-permitted for the helper.
|
||||
|
||||
### 2. `l4d2host/tests/test_overlay_helper.py`
|
||||
|
||||
Add tests using the existing PRINT_ONLY harness:
|
||||
|
||||
- `test_mount_idmaps_sandbox_owned_lowerdir`: create a tmp lowerdir, `chown`
|
||||
it to a fake-`l4d2-sandbox` (use `monkeypatch` on the uid lookup if running
|
||||
unprivileged), run helper in PRINT_ONLY, assert a `mount --bind
|
||||
--map-users=...` line appears and the `lowerdir=` string references the
|
||||
idmap path.
|
||||
- `test_mount_skips_idmap_for_left4me_owned_lowerdir`: tmp lowerdir owned by
|
||||
the test user, assert no bind-mount argv emitted and `lowerdir=`
|
||||
references the raw path.
|
||||
- `test_umount_unwinds_idmap_binds`: pre-create `runtime/<n>/idmap/foo` as a
|
||||
mountpoint sentinel, assert the PRINT_ONLY umount sequence includes the
|
||||
bind-mount teardown before the overlay umount? Actually overlay-first,
|
||||
then binds — match the helper order.
|
||||
|
||||
Reuse the existing `LEFT4ME_OVERLAY_PRINT_ONLY=1` plumbing rather than
|
||||
inventing a new mode.
|
||||
|
||||
### 3. `deploy/tests/test_deploy_artifacts.py`
|
||||
|
||||
Add a grep-style assertion that the helper file contains the strings
|
||||
`--map-users` and `idmap` so the deploy artifact can't silently regress.
|
||||
|
||||
### 4. `deploy/README.md`
|
||||
|
||||
One-line mirror note: ckn-bw's `bundles/left4me/` ships the helper verbatim,
|
||||
so no new bundle-side change is needed beyond updating the file. No new
|
||||
group, no new user, no new systemd unit. Flag this explicitly so the next
|
||||
deploy-to-prod step is "rebuild the helper file in ckn-bw, `bw apply
|
||||
ovh.left4me`".
|
||||
|
||||
## Migration
|
||||
|
||||
No on-disk schema change. Existing overlays keep their current ownership
|
||||
(`l4d2-sandbox`-owned for script builds, `left4me`-owned for workshop
|
||||
builds). The mount helper picks the right path per lowerdir at next
|
||||
`systemctl start <instance>`.
|
||||
|
||||
Already-running instances on the test server pick up the change after a
|
||||
service restart. Live SourceMod sessions whose `addons/sourcemod/logs/`
|
||||
copy-up is already broken in upperdir need a fix too: the upperdir entries
|
||||
are `l4d2-sandbox:l4d2-sandbox 0755` from the previous broken copy-up. The
|
||||
helper doesn't touch upper/ on mount, so those stale entries persist.
|
||||
|
||||
Two safe migration options:
|
||||
|
||||
1. Manual: on the test server, stop server 2, `rm -rf
|
||||
runtime/2/upper/left4dead2/addons/sourcemod/logs runtime/2/upper/left4dead2/addons/sourcemod/data`,
|
||||
start it again. Copy-up will redo with idmapped lower → `left4me`-owned
|
||||
upper.
|
||||
2. Automatic: have `start_instance` proactively delete known-SM writable
|
||||
dirs from `upper/` if their uid is `l4d2-sandbox`. Out of scope for this
|
||||
change unless we hit it again.
|
||||
|
||||
Recommend option 1 — one-shot, no code change.
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end test on `left4.me`:
|
||||
|
||||
1. `bw apply ovh.left4me` (or scp the updated helper into place).
|
||||
2. Stop server 2: `sudo systemctl stop left4me-server@2`.
|
||||
3. Clean the stale broken SM upperdir: `sudo rm -rf
|
||||
/var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}`.
|
||||
4. Start server 2: `sudo systemctl start left4me-server@2`.
|
||||
5. From inside `left4me-overlay mount` argv (check `journalctl -u
|
||||
left4me-server@2 -o cat -n 50`), confirm `mount --bind --map-users=...`
|
||||
was executed.
|
||||
6. RCON to server 2, `sm_cvar nb_update_frequency 0.0333`. Expect no
|
||||
`Platform returned error: "Permission denied"` log line.
|
||||
7. `ls -ln
|
||||
/var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/`.
|
||||
Expect files owned by `left4me`'s numeric uid.
|
||||
8. `sudo umount /var/lib/left4me/runtime/2/merged` should still work; then
|
||||
verify `runtime/2/idmap/` is cleaned up by `ExecStopPost`. Restart and
|
||||
confirm idempotent (no leftover binds).
|
||||
|
||||
Local tests:
|
||||
|
||||
- `pytest l4d2host/tests/test_overlay_helper.py -q`
|
||||
- `pytest deploy/tests/test_deploy_artifacts.py -q`
|
||||
|
||||
## Risks and edge cases
|
||||
|
||||
- **Workshop overlay misidentification**: a workshop overlay with a
|
||||
`l4d2-sandbox`-owned subdir somehow (e.g. partial migration) would get
|
||||
idmapped despite containing `left4me`-owned files. Files with other uids
|
||||
through an idmap appear as the overflow uid (`nobody`/65534), which would
|
||||
break reads. Mitigation: check ownership of the **top-level overlay
|
||||
directory** as the trigger, not file-by-file. If the top is sandbox-owned,
|
||||
trust the whole tree; if the top is left4me-owned, no idmap. This matches
|
||||
what each builder actually produces.
|
||||
- **`installation/` and caches**: always `left4me`-owned, never idmapped.
|
||||
- **Symlinks inside script overlays**: idmap operates at the mount level,
|
||||
not per-inode. Symlink ownership translates the same as files. Targets
|
||||
inside the overlay resolve through the same mount. Targets outside (none
|
||||
in script overlays today; workshop ones don't take this code path) would
|
||||
not be affected.
|
||||
- **Mount namespace**: the helper runs in PID 1's mount namespace via the
|
||||
unit's `ExecStartPre=+nsenter ...`. Bind mounts created there persist
|
||||
until the matching `ExecStopPost` umount, exactly like the overlay mount
|
||||
itself.
|
||||
- **Crash mid-build**: idmap binds are created only at `mount` time, not at
|
||||
build time. A crashed build leaves no orphan mounts.
|
||||
- **Crash mid-start (ExecStartPre fails between bind and overlay mount)**:
|
||||
systemd's `Restart=on-failure` re-invokes ExecStartPre. The helper checks
|
||||
`os.path.ismount` on each idmap target and skips already-mounted ones.
|
||||
Idempotent.
|
||||
- **`runtime/<n>/idmap/` cleanup on `_purge_instance`**: existing
|
||||
`shutil.rmtree(runtime_dir)` after `disable_service` already triggers the
|
||||
helper's umount sequence, which removes the idmap dir. No new code.
|
||||
- **util-linux flag form**: prefer `--map-users <inner>:<outer>:<count>` and
|
||||
`--map-groups` (numeric uids/gids resolved by the helper) over the
|
||||
`X-mount.idmap=` mount-option syntax — clearer and easier to log.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Web app uid split (`l4d2-web` separate from `left4me`) — orthogonal,
|
||||
rejected for this change.
|
||||
- Gameserver uid split (separating the gameserver-runtime uid from
|
||||
`left4me`) — planned for a later session. **One forward-compat
|
||||
coupling**: the helper looks up `pwd.getpwnam("left4me")` as the in-mount
|
||||
target uid. When the gameserver moves to its own user (e.g. `l4d2-game`),
|
||||
change that one string. Everything else (script-sandbox uid, workshop
|
||||
builder uid, file-tree endpoint, idmap cleanup) is uid-agnostic.
|
||||
- Replacing `l4d2-sandbox` with a different uid scheme — kept as defense in
|
||||
depth.
|
||||
- Spec doc updates (including the bubblewrap → systemd-run wording
|
||||
correction in the existing script-overlay spec): dropped per user
|
||||
decision; this change ships plan-only.
|
||||
|
|
@ -41,11 +41,6 @@ def _run(args: list[str], root: Path, extra_env: dict[str, str] | None = None) -
|
|||
**os.environ,
|
||||
"LEFT4ME_ROOT": str(root),
|
||||
"LEFT4ME_OVERLAY_PRINT_ONLY": "1",
|
||||
# Inject synthetic user ids so tests work without real system users.
|
||||
"LEFT4ME_TEST_SANDBOX_UID": str(FAKE_SANDBOX_UID),
|
||||
"LEFT4ME_TEST_SANDBOX_GID": str(FAKE_SANDBOX_GID),
|
||||
"LEFT4ME_TEST_LEFT4ME_UID": str(FAKE_LEFT4ME_UID),
|
||||
"LEFT4ME_TEST_LEFT4ME_GID": str(FAKE_LEFT4ME_GID),
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
|
@ -161,136 +156,6 @@ def test_rejects_empty_lowerdir_entry(tmp_path: Path) -> None:
|
|||
assert "empty entry" in result.stderr
|
||||
|
||||
|
||||
FAKE_SANDBOX_UID = 7001
|
||||
FAKE_SANDBOX_GID = 7001
|
||||
FAKE_LEFT4ME_UID = 7002
|
||||
FAKE_LEFT4ME_GID = 7002
|
||||
|
||||
|
||||
def _setup_instance_with_uid(
|
||||
root: Path,
|
||||
name: str = "alpha",
|
||||
lowerdir_uid: int = FAKE_LEFT4ME_UID,
|
||||
lowerdir_gid: int = FAKE_LEFT4ME_GID,
|
||||
) -> Path:
|
||||
"""Like _setup_instance but chowns the lowerdir to a specific uid/gid."""
|
||||
overlay_dir = root / "overlays" / "workshop"
|
||||
overlay_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chown(overlay_dir, lowerdir_uid, lowerdir_gid)
|
||||
except PermissionError:
|
||||
pass # tests not running as root — uid won't match; that's fine for the "skips idmap" test
|
||||
(root / "installation").mkdir(parents=True, exist_ok=True)
|
||||
lowerdirs = [str(overlay_dir), str(root / "installation")]
|
||||
inst_dir = root / "instances" / name
|
||||
inst_dir.mkdir(parents=True, exist_ok=True)
|
||||
(inst_dir / "instance.env").write_text(
|
||||
f"L4D2_LOWERDIRS={':'.join(lowerdirs)}\n"
|
||||
)
|
||||
runtime = root / "runtime" / name
|
||||
(runtime / "upper").mkdir(parents=True, exist_ok=True)
|
||||
(runtime / "work").mkdir(parents=True, exist_ok=True)
|
||||
(runtime / "merged").mkdir(parents=True, exist_ok=True)
|
||||
return overlay_dir
|
||||
|
||||
|
||||
|
||||
def test_mount_idmaps_sandbox_owned_lowerdir(tmp_path: Path) -> None:
|
||||
"""A lowerdir owned by l4d2-sandbox uid triggers an idmap bind mount.
|
||||
|
||||
The overlay lowerdir= string must reference the idmap path, not the raw
|
||||
overlay path. A mount --bind --map-users/--map-groups argv must be emitted
|
||||
before the overlay mount argv.
|
||||
"""
|
||||
overlay_dir = _setup_instance_with_uid(
|
||||
tmp_path, lowerdir_uid=FAKE_SANDBOX_UID, lowerdir_gid=FAKE_SANDBOX_GID
|
||||
)
|
||||
try:
|
||||
os.chown(overlay_dir, FAKE_SANDBOX_UID, FAKE_SANDBOX_GID)
|
||||
except PermissionError:
|
||||
pytest.skip("chown requires root — skip on unprivileged runner")
|
||||
|
||||
result = _run(["mount", "alpha"], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
lines = [l for l in result.stdout.splitlines() if l.strip()]
|
||||
assert len(lines) == 2, f"expected 2 argv lines, got: {result.stdout!r}"
|
||||
|
||||
bind_parts = shlex.split(lines[0])
|
||||
assert bind_parts[0] == "/bin/mount"
|
||||
assert "--bind" in bind_parts
|
||||
assert f"--map-users={FAKE_SANDBOX_UID}:{FAKE_LEFT4ME_UID}:1" in bind_parts
|
||||
assert f"--map-groups={FAKE_SANDBOX_GID}:{FAKE_LEFT4ME_GID}:1" in bind_parts
|
||||
assert bind_parts[-2] == str(overlay_dir)
|
||||
idmap_target = str(tmp_path / "runtime" / "alpha" / "idmap" / "overlays_workshop")
|
||||
assert bind_parts[-1] == idmap_target
|
||||
|
||||
overlay_parts = shlex.split(lines[1])
|
||||
assert overlay_parts[0] == "/bin/mount"
|
||||
assert overlay_parts[1:3] == ["-t", "overlay"]
|
||||
options = overlay_parts[5]
|
||||
assert f"lowerdir={idmap_target}:" in options, \
|
||||
f"lowerdir should start with idmap path; got: {options!r}"
|
||||
assert str(overlay_dir) not in options, \
|
||||
f"raw overlay path should not appear in lowerdir; got: {options!r}"
|
||||
|
||||
|
||||
def test_mount_skips_idmap_for_left4me_owned_lowerdir(tmp_path: Path) -> None:
|
||||
"""A lowerdir already owned by the left4me uid needs no idmap bind mount."""
|
||||
overlay_dir = _setup_instance_with_uid(
|
||||
tmp_path, lowerdir_uid=FAKE_LEFT4ME_UID, lowerdir_gid=FAKE_LEFT4ME_GID
|
||||
)
|
||||
# Best-effort chown to the left4me uid — skip if not root.
|
||||
try:
|
||||
os.chown(overlay_dir, FAKE_LEFT4ME_UID, FAKE_LEFT4ME_GID)
|
||||
except PermissionError:
|
||||
# Without root, st_uid is 0 or our own uid; neither matches FAKE_SANDBOX_UID,
|
||||
# so the helper will correctly skip the idmap bind either way.
|
||||
pass
|
||||
|
||||
result = _run(["mount", "alpha"], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
lines = [l for l in result.stdout.splitlines() if l.strip()]
|
||||
assert len(lines) == 1, f"expected 1 argv line (no bind mount), got: {result.stdout!r}"
|
||||
|
||||
overlay_parts = shlex.split(lines[0])
|
||||
assert overlay_parts[0] == "/bin/mount"
|
||||
assert "--bind" not in overlay_parts
|
||||
options = overlay_parts[5]
|
||||
idmap_subdir = str(tmp_path / "runtime" / "alpha" / "idmap")
|
||||
assert idmap_subdir not in options, f"idmap path should not appear; got: {options!r}"
|
||||
assert str(overlay_dir) in options
|
||||
|
||||
|
||||
def test_umount_unwinds_idmap_binds(tmp_path: Path) -> None:
|
||||
"""umount emits bind-umount lines for each idmap subdir, after the overlay umount."""
|
||||
_setup_instance(tmp_path)
|
||||
# Pre-seed an idmap subdir as if a previous mount had set it up.
|
||||
idmap_dir = tmp_path / "runtime" / "alpha" / "idmap"
|
||||
idmap_dir.mkdir(parents=True)
|
||||
idmap_sub = idmap_dir / "workshop"
|
||||
idmap_sub.mkdir()
|
||||
|
||||
result = _run(["umount", "alpha"], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
lines = [l for l in result.stdout.splitlines() if l.strip()]
|
||||
assert len(lines) >= 2, f"expected at least 2 argv lines, got: {result.stdout!r}"
|
||||
|
||||
# First line: overlay umount
|
||||
overlay_umount_parts = shlex.split(lines[0])
|
||||
assert overlay_umount_parts == [
|
||||
"/bin/umount",
|
||||
str(tmp_path / "runtime" / "alpha" / "merged"),
|
||||
]
|
||||
|
||||
# Subsequent lines: bind umounts for each idmap subdir
|
||||
bind_umount_parts = shlex.split(lines[1])
|
||||
assert bind_umount_parts[0] == "/bin/umount"
|
||||
assert bind_umount_parts[-1] == str(idmap_sub)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="user.* xattrs are Linux-only")
|
||||
def test_rejects_upperdir_with_fuseoverlayfs_xattr(tmp_path: Path) -> None:
|
||||
_setup_instance(tmp_path)
|
||||
|
|
|
|||
Loading…
Reference in a new issue