feat(l4d2): implement start stop delete lifecycle with callback support
This commit is contained in:
parent
60bb709916
commit
270f31f6e7
5 changed files with 211 additions and 0 deletions
0
components/l4d2-host-lib/src/l4d2host/fs/__init__.py
Normal file
0
components/l4d2-host-lib/src/l4d2host/fs/__init__.py
Normal file
30
components/l4d2-host-lib/src/l4d2host/fs/base.py
Normal file
30
components/l4d2-host-lib/src/l4d2host/fs/base.py
Normal file
|
|
@ -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
|
||||||
45
components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py
Normal file
45
components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from l4d2host.process import run_command
|
||||||
from l4d2host.spec import load_spec
|
from l4d2host.spec import load_spec
|
||||||
from l4d2host.systemd_user import daemon_reload, ensure_template_unit
|
from l4d2host.systemd_user import daemon_reload, ensure_template_unit
|
||||||
|
|
||||||
|
|
@ -44,3 +46,104 @@ def initialize_instance(
|
||||||
if root.resolve() == DEFAULT_ROOT:
|
if root.resolve() == DEFAULT_ROOT:
|
||||||
ensure_template_unit()
|
ensure_template_unit()
|
||||||
daemon_reload(on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough)
|
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)
|
||||||
|
|
|
||||||
33
components/l4d2-host-lib/tests/test_lifecycle.py
Normal file
33
components/l4d2-host-lib/tests/test_lifecycle.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Reference in a new issue