nanobot/nanobot/gateway/service.py
2026-06-24 10:29:08 +08:00

287 lines
10 KiB
Python

"""Install and manage OS-level gateway services."""
from __future__ import annotations
import os
import plistlib
import re
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from nanobot.gateway import GatewayStartOptions, build_gateway_command
ServiceManagerKind = Literal["auto", "systemd", "launchd"]
@dataclass(frozen=True)
class GatewayServiceOptions:
"""Inputs used to render one system service."""
start: GatewayStartOptions
name: str = "nanobot-gateway"
manager: ServiceManagerKind = "auto"
enable: bool = True
start_now: bool = True
python_executable: str = sys.executable
@dataclass(frozen=True)
class GatewayServiceResult:
"""Result from service install/uninstall operations."""
ok: bool
message: str
manager: str
path: Path | None
commands: tuple[tuple[str, ...], ...] = ()
content: str | None = None
class GatewayServiceInstaller:
"""Render and install systemd user services or macOS LaunchAgents."""
def __init__(
self,
*,
platform_name: str | None = None,
subprocess_run: Callable[..., Any] = subprocess.run,
home: Path | None = None,
) -> None:
self.platform_name = platform_name or _platform_name()
self._subprocess_run = subprocess_run
self.home = home or Path.home()
def install(self, options: GatewayServiceOptions, *, dry_run: bool = False) -> GatewayServiceResult:
manager = self._resolve_manager(options.manager)
if manager == "systemd":
return self._install_systemd(options, dry_run=dry_run)
if manager == "launchd":
return self._install_launchd(options, dry_run=dry_run)
return GatewayServiceResult(False, f"unsupported_service_manager:{manager}", manager, None)
def uninstall(
self,
*,
name: str = "nanobot-gateway",
manager: ServiceManagerKind = "auto",
dry_run: bool = False,
) -> GatewayServiceResult:
resolved = self._resolve_manager(manager)
if resolved == "systemd":
return self._uninstall_systemd(name=name, dry_run=dry_run)
if resolved == "launchd":
return self._uninstall_launchd(name=name, dry_run=dry_run)
return GatewayServiceResult(False, f"unsupported_service_manager:{resolved}", resolved, None)
def _install_systemd(
self,
options: GatewayServiceOptions,
*,
dry_run: bool,
) -> GatewayServiceResult:
unit_name = _systemd_unit_name(options.name)
path = self.home / ".config" / "systemd" / "user" / unit_name
command = build_gateway_command(options.python_executable, options.start)
content = _systemd_unit_content(
description=f"Nanobot Gateway ({options.name})",
command=command,
working_directory=_working_directory_text(options.start),
)
commands: list[tuple[str, ...]] = [("systemctl", "--user", "daemon-reload")]
if options.enable:
commands.append(("systemctl", "--user", "enable", unit_name))
if options.start_now:
commands.append(("systemctl", "--user", "restart", unit_name))
if dry_run:
return GatewayServiceResult(True, "service_install_dry_run", "systemd", path, tuple(commands), content)
_working_directory(options.start).mkdir(parents=True, exist_ok=True)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
for command_args in commands:
self._subprocess_run(list(command_args), check=True)
return GatewayServiceResult(True, "service_installed", "systemd", path, tuple(commands), content)
def _uninstall_systemd(
self,
*,
name: str,
dry_run: bool,
) -> GatewayServiceResult:
unit_name = _systemd_unit_name(name)
path = self.home / ".config" / "systemd" / "user" / unit_name
commands = (
("systemctl", "--user", "disable", "--now", unit_name),
("systemctl", "--user", "daemon-reload"),
)
if dry_run:
return GatewayServiceResult(True, "service_uninstall_dry_run", "systemd", path, commands)
self._run_best_effort(commands[0])
path.unlink(missing_ok=True)
self._subprocess_run(list(commands[1]), check=True)
return GatewayServiceResult(True, "service_uninstalled", "systemd", path, commands)
def _install_launchd(
self,
options: GatewayServiceOptions,
*,
dry_run: bool,
) -> GatewayServiceResult:
label = _launchd_label(options.name)
path = self.home / "Library" / "LaunchAgents" / f"{label}.plist"
log_stem = _safe_service_name(options.name)
stdout_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.log"
stderr_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.err.log"
payload = {
"Label": label,
"ProgramArguments": build_gateway_command(options.python_executable, options.start),
"WorkingDirectory": _working_directory_text(options.start),
"RunAtLoad": bool(options.enable),
"KeepAlive": {"SuccessfulExit": False},
"StandardOutPath": str(stdout_path),
"StandardErrorPath": str(stderr_path),
}
content = plistlib.dumps(payload, sort_keys=False).decode("utf-8")
domain = _launchd_domain()
commands: list[tuple[str, ...]] = []
if options.start_now:
commands.append(("launchctl", "bootstrap", domain, str(path)))
if options.enable:
commands.append(("launchctl", "enable", f"{domain}/{label}"))
if options.start_now:
commands.append(("launchctl", "kickstart", "-k", f"{domain}/{label}"))
if dry_run:
return GatewayServiceResult(True, "service_install_dry_run", "launchd", path, tuple(commands), content)
_working_directory(options.start).mkdir(parents=True, exist_ok=True)
path.parent.mkdir(parents=True, exist_ok=True)
stdout_path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if options.start_now:
self._run_best_effort(("launchctl", "bootout", domain, str(path)))
for command_args in commands:
self._subprocess_run(list(command_args), check=True)
return GatewayServiceResult(True, "service_installed", "launchd", path, tuple(commands), content)
def _uninstall_launchd(
self,
*,
name: str,
dry_run: bool,
) -> GatewayServiceResult:
label = _launchd_label(name)
path = self.home / "Library" / "LaunchAgents" / f"{label}.plist"
domain = _launchd_domain()
commands = (
("launchctl", "bootout", domain, str(path)),
("launchctl", "disable", f"{domain}/{label}"),
)
if dry_run:
return GatewayServiceResult(True, "service_uninstall_dry_run", "launchd", path, commands)
for command_args in commands:
self._run_best_effort(command_args)
path.unlink(missing_ok=True)
return GatewayServiceResult(True, "service_uninstalled", "launchd", path, commands)
def _resolve_manager(self, manager: ServiceManagerKind) -> str:
if manager != "auto":
return manager
if self.platform_name == "Darwin":
return "launchd"
if self.platform_name == "Linux":
return "systemd"
return self.platform_name.lower()
def _run_best_effort(self, command_args: tuple[str, ...]) -> None:
self._subprocess_run(list(command_args), check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _platform_name() -> str:
if sys.platform == "darwin":
return "Darwin"
if sys.platform.startswith("linux"):
return "Linux"
if sys.platform.startswith("win"):
return "Windows"
return sys.platform
def _working_directory(options: GatewayStartOptions) -> Path:
if options.workspace:
return Path(options.workspace).expanduser()
return Path.home()
def _working_directory_text(options: GatewayStartOptions) -> str:
if options.workspace:
return os.path.expanduser(options.workspace)
return str(Path.home())
def _systemd_unit_name(name: str) -> str:
stem = _safe_service_name(name)
return stem if stem.endswith(".service") else f"{stem}.service"
def _launchd_label(name: str) -> str:
if name.startswith("ai.nanobot."):
return name
suffix = _safe_service_name(name).removeprefix("nanobot-").replace("-", ".")
return f"ai.nanobot.{suffix}"
def _safe_service_name(name: str) -> str:
value = name.strip().lower()
value = re.sub(r"[^a-z0-9_.-]+", "-", value)
value = value.strip(".-")
return value or "nanobot-gateway"
def _launchd_domain() -> str:
getuid = getattr(os, "getuid", None)
if getuid is None:
return "gui/current"
return f"gui/{getuid()}"
def _systemd_unit_content(
*,
description: str,
command: list[str],
working_directory: str,
) -> str:
quoted_command = " ".join(_systemd_quote(part) for part in command)
return "\n".join(
[
"[Unit]",
f"Description={description}",
"After=network-online.target",
"Wants=network-online.target",
"",
"[Service]",
"Type=simple",
f"WorkingDirectory={_systemd_quote(str(working_directory))}",
f"ExecStart={quoted_command}",
"Restart=always",
"RestartSec=10",
"Environment=PYTHONUNBUFFERED=1",
"NoNewPrivileges=yes",
"",
"[Install]",
"WantedBy=default.target",
"",
]
)
def _systemd_quote(value: str) -> str:
if value and not re.search(r"\s|['\"\\]", value):
return value
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'