From a6c4a6c50f96b2c03a9a2f80d7d2d1cf1994917a Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 23 Apr 2026 01:00:02 +0200 Subject: [PATCH] feat(l4d2): add status and journald log read APIs --- components/l4d2-host-lib/src/l4d2host/logs.py | 34 +++++++++++ .../l4d2-host-lib/src/l4d2host/status.py | 56 +++++++++++++++++++ components/l4d2-host-lib/tests/test_logs.py | 42 ++++++++++++++ components/l4d2-host-lib/tests/test_status.py | 8 +++ 4 files changed, 140 insertions(+) create mode 100644 components/l4d2-host-lib/src/l4d2host/logs.py create mode 100644 components/l4d2-host-lib/src/l4d2host/status.py create mode 100644 components/l4d2-host-lib/tests/test_logs.py create mode 100644 components/l4d2-host-lib/tests/test_status.py diff --git a/components/l4d2-host-lib/src/l4d2host/logs.py b/components/l4d2-host-lib/src/l4d2host/logs.py new file mode 100644 index 0000000..9d7bbe7 --- /dev/null +++ b/components/l4d2-host-lib/src/l4d2host/logs.py @@ -0,0 +1,34 @@ +import subprocess +from typing import Iterator + + +def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: + command = [ + "journalctl", + "--user", + "-u", + f"l4d2@{name}.service", + "-n", + str(lines), + "-o", + "cat", + ] + if follow: + command.append("-f") + + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + try: + if proc.stdout is None: + return + for raw in iter(proc.stdout.readline, ""): + yield raw.rstrip("\n") + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) diff --git a/components/l4d2-host-lib/src/l4d2host/status.py b/components/l4d2-host-lib/src/l4d2host/status.py new file mode 100644 index 0000000..6561ab7 --- /dev/null +++ b/components/l4d2-host-lib/src/l4d2host/status.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +import subprocess + +from l4d2host.process import run_command + + +@dataclass(slots=True) +class InstanceStatus: + state: str + raw_active_state: str + raw_sub_state: str + + +def map_active_state(active_state: str) -> str: + if active_state == "active": + return "running" + if active_state in {"inactive", "failed"}: + return "stopped" + return "unknown" + + +def get_instance_status(name: str) -> InstanceStatus: + try: + result = run_command( + [ + "systemctl", + "--user", + "show", + f"l4d2@{name}.service", + "--property=ActiveState", + "--property=SubState", + "--no-pager", + ] + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return InstanceStatus( + state="unknown", + raw_active_state="unknown", + raw_sub_state="unknown", + ) + + values: dict[str, str] = {} + for line in result.stdout.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key] = value + + active_state = values.get("ActiveState", "unknown") + sub_state = values.get("SubState", "unknown") + + return InstanceStatus( + state=map_active_state(active_state), + raw_active_state=active_state, + raw_sub_state=sub_state, + ) diff --git a/components/l4d2-host-lib/tests/test_logs.py b/components/l4d2-host-lib/tests/test_logs.py new file mode 100644 index 0000000..eeb13f7 --- /dev/null +++ b/components/l4d2-host-lib/tests/test_logs.py @@ -0,0 +1,42 @@ +from types import SimpleNamespace + +import pytest + +from l4d2host.logs import stream_instance_logs + + +class DummyProcess: + def __init__(self, lines: list[str]) -> None: + self.stdout = SimpleNamespace(readline=self._readline) + self.stderr = SimpleNamespace(readline=lambda: "") + self._lines = iter(lines) + self.terminated = False + self.waited = False + + def _readline(self) -> str: + return next(self._lines, "") + + def poll(self): + return None if not self.waited else 0 + + def terminate(self) -> None: + self.terminated = True + + def wait(self, timeout: int) -> None: + del timeout + self.waited = True + + +def test_stream_instance_logs_yields_lines(monkeypatch: pytest.MonkeyPatch) -> None: + proc = DummyProcess(["line1\n", "line2\n", ""]) + + def fake_popen(cmd, **kwargs): + del cmd + del kwargs + return proc + + monkeypatch.setattr("l4d2host.logs.subprocess.Popen", fake_popen) + + lines = list(stream_instance_logs("alpha", lines=10, follow=False)) + assert lines == ["line1", "line2"] + assert proc.terminated is True diff --git a/components/l4d2-host-lib/tests/test_status.py b/components/l4d2-host-lib/tests/test_status.py new file mode 100644 index 0000000..d2539a2 --- /dev/null +++ b/components/l4d2-host-lib/tests/test_status.py @@ -0,0 +1,8 @@ +from l4d2host.status import map_active_state + + +def test_status_mapping() -> None: + assert map_active_state("active") == "running" + assert map_active_state("inactive") == "stopped" + assert map_active_state("failed") == "stopped" + assert map_active_state("weird") == "unknown"