feat(l4d2): add status and journald log read APIs
This commit is contained in:
parent
270f31f6e7
commit
a6c4a6c50f
4 changed files with 140 additions and 0 deletions
34
components/l4d2-host-lib/src/l4d2host/logs.py
Normal file
34
components/l4d2-host-lib/src/l4d2host/logs.py
Normal file
|
|
@ -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)
|
||||||
56
components/l4d2-host-lib/src/l4d2host/status.py
Normal file
56
components/l4d2-host-lib/src/l4d2host/status.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
42
components/l4d2-host-lib/tests/test_logs.py
Normal file
42
components/l4d2-host-lib/tests/test_logs.py
Normal file
|
|
@ -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
|
||||||
8
components/l4d2-host-lib/tests/test_status.py
Normal file
8
components/l4d2-host-lib/tests/test_status.py
Normal file
|
|
@ -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"
|
||||||
Loading…
Reference in a new issue