From 93a60befb6e9a3dd5455b19f3bb82a21dc12a34c Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 12:26:28 +0200 Subject: [PATCH] refactor(l4d2-host): start/stop/delete go through OverlayMounter; drop fuse module Replace direct fuse-overlayfs / fusermount3 subprocess calls in start_instance / stop_instance / delete_instance with the existing OverlayMounter abstraction, now backed by KernelOverlayFSMounter. Adds an os.path.ismount guard at the top of start_instance so a kernel-level overlay that survived a web-worker crash isn't double- mounted (kernel mounts persist when the cgroup dies, unlike fuse daemons). Delete the unused FuseOverlayFSMounter module. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2host/fs/fuse_overlayfs.py | 49 -------------------- l4d2host/instances.py | 41 ++++++++++------- l4d2host/tests/test_lifecycle.py | 78 +++++++++++++++++++++++++------- 3 files changed, 87 insertions(+), 81 deletions(-) delete mode 100644 l4d2host/fs/fuse_overlayfs.py diff --git a/l4d2host/fs/fuse_overlayfs.py b/l4d2host/fs/fuse_overlayfs.py deleted file mode 100644 index 444e3e5..0000000 --- a/l4d2host/fs/fuse_overlayfs.py +++ /dev/null @@ -1,49 +0,0 @@ -from pathlib import Path -from typing import Callable - -from l4d2host.fs.base import OverlayMounter -from l4d2host.process import run_command - - -class FuseOverlayFSMounter(OverlayMounter): - def mount( - self, - *, - lowerdirs: str, - upperdir: Path, - workdir: Path, - merged: Path, - on_stdout: Callable[[str], None] | None = None, - on_stderr: Callable[[str], None] | None = None, - passthrough: bool = False, - should_cancel: Callable[[], bool] | None = None, - ) -> None: - run_command( - [ - "fuse-overlayfs", - "-o", - f"lowerdir={lowerdirs},upperdir={upperdir},workdir={workdir}", - str(merged), - ], - on_stdout=on_stdout, - on_stderr=on_stderr, - passthrough=passthrough, - should_cancel=should_cancel, - ) - - def unmount( - self, - *, - merged: Path, - on_stdout: Callable[[str], None] | None = None, - on_stderr: Callable[[str], None] | None = None, - passthrough: bool = False, - should_cancel: Callable[[], bool] | None = None, - ) -> None: - run_command( - ["fusermount3", "-u", str(merged)], - on_stdout=on_stdout, - on_stderr=on_stderr, - passthrough=passthrough, - should_cancel=should_cancel, - ) diff --git a/l4d2host/instances.py b/l4d2host/instances.py index 8128114..f0244de 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -1,10 +1,11 @@ +import os from pathlib import Path import shutil import subprocess from typing import Callable +from l4d2host.fs.kernel_overlayfs import KernelOverlayFSMounter from l4d2host.paths import DEFAULT_LEFT4ME_ROOT, get_left4me_root, overlay_path, validate_instance_name -from l4d2host.process import run_command from l4d2host.service_control import start_service, stop_service from l4d2host.spec import load_spec @@ -12,6 +13,9 @@ from l4d2host.spec import load_spec from l4d2host.logging import emit_step +_mounter = KernelOverlayFSMounter() + + DEFAULT_ROOT = DEFAULT_LEFT4ME_ROOT @@ -82,18 +86,23 @@ def start_instance( env = _load_instance_env(instance_dir / "instance.env") + merged = runtime_dir / "merged" + if os.path.ismount(merged): + # Kernel overlayfs mounts persist when the web worker dies (unlike + # fuse daemons, which were reaped with their cgroup). Refuse rather + # than double-mount. + raise subprocess.CalledProcessError( + returncode=1, + cmd=["start_instance"], + stderr=f"runtime overlay already mounted at {merged}; refusing to double-mount", + ) + emit_step("mounting runtime overlay...", on_stdout, passthrough) - run_command( - [ - "fuse-overlayfs", - "-o", - ( - f"lowerdir={env['L4D2_LOWERDIRS']}," - f"upperdir={runtime_dir / 'upper'}," - f"workdir={runtime_dir / 'work'}" - ), - str(runtime_dir / "merged"), - ], + _mounter.mount( + lowerdirs=env["L4D2_LOWERDIRS"], + upperdir=runtime_dir / "upper", + workdir=runtime_dir / "work", + merged=merged, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, @@ -137,8 +146,8 @@ def stop_instance( ) emit_step("unmounting runtime overlay (if mounted)...", on_stdout, passthrough) try: - run_command( - ["fusermount3", "-u", str(root / "runtime" / name / "merged")], + _mounter.unmount( + merged=root / "runtime" / name / "merged", on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, @@ -180,8 +189,8 @@ def delete_instance( emit_step("unmounting runtime overlay (if mounted)...", on_stdout, passthrough) try: - run_command( - ["fusermount3", "-u", str(runtime_dir / "merged")], + _mounter.unmount( + merged=runtime_dir / "merged", on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index 5c369d1..de9cdfd 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -27,15 +27,51 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: ) (instance_dir / "server.cfg").write_text("sv_consistency 1") - monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) start_instance("alpha", root=tmp_path) - assert calls[0][0] == "fuse-overlayfs" + assert calls[0] == [ + "sudo", + "-n", + "/usr/local/libexec/left4me/left4me-overlay", + "mount", + "alpha", + ] assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"] +def test_start_refuses_to_double_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(list(cmd)) + + instance_dir = tmp_path / "instances" / "alpha" + runtime_dir = tmp_path / "runtime" / "alpha" + (runtime_dir / "merged").mkdir(parents=True) + instance_dir.mkdir(parents=True) + (instance_dir / "instance.env").write_text("L4D2_PORT=27015\nL4D2_ARGS=\nL4D2_LOWERDIRS=/x\n") + (instance_dir / "server.cfg").write_text("") + + merged = runtime_dir / "merged" + + def fake_ismount(path): + return Path(path) == merged + + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.instances.os.path.ismount", fake_ismount) + + with pytest.raises(subprocess.CalledProcessError) as exc_info: + start_instance("alpha", root=tmp_path) + + assert "already mounted" in (exc_info.value.stderr or "") + assert calls == [], "no mount/start commands must be issued when refusing" + + def test_delete_missing_is_noop(tmp_path: Path) -> None: delete_instance("missing", root=tmp_path) @@ -56,7 +92,7 @@ def test_delete_succeeds_when_stop_service_fails(tmp_path: Path, monkeypatch: py (tmp_path / "instances" / "alpha").mkdir(parents=True) (tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True) - monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) delete_instance("alpha", root=tmp_path) @@ -86,7 +122,7 @@ def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytes (tmp_path / "instances" / "alpha").mkdir(parents=True) (tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True) - monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) delete_instance("alpha", root=tmp_path) @@ -97,47 +133,57 @@ def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytes def test_stop_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - fusermount_calls: list[list[str]] = [] + umount_calls: list[list[str]] = [] def fake_run_command(cmd, **kwargs): del kwargs - if cmd and cmd[0] == "fusermount3": - fusermount_calls.append(list(cmd)) + if cmd[:4] == [ + "sudo", + "-n", + "/usr/local/libexec/left4me/left4me-overlay", + "umount", + ]: + umount_calls.append(list(cmd)) raise subprocess.CalledProcessError( returncode=1, cmd=list(cmd), - stderr="fusermount3: failed to unmount /var/lib/left4me/runtime/alpha/merged: Invalid argument", + stderr="umount: /var/lib/left4me/runtime/alpha/merged: not mounted", ) - monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) stop_instance("alpha", root=tmp_path) - assert fusermount_calls, "stop must always attempt fusermount3 -u (no preflight)" + assert umount_calls, "stop must always attempt the overlay helper (no preflight)" def test_delete_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - fusermount_calls: list[list[str]] = [] + umount_calls: list[list[str]] = [] def fake_run_command(cmd, **kwargs): del kwargs - if cmd and cmd[0] == "fusermount3": - fusermount_calls.append(list(cmd)) + if cmd[:4] == [ + "sudo", + "-n", + "/usr/local/libexec/left4me/left4me-overlay", + "umount", + ]: + umount_calls.append(list(cmd)) raise subprocess.CalledProcessError( returncode=1, cmd=list(cmd), - stderr="fusermount3: entry for merged not found in /etc/mtab", + stderr="umount: /var/lib/left4me/runtime/alpha/merged: not mounted", ) (tmp_path / "instances" / "alpha").mkdir(parents=True) (tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True) - monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) delete_instance("alpha", root=tmp_path) - assert fusermount_calls, "delete must always attempt fusermount3 -u (no preflight)" + assert umount_calls, "delete must always attempt the overlay helper (no preflight)" assert not (tmp_path / "instances" / "alpha").exists() assert not (tmp_path / "runtime" / "alpha").exists()