diff --git a/components/l4d2-host-lib/src/l4d2host/fs/__init__.py b/components/l4d2-host-lib/src/l4d2host/fs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/l4d2-host-lib/src/l4d2host/fs/base.py b/components/l4d2-host-lib/src/l4d2host/fs/base.py new file mode 100644 index 0000000..c34f8d4 --- /dev/null +++ b/components/l4d2-host-lib/src/l4d2host/fs/base.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Callable + + +class OverlayMounter(ABC): + @abstractmethod + 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, + ) -> None: + raise NotImplementedError + + @abstractmethod + def unmount( + self, + *, + merged: Path, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + ) -> None: + raise NotImplementedError diff --git a/components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py b/components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py new file mode 100644 index 0000000..3985288 --- /dev/null +++ b/components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py @@ -0,0 +1,45 @@ +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, + ) -> 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, + ) + + def unmount( + self, + *, + merged: Path, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + ) -> None: + run_command( + ["fusermount3", "-u", str(merged)], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) diff --git a/components/l4d2-host-lib/src/l4d2host/instances.py b/components/l4d2-host-lib/src/l4d2host/instances.py index e3f1462..7c5995d 100644 --- a/components/l4d2-host-lib/src/l4d2host/instances.py +++ b/components/l4d2-host-lib/src/l4d2host/instances.py @@ -1,6 +1,8 @@ from pathlib import Path +import shutil from typing import Callable +from l4d2host.process import run_command from l4d2host.spec import load_spec from l4d2host.systemd_user import daemon_reload, ensure_template_unit @@ -44,3 +46,104 @@ def initialize_instance( if root.resolve() == DEFAULT_ROOT: ensure_template_unit() daemon_reload(on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + + +def _load_instance_env(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + for line in path.read_text().splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + result[key] = value + return result + + +def start_instance( + name: str, + *, + root: Path = DEFAULT_ROOT, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, +) -> None: + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + + env = _load_instance_env(instance_dir / "instance.env") + + run_command( + [ + "fuse-overlayfs", + "-o", + ( + f"lowerdir={env['L4D2_LOWERDIRS']}," + f"upperdir={runtime_dir / 'upper'}," + f"workdir={runtime_dir / 'work'}" + ), + str(runtime_dir / "merged"), + ], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) + + target_cfg = runtime_dir / "merged" / "left4dead2" / "cfg" / "server.cfg" + target_cfg.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(instance_dir / "server.cfg", target_cfg) + + run_command( + ["systemctl", "--user", "start", f"l4d2@{name}.service"], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) + + +def stop_instance( + name: str, + *, + root: Path = DEFAULT_ROOT, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, +) -> None: + run_command( + ["systemctl", "--user", "stop", f"l4d2@{name}.service"], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) + run_command( + ["fusermount3", "-u", str(root / "runtime" / name / "merged")], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) + + +def delete_instance( + name: str, + *, + root: Path = DEFAULT_ROOT, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, +) -> None: + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + + if not instance_dir.exists() and not runtime_dir.exists(): + return + + stop_instance( + name, + root=root, + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) + + if instance_dir.exists(): + shutil.rmtree(instance_dir) + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) diff --git a/components/l4d2-host-lib/tests/test_lifecycle.py b/components/l4d2-host-lib/tests/test_lifecycle.py new file mode 100644 index 0000000..7e846bd --- /dev/null +++ b/components/l4d2-host-lib/tests/test_lifecycle.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest + +from l4d2host.instances import delete_instance, start_instance + + +def test_start_order(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" / "left4dead2" / "cfg").mkdir(parents=True, exist_ok=True) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "instance.env").write_text( + "L4D2_PORT=27015\nL4D2_ARGS=-tickrate 100\nL4D2_LOWERDIRS=/x:/y\n" + ) + (instance_dir / "server.cfg").write_text("sv_consistency 1") + + monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + + start_instance("alpha", root=tmp_path) + + assert calls[0][0] == "fuse-overlayfs" + assert calls[1][:3] == ["systemctl", "--user", "start"] + + +def test_delete_missing_is_noop(tmp_path: Path) -> None: + delete_instance("missing", root=tmp_path)