From 51914b051416984469383812574d0b6635b0b5ef Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 21 May 2026 14:57:50 +1000 Subject: [PATCH] feat(service_manager): add ServiceManager protocol + host wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the s6-overlay supervision plan. Pure-refactor addition: introduces the abstract interface (with runtime_checkable Protocol), detect_service_manager(), validate_profile_name(), and thin SystemdServiceManager / LaunchdServiceManager / WindowsServiceManager wrappers around the existing systemd_* / launchd_* / gateway_windows.* module-level functions. No host call site was modified — host code continues to use the existing functions directly; the protocol is for new backend-agnostic code (Phase 4 profile create/delete hooks and the Phase 4 s6 dispatch path in 'hermes gateway start/stop/restart'). WindowsServiceManager.install() forwards the v3 kwargs (start_now, start_on_login, elevated_handoff) added in PRs #28169-adjacent so non-Windows callers — there aren't any today — can opt in. The s6 backend lands in Phase 3; until then get_service_manager() raises a clear error if invoked on a host that detects as 's6'. --- hermes_cli/service_manager.py | 296 +++++++++++++++++++++++ tests/hermes_cli/test_service_manager.py | 273 +++++++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 hermes_cli/service_manager.py create mode 100644 tests/hermes_cli/test_service_manager.py diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py new file mode 100644 index 00000000000..f6a28a8ec3c --- /dev/null +++ b/hermes_cli/service_manager.py @@ -0,0 +1,296 @@ +"""Abstract service manager interface. + +Wraps the existing systemd (Linux host), launchd (macOS host), Windows +Scheduled Task (native Windows host), and s6 (container) backends behind +a common Protocol. Only the s6 backend supports runtime registration +(for per-profile gateways) — host backends raise NotImplementedError +from those methods, and callers MUST check supports_runtime_registration() +before invoking them. + +Host-side call sites (setup wizard, uninstall, status) continue to use +the existing module-level functions in hermes_cli.gateway and +hermes_cli.gateway_windows directly. This protocol is a thin facade +used by new code that needs to be backend-agnostic — specifically the +profile create/delete hooks (Phase 4) and the s6 dispatch path in +``hermes gateway start/stop/restart`` when running inside a container. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Literal, Protocol, runtime_checkable + +ServiceManagerKind = Literal["systemd", "launchd", "windows", "s6", "none"] + +# Profile name → service directory mapping. Profile names must be safe +# as filesystem directory names because the s6 backend creates a service +# directory at ``/gateway-/``. We reject anything that +# could traverse paths, span filesystems, or break s6's own naming rules. +_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +_MAX_PROFILE_LEN = 251 # s6-svscan default name_max + + +def validate_profile_name(name: str) -> None: + """Raise ValueError if ``name`` is not usable as a profile name. + + Profile names are used as s6 service directory names, so they must + match a conservative subset of filesystem-safe characters. Reject + empty strings, uppercase, paths-traversal sequences, and anything + longer than s6's default ``name_max``. + """ + if not name: + raise ValueError("profile name must not be empty") + if len(name) > _MAX_PROFILE_LEN: + raise ValueError( + f"profile name too long ({len(name)} > {_MAX_PROFILE_LEN})" + ) + if not _VALID_PROFILE_RE.match(name): + raise ValueError( + f"profile name must match [a-z0-9][a-z0-9_-]*, got {name!r}" + ) + + +@runtime_checkable +class ServiceManager(Protocol): + """Abstract interface for init-system-specific service operations. + + Lifecycle methods (start / stop / restart / is_running) are + implemented by every backend. Runtime registration + (register_profile_gateway / unregister_profile_gateway / + list_profile_gateways) is implemented only by the s6 backend — + callers MUST check ``supports_runtime_registration()`` before + invoking the registration methods. + """ + + kind: ServiceManagerKind + + # Lifecycle of a pre-declared service. + def start(self, name: str) -> None: ... + def stop(self, name: str) -> None: ... + def restart(self, name: str) -> None: ... + def is_running(self, name: str) -> bool: ... + + # Runtime registration (s6 only). + def supports_runtime_registration(self) -> bool: ... + def register_profile_gateway( + self, + profile: str, + *, + port: int, + extra_env: dict[str, str] | None = None, + ) -> None: ... + def unregister_profile_gateway(self, profile: str) -> None: ... + def list_profile_gateways(self) -> list[str]: ... + + +def detect_service_manager() -> ServiceManagerKind: + """Detect which service manager is available in this environment. + + Returns: + "s6" — inside a container when /init is s6-svscan (Phase 2+) + "windows" — native Windows host + "launchd" — macOS host + "systemd" — Linux host with a working user/system bus + "none" — anything else (Termux, sandbox shells, etc.) + + This function does NOT replace ``supports_systemd_services()`` — + host call sites continue to use that. It exists for new backend- + agnostic code (profile create/delete hooks, the s6 dispatch path + in ``hermes gateway start/stop/restart``). + """ + # Imports deferred so importing this module doesn't drag in the + # whole gateway dependency graph for callers that only need the + # Protocol type or validate_profile_name(). + from hermes_constants import is_container + from hermes_cli.gateway import ( + is_macos, + is_windows, + supports_systemd_services, + ) + + if is_container() and _s6_running(): + return "s6" + if is_windows(): + return "windows" + if is_macos(): + return "launchd" + if supports_systemd_services(): + return "systemd" + return "none" + + +def _s6_running() -> bool: + """True when s6-svscan is running as PID 1 in this container. + + s6-overlay's /init exec's s6-svscan, so ``/proc/1/exe`` resolves + to it (or to ``init`` on some kernel configurations that hide the + exe link). The ``/run/s6/`` directory is created by stage1, so its + presence is a second necessary signal. + """ + try: + exe = Path("/proc/1/exe").resolve() + return exe.name in ("s6-svscan", "init") and Path("/run/s6").exists() + except (OSError, RuntimeError): + return False + + +# --------------------------------------------------------------------------- +# Backend wrappers +# +# These adapters are thin facades over the existing module-level functions +# in ``hermes_cli.gateway`` (systemd/launchd) and ``hermes_cli.gateway_windows`` +# (Windows Scheduled Tasks). The protocol's ``name`` parameter is currently +# unused for host backends — they operate on whichever profile is currently +# active (set via the ``hermes -p `` flag before the call). This +# matches existing host-side semantics; the parameter shape is designed +# for s6 where each profile maps to a distinct service directory. +# --------------------------------------------------------------------------- + + +class _RegistrationUnsupportedMixin: + """Mixin for host backends that don't support runtime registration.""" + + def supports_runtime_registration(self) -> bool: + return False + + def register_profile_gateway( + self, + profile: str, + *, + port: int, + extra_env: dict[str, str] | None = None, + ) -> None: + raise NotImplementedError( + f"{type(self).__name__} does not support runtime profile " + "gateway registration (container-only feature)" + ) + + def unregister_profile_gateway(self, profile: str) -> None: + raise NotImplementedError( + f"{type(self).__name__} does not support runtime profile " + "gateway unregistration (container-only feature)" + ) + + def list_profile_gateways(self) -> list[str]: + return [] + + +class SystemdServiceManager(_RegistrationUnsupportedMixin): + """Thin wrapper around the ``systemd_*`` functions in hermes_cli.gateway. + + Existing host call sites continue to use those functions directly; + this wrapper exists for new code that needs to be backend-agnostic + (the Phase 4 profile create/delete hooks). + """ + + kind: ServiceManagerKind = "systemd" + + def start(self, name: str) -> None: + from hermes_cli.gateway import systemd_start + systemd_start() + + def stop(self, name: str) -> None: + from hermes_cli.gateway import systemd_stop + systemd_stop() + + def restart(self, name: str) -> None: + from hermes_cli.gateway import systemd_restart + systemd_restart() + + def is_running(self, name: str) -> bool: + from hermes_cli.gateway import _probe_systemd_service_running + _, running = _probe_systemd_service_running() + return running + + +class LaunchdServiceManager(_RegistrationUnsupportedMixin): + """Thin wrapper around the ``launchd_*`` functions in hermes_cli.gateway.""" + + kind: ServiceManagerKind = "launchd" + + def start(self, name: str) -> None: + from hermes_cli.gateway import launchd_start + launchd_start() + + def stop(self, name: str) -> None: + from hermes_cli.gateway import launchd_stop + launchd_stop() + + def restart(self, name: str) -> None: + from hermes_cli.gateway import launchd_restart + launchd_restart() + + def is_running(self, name: str) -> bool: + from hermes_cli.gateway import _probe_launchd_service_running + return _probe_launchd_service_running() + + +class WindowsServiceManager(_RegistrationUnsupportedMixin): + """Thin wrapper around ``hermes_cli.gateway_windows`` (Scheduled Task / + Startup-folder fallback). + + The native Windows backend uses a Scheduled Task rather than a true + init-system service, but for protocol purposes the lifecycle is the + same: start / stop / restart / is_running. ``install`` accepts a + handful of Windows-specific kwargs (start_now, start_on_login, + elevated_handoff) that are passed straight through — non-Windows + callers should never invoke ``install`` on this wrapper. + """ + + kind: ServiceManagerKind = "windows" + + def install( + self, + *, + force: bool = False, + start_now: bool | None = None, + start_on_login: bool | None = None, + elevated_handoff: bool = False, + ) -> None: + from hermes_cli import gateway_windows + gateway_windows.install( + force=force, + start_now=start_now, + start_on_login=start_on_login, + elevated_handoff=elevated_handoff, + ) + + def start(self, name: str) -> None: + from hermes_cli import gateway_windows + gateway_windows.start() + + def stop(self, name: str) -> None: + from hermes_cli import gateway_windows + gateway_windows.stop() + + def restart(self, name: str) -> None: + from hermes_cli import gateway_windows + gateway_windows.restart() + + def is_running(self, name: str) -> bool: + from hermes_cli import gateway_windows + from hermes_cli.gateway import find_gateway_pids + if not gateway_windows.is_installed(): + return False + return bool(find_gateway_pids()) + + +def get_service_manager() -> ServiceManager: + """Return the ServiceManager instance for the current environment. + + Raises: + RuntimeError: when no supported backend is available, or when + the detected backend's implementation hasn't shipped yet + (the s6 backend lands in Phase 3). + """ + kind = detect_service_manager() + if kind == "systemd": + return SystemdServiceManager() + if kind == "launchd": + return LaunchdServiceManager() + if kind == "windows": + return WindowsServiceManager() + if kind == "s6": + # Phase 3 will replace this with `return S6ServiceManager()`. + raise RuntimeError("s6 backend not yet implemented (Phase 3)") + raise RuntimeError("no supported service manager detected") diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py new file mode 100644 index 00000000000..067048380b9 --- /dev/null +++ b/tests/hermes_cli/test_service_manager.py @@ -0,0 +1,273 @@ +"""Tests for hermes_cli.service_manager — the abstract ServiceManager +protocol, the detect_service_manager() entry point, and the host-side +adapter wrappers (Systemd / Launchd / Windows). + +The s6 backend is added in Phase 3; its tests live alongside the +implementation in this same file once that phase ships. +""" +from __future__ import annotations + +import pytest + +from hermes_cli.service_manager import ( + LaunchdServiceManager, + ServiceManager, + ServiceManagerKind, + SystemdServiceManager, + WindowsServiceManager, + detect_service_manager, + get_service_manager, + validate_profile_name, +) + + +# --------------------------------------------------------------------------- +# validate_profile_name +# --------------------------------------------------------------------------- + + +def test_validate_profile_name_accepts_valid_names() -> None: + # Smoke: known-good names should not raise. + validate_profile_name("coder") + validate_profile_name("my-profile") + validate_profile_name("assistant_v2") + validate_profile_name("a") + validate_profile_name("0") + validate_profile_name("0abc") + + +@pytest.mark.parametrize( + "bad", + [ + "", # empty + "Coder", # uppercase + "foo/bar", # path traversal + "../escape", # path traversal + "-leading-dash", # leading dash (s6 reads as a flag) + "_leading_underscore", # leading underscore + "name with spaces", # whitespace + "name.with.dots", # punctuation + "a" * 252, # too long + ], +) +def test_validate_profile_name_rejects_invalid(bad: str) -> None: + with pytest.raises(ValueError): + validate_profile_name(bad) + + +# --------------------------------------------------------------------------- +# detect_service_manager +# --------------------------------------------------------------------------- + + +def test_detect_service_manager_returns_known_value() -> None: + """Without mocking, the function must still return one of the + advertised literals — anything else means a new platform branch + was added without updating ServiceManagerKind.""" + result = detect_service_manager() + assert result in ("systemd", "launchd", "windows", "s6", "none") + + +# --------------------------------------------------------------------------- +# Backend wrappers — kind + registration unsupported on hosts +# --------------------------------------------------------------------------- + + +def test_systemd_manager_kind_and_registration_unsupported() -> None: + mgr = SystemdServiceManager() + assert mgr.kind == "systemd" + assert mgr.supports_runtime_registration() is False + with pytest.raises(NotImplementedError): + mgr.register_profile_gateway("foo", port=9100) + with pytest.raises(NotImplementedError): + mgr.unregister_profile_gateway("foo") + assert mgr.list_profile_gateways() == [] + # Protocol conformance — runtime_checkable lets us assert this. + assert isinstance(mgr, ServiceManager) + + +def test_launchd_manager_kind_and_registration_unsupported() -> None: + mgr = LaunchdServiceManager() + assert mgr.kind == "launchd" + assert mgr.supports_runtime_registration() is False + with pytest.raises(NotImplementedError): + mgr.register_profile_gateway("foo", port=9100) + assert mgr.list_profile_gateways() == [] + assert isinstance(mgr, ServiceManager) + + +def test_windows_manager_kind_and_registration_unsupported() -> None: + mgr = WindowsServiceManager() + assert mgr.kind == "windows" + assert mgr.supports_runtime_registration() is False + with pytest.raises(NotImplementedError): + mgr.register_profile_gateway("foo", port=9100) + assert isinstance(mgr, ServiceManager) + + +# --------------------------------------------------------------------------- +# Lifecycle delegation — wrappers must call through to module-level fns +# --------------------------------------------------------------------------- + + +def test_systemd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + called: list[str] = [] + monkeypatch.setattr( + "hermes_cli.gateway.systemd_start", lambda: called.append("start"), + ) + monkeypatch.setattr( + "hermes_cli.gateway.systemd_stop", lambda: called.append("stop"), + ) + monkeypatch.setattr( + "hermes_cli.gateway.systemd_restart", lambda: called.append("restart"), + ) + monkeypatch.setattr( + "hermes_cli.gateway._probe_systemd_service_running", + lambda *a, **kw: (False, True), + ) + mgr = SystemdServiceManager() + mgr.start("ignored") + mgr.stop("ignored") + mgr.restart("ignored") + assert called == ["start", "stop", "restart"] + assert mgr.is_running("ignored") is True + + +def test_launchd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + called: list[str] = [] + monkeypatch.setattr( + "hermes_cli.gateway.launchd_start", lambda: called.append("start"), + ) + monkeypatch.setattr( + "hermes_cli.gateway.launchd_stop", lambda: called.append("stop"), + ) + monkeypatch.setattr( + "hermes_cli.gateway.launchd_restart", lambda: called.append("restart"), + ) + monkeypatch.setattr( + "hermes_cli.gateway._probe_launchd_service_running", lambda: False, + ) + mgr = LaunchdServiceManager() + mgr.start("ignored") + mgr.stop("ignored") + mgr.restart("ignored") + assert called == ["start", "stop", "restart"] + assert mgr.is_running("ignored") is False + + +def test_windows_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + called: list[str] = [] + # Force-import the submodule so monkeypatch's attribute lookup + # against the `hermes_cli` package succeeds — gateway_windows is + # imported lazily inside the wrapper and may not yet be loaded. + import hermes_cli.gateway_windows # noqa: F401 + + class _FakeWindowsModule: + @staticmethod + def start() -> None: called.append("start") + @staticmethod + def stop() -> None: called.append("stop") + @staticmethod + def restart() -> None: called.append("restart") + @staticmethod + def is_installed() -> bool: return True + + monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule) + monkeypatch.setattr( + "hermes_cli.gateway.find_gateway_pids", + lambda **kw: [12345], + ) + mgr = WindowsServiceManager() + mgr.start("ignored") + mgr.stop("ignored") + mgr.restart("ignored") + assert called == ["start", "stop", "restart"] + assert mgr.is_running("ignored") is True + + +def test_windows_manager_is_running_false_when_not_installed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import hermes_cli.gateway_windows # noqa: F401 + + class _FakeWindowsModule: + @staticmethod + def is_installed() -> bool: return False + + monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule) + monkeypatch.setattr( + "hermes_cli.gateway.find_gateway_pids", + lambda **kw: [12345], # PIDs would otherwise vote "running" + ) + assert WindowsServiceManager().is_running("ignored") is False + + +def test_windows_manager_install_forwards_kwargs(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + import hermes_cli.gateway_windows # noqa: F401 + + class _FakeWindowsModule: + @staticmethod + def install(*, force, start_now, start_on_login, elevated_handoff) -> None: + captured["force"] = force + captured["start_now"] = start_now + captured["start_on_login"] = start_on_login + captured["elevated_handoff"] = elevated_handoff + + monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule) + WindowsServiceManager().install( + force=True, start_now=True, start_on_login=False, elevated_handoff=True, + ) + assert captured == { + "force": True, + "start_now": True, + "start_on_login": False, + "elevated_handoff": True, + } + + +# --------------------------------------------------------------------------- +# get_service_manager factory +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind,cls", + [ + ("systemd", SystemdServiceManager), + ("launchd", LaunchdServiceManager), + ("windows", WindowsServiceManager), + ], +) +def test_get_service_manager_returns_correct_backend( + monkeypatch: pytest.MonkeyPatch, + kind: ServiceManagerKind, + cls: type, +) -> None: + monkeypatch.setattr( + "hermes_cli.service_manager.detect_service_manager", lambda: kind, + ) + assert isinstance(get_service_manager(), cls) + + +def test_get_service_manager_raises_when_unsupported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "hermes_cli.service_manager.detect_service_manager", lambda: "none", + ) + with pytest.raises(RuntimeError, match="no supported service manager"): + get_service_manager() + + +def test_get_service_manager_raises_for_s6_until_phase_3( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The s6 backend ships in Phase 3 — until then the factory raises + with an explicit message so accidental host code that ends up + running inside the container surfaces clearly.""" + monkeypatch.setattr( + "hermes_cli.service_manager.detect_service_manager", lambda: "s6", + ) + with pytest.raises(RuntimeError, match="s6 backend not yet implemented"): + get_service_manager()