left4me/l4d2host/service_control.py
mwiegand 8552c559d3
feat(l4d2-host): server lifecycle uses systemctl enable --now / disable --now
Servers started via the web UI now create a WantedBy= symlink under
multi-user.target.wants/, so they auto-start on the next host reboot.
Helper verbs renamed start/stop -> enable/disable; service_control.py
renamed start_service/stop_service -> enable_service/disable_service.
The user-facing l4d2ctl start/stop commands keep their names per the
AGENTS.md contract -- only the implementation changes. Spec:
docs/superpowers/specs/2026-05-09-l4d2-server-lifecycle-reboot-and-drift-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:28:44 +02:00

85 lines
2.5 KiB
Python

import subprocess
from typing import Callable, Iterator, Sequence
from l4d2host.process import CommandResult, run_command
SYSTEMCTL_HELPER = "/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = "/usr/local/libexec/left4me/left4me-journalctl"
def systemctl_command(action: str, name: str) -> list[str]:
return ["sudo", "-n", SYSTEMCTL_HELPER, action, name]
def journalctl_command(name: str, lines: int = 200, follow: bool = True) -> list[str]:
follow_arg = "--follow" if follow else "--no-follow"
return ["sudo", "-n", JOURNALCTL_HELPER, name, "--lines", str(lines), follow_arg]
def enable_service(
name: str,
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
) -> CommandResult:
return run_command(
systemctl_command("enable", name),
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
should_cancel=should_cancel,
)
def disable_service(
name: str,
*,
on_stdout: Callable[[str], None] | None = None,
on_stderr: Callable[[str], None] | None = None,
passthrough: bool = False,
should_cancel: Callable[[], bool] | None = None,
) -> CommandResult:
return run_command(
systemctl_command("disable", name),
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
should_cancel=should_cancel,
)
def show_service(name: str) -> CommandResult:
return run_command(systemctl_command("show", name))
def stream_command(cmd: Sequence[str]) -> Iterator[str]:
command = list(cmd)
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")
returncode = proc.wait()
if returncode is None:
returncode = proc.poll()
stderr = proc.stderr.read() if proc.stderr is not None else ""
if returncode:
raise subprocess.CalledProcessError(returncode=returncode, cmd=command, stderr=stderr)
finally:
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=2)
def stream_journal(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]:
return stream_command(journalctl_command(name, lines=lines, follow=follow))