mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
fix(docker): supervise plugin manager lifecycle
This commit is contained in:
1 parent
8054333e3f
commit
c0aeb89399
15 files changed
+858
-25
No files matched your search
@@ -0,0 +1,212 @@
|
||||
"""Container supervision for Repeater and its plugin manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
|
||||
# Subprocess calls below use fixed argument arrays and never shell=True.
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger("ContainerSupervisor")
|
||||
_FALSE_VALUES = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def plugin_manager_enabled(
|
||||
config_path: Path | str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Return whether the container should run the plugin manager."""
|
||||
env = os.environ if environ is None else environ
|
||||
if str(env.get("OPENHOP_PLUGIN_MANAGER", "1")).strip().lower() in _FALSE_VALUES:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as config_file:
|
||||
config = yaml.safe_load(config_file) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return True
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return True
|
||||
plugins = config.get("plugins")
|
||||
return not (isinstance(plugins, dict) and plugins.get("enabled") is False)
|
||||
|
||||
|
||||
def _signal_process_group(process: subprocess.Popen, signum: int) -> None:
|
||||
"""Signal a child session, falling back to the direct process."""
|
||||
try:
|
||||
os.killpg(process.pid, signum)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError:
|
||||
try:
|
||||
process.send_signal(signum)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def _process_group_exists(process: subprocess.Popen) -> bool:
|
||||
"""Return whether any process remains in a child session."""
|
||||
try:
|
||||
os.killpg(process.pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
class ContainerSupervisor:
|
||||
"""Run, restart, and gracefully stop container child processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path | str,
|
||||
*,
|
||||
manager_enabled: bool | None = None,
|
||||
python_executable: str = "python3",
|
||||
popen_factory: Callable[..., subprocess.Popen] = subprocess.Popen,
|
||||
signal_process_group: Callable[[subprocess.Popen, int], None] = _signal_process_group,
|
||||
process_group_exists: Callable[[subprocess.Popen], bool] = _process_group_exists,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
manager_restart_delay: float = 2.0,
|
||||
shutdown_timeout: float = 8.0,
|
||||
):
|
||||
self.config_path = str(config_path)
|
||||
self.manager_enabled = (
|
||||
plugin_manager_enabled(config_path) if manager_enabled is None else manager_enabled
|
||||
)
|
||||
self.python_executable = python_executable
|
||||
self._popen = popen_factory
|
||||
self._signal_process_group = signal_process_group
|
||||
self._process_group_exists = process_group_exists
|
||||
self._sleep = sleep
|
||||
self.manager_restart_delay = manager_restart_delay
|
||||
self.shutdown_timeout = shutdown_timeout
|
||||
self.manager_process: subprocess.Popen | None = None
|
||||
self.repeater_process: subprocess.Popen | None = None
|
||||
self._stopping = False
|
||||
self._shutdown_signal = signal.SIGTERM
|
||||
|
||||
def _start(self, module: str) -> subprocess.Popen:
|
||||
command = [
|
||||
self.python_executable,
|
||||
"-m",
|
||||
module,
|
||||
"--config",
|
||||
self.config_path,
|
||||
]
|
||||
return self._popen(command, start_new_session=True)
|
||||
|
||||
def _start_manager(self) -> None:
|
||||
logger.info("Starting plugin manager")
|
||||
self.manager_process = self._start("repeater.plugins")
|
||||
|
||||
def request_shutdown(self, signum: int = signal.SIGTERM) -> None:
|
||||
if self._stopping:
|
||||
return
|
||||
self._stopping = True
|
||||
self._shutdown_signal = signum
|
||||
logger.info("Signal %s received; stopping container services", signum)
|
||||
for process in (self.repeater_process, self.manager_process):
|
||||
if process is not None:
|
||||
self._signal_process_group(process, signum)
|
||||
|
||||
def _wait_or_kill(self, process: subprocess.Popen | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
try:
|
||||
process.wait(timeout=self.shutdown_timeout)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Process group %s did not stop in time; killing it", process.pid)
|
||||
self._signal_process_group(process, signal.SIGKILL)
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, ChildProcessError):
|
||||
pass
|
||||
|
||||
def _finish(self) -> None:
|
||||
if not self._stopping:
|
||||
self.request_shutdown(signal.SIGTERM)
|
||||
self._wait_or_kill(self.repeater_process)
|
||||
self._wait_or_kill(self.manager_process)
|
||||
|
||||
def run(self) -> int:
|
||||
if self.manager_enabled:
|
||||
self._start_manager()
|
||||
else:
|
||||
logger.info("Plugin manager disabled")
|
||||
|
||||
self.repeater_process = self._start("repeater.main")
|
||||
|
||||
try:
|
||||
while not self._stopping:
|
||||
repeater_code = self.repeater_process.poll()
|
||||
if repeater_code is not None:
|
||||
return int(repeater_code)
|
||||
|
||||
if self.manager_enabled and self.manager_process is not None:
|
||||
manager_code = self.manager_process.poll()
|
||||
if manager_code is not None:
|
||||
logger.error(
|
||||
"Plugin manager exited unexpectedly with status %s; restarting",
|
||||
manager_code,
|
||||
)
|
||||
# Terminate any plugin descendants left in the old process group.
|
||||
self._signal_process_group(self.manager_process, signal.SIGTERM)
|
||||
self._sleep(self.manager_restart_delay)
|
||||
if self._process_group_exists(self.manager_process):
|
||||
logger.warning(
|
||||
"Plugin descendants did not stop with manager %s; killing group",
|
||||
self.manager_process.pid,
|
||||
)
|
||||
self._signal_process_group(self.manager_process, signal.SIGKILL)
|
||||
if not self._stopping and self.repeater_process.poll() is None:
|
||||
self._start_manager()
|
||||
|
||||
self._sleep(0.2)
|
||||
finally:
|
||||
self._finish()
|
||||
|
||||
code = self.repeater_process.poll()
|
||||
if code is None:
|
||||
return 128 + int(self._shutdown_signal)
|
||||
return int(code)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="openHop Docker process supervisor")
|
||||
parser.add_argument("--config", required=True, help="Path to Repeater config.yaml")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_arg_parser().parse_args(argv)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
supervisor = ContainerSupervisor(args.config)
|
||||
|
||||
def handle_signal(signum: int, _frame: Any) -> None:
|
||||
supervisor.request_shutdown(signum)
|
||||
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(signum, handle_signal)
|
||||
|
||||
return supervisor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -125,10 +125,12 @@ class GitHubReleaseClient:
|
||||
cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS,
|
||||
opener: Optional[Callable[..., Any]] = None,
|
||||
user_agent: str = USER_AGENT,
|
||||
token: str | None = None,
|
||||
):
|
||||
self.cache_ttl = cache_ttl
|
||||
self._opener = opener or self._default_open
|
||||
self.user_agent = user_agent
|
||||
self.token = str(token or "").strip() or None
|
||||
# repo -> (monotonic_ts, list[PluginRelease])
|
||||
self._cache: dict[str, tuple[float, list[PluginRelease]]] = {}
|
||||
|
||||
@@ -260,12 +262,15 @@ class GitHubReleaseClient:
|
||||
|
||||
def _fetch_releases_json(self, repo: str) -> list[Any]:
|
||||
url = f"{GITHUB_API_BASE}/repos/{repo}/releases?per_page=30"
|
||||
headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -48,7 +49,9 @@ class PluginManager:
|
||||
self.runtime = runtime or PluginRuntime(storage)
|
||||
self._lock = threading.RLock()
|
||||
self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL)
|
||||
self.github = github_client or GitHubReleaseClient()
|
||||
self.github = github_client or GitHubReleaseClient(
|
||||
token=os.environ.get("OPENHOP_PLUGIN_GITHUB_TOKEN")
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Load state and start enabled service plugins."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess # nosec B404 - argument arrays only, never shell=True
|
||||
import sys
|
||||
@@ -89,15 +90,27 @@ class PluginRuntime:
|
||||
paths = self.storage.ensure_plugin_layout(plugin_id, version)
|
||||
self.storage.write_manifest(plugin_id, version, manifest)
|
||||
|
||||
# Keep the exact install artifact with the release. Container data
|
||||
# volumes outlive the image, so this lets us rebuild the isolated venv
|
||||
# after a future base-image Python minor-version change.
|
||||
release_dir = paths.release_dir(version)
|
||||
archived_wheel = release_dir / wheel_path.name
|
||||
if archived_wheel.resolve() != wheel_path:
|
||||
for previous_wheel in release_dir.glob("*.whl"):
|
||||
previous_wheel.unlink(missing_ok=True)
|
||||
shutil.copy2(wheel_path, archived_wheel)
|
||||
else:
|
||||
archived_wheel = wheel_path
|
||||
|
||||
# Extract optional UI assets from the wheel into the release dir
|
||||
self._extract_ui_assets(wheel_path, paths.release_dir(version), manifest)
|
||||
self._extract_ui_assets(archived_wheel, release_dir, manifest)
|
||||
# Also extract optional config.default.json next to the manifest if present
|
||||
self._extract_config_default_file(wheel_path, paths.release_dir(version))
|
||||
self._extract_config_default_file(archived_wheel, release_dir)
|
||||
|
||||
venv_path = paths.venv_dir(version)
|
||||
if manifest.runtime is not None:
|
||||
self._create_venv(venv_path)
|
||||
self._pip_install(venv_path, wheel_path)
|
||||
self._pip_install(venv_path, archived_wheel)
|
||||
|
||||
# Seed data/config.json from defaults when the user has no saved config yet
|
||||
self._seed_config_if_missing(plugin_id, manifest, paths.release_dir(version))
|
||||
@@ -123,6 +136,58 @@ class PluginRuntime:
|
||||
"data_dir": str(paths.data_dir),
|
||||
}
|
||||
|
||||
def ensure_venv_compatible(self, plugin_id: str) -> None:
|
||||
"""Rebuild a persisted plugin venv when Python's minor version changes."""
|
||||
state = self.storage.read_state(plugin_id)
|
||||
manifest = self.storage.load_current_manifest(plugin_id)
|
||||
if state is None or manifest is None or manifest.runtime is None:
|
||||
return
|
||||
|
||||
version = str(state.get("version") or manifest.version)
|
||||
paths = self.storage.paths_for(plugin_id)
|
||||
venv_path = paths.venv_dir(version)
|
||||
config_path = venv_path / "pyvenv.cfg"
|
||||
if not config_path.is_file():
|
||||
# Legacy/test venvs have no reliable version marker. Let normal
|
||||
# entrypoint validation report a useful error instead of deleting.
|
||||
return
|
||||
|
||||
persisted_version = None
|
||||
try:
|
||||
for line in config_path.read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if separator and key.strip().lower() == "version":
|
||||
parts = value.strip().split(".")
|
||||
if len(parts) >= 2:
|
||||
persisted_version = (int(parts[0]), int(parts[1]))
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
current_version = (sys.version_info.major, sys.version_info.minor)
|
||||
if persisted_version is None or persisted_version == current_version:
|
||||
return
|
||||
|
||||
wheels = sorted(paths.release_dir(version).glob("*.whl"))
|
||||
if not wheels:
|
||||
raise RuntimeError(
|
||||
f"plugin venv uses Python {persisted_version[0]}.{persisted_version[1]}, "
|
||||
f"but the container uses {current_version[0]}.{current_version[1]}; "
|
||||
"reinstall the plugin to rebuild its environment"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Rebuilding %s venv for Python %s.%s (was %s.%s)",
|
||||
plugin_id,
|
||||
current_version[0],
|
||||
current_version[1],
|
||||
persisted_version[0],
|
||||
persisted_version[1],
|
||||
)
|
||||
shutil.rmtree(venv_path)
|
||||
self._create_venv(venv_path)
|
||||
self._pip_install(venv_path, wheels[0])
|
||||
|
||||
def _create_venv(self, venv_path: Path) -> None:
|
||||
if venv_path.exists():
|
||||
# Reinstall into existing venv is ok; recreate if broken
|
||||
@@ -411,6 +476,10 @@ class PluginRuntime:
|
||||
# Allow explicit start only when enabled — callers enable first
|
||||
raise RuntimeError(f"plugin is disabled: {plugin_id}")
|
||||
|
||||
# Persisted Docker volumes can outlive the image's Python minor
|
||||
# version. Rebuild before resolving the installed entrypoint.
|
||||
self.ensure_venv_compatible(plugin_id)
|
||||
|
||||
existing = self._handles.get(plugin_id)
|
||||
if existing and existing.process.poll() is None:
|
||||
self._runtime_state[plugin_id] = PluginState.RUNNING
|
||||
@@ -434,6 +503,8 @@ class PluginRuntime:
|
||||
log_fp = open(paths.log_file, "a", encoding="utf-8", buffering=1)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Manager-only credentials must never become plugin credentials.
|
||||
env.pop("OPENHOP_PLUGIN_GITHUB_TOKEN", None)
|
||||
env["OPENHOP_PLUGIN_ID"] = plugin_id
|
||||
env["OPENHOP_PLUGIN_DATA"] = str(paths.data_dir)
|
||||
|
||||
|
||||
Reference in new issue
Block a user