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,39 @@
|
||||
"""Checks for the container runtime quality gate."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_container_workflow_runs_plugin_runtime_smoke_test():
|
||||
workflow = (ROOT / ".github/workflows/container-checks.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "docker build --build-arg PACKAGE_VERSION=0.0.0.dev0" in workflow
|
||||
assert "scripts/docker-plugin-smoke.sh openhop-repeater:plugin-smoke" in workflow
|
||||
|
||||
|
||||
def test_container_smoke_script_checks_install_restart_and_shutdown():
|
||||
script = (ROOT / "scripts/docker-plugin-smoke.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "local wheel upload" in script
|
||||
assert "container recreation persistence" in script
|
||||
assert "graceful plugin shutdown" in script
|
||||
assert "EXPECTED_PLUGIN_PID" in script
|
||||
|
||||
|
||||
def test_compose_exposes_service_scoped_github_token():
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
docs = (ROOT / "docs/plugins.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in compose
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in docs
|
||||
|
||||
|
||||
def test_image_uses_tini_to_reap_orphaned_plugin_processes():
|
||||
dockerfile = (ROOT / "dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert " tini \\\n" in dockerfile
|
||||
assert (
|
||||
'ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/docker-entrypoint.sh"]'
|
||||
in dockerfile
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Container process supervision for Repeater and plugin manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repeater.plugins.container_supervisor import ContainerSupervisor, plugin_manager_enabled
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, pid: int, polls: list[int | None]):
|
||||
self.pid = pid
|
||||
self._polls = list(polls)
|
||||
self.returncode = None
|
||||
self.wait_calls = []
|
||||
self.killed = False
|
||||
|
||||
def poll(self):
|
||||
if self._polls:
|
||||
value = self._polls.pop(0)
|
||||
if value is not None:
|
||||
self.returncode = value
|
||||
return value
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.wait_calls.append(timeout)
|
||||
if self.returncode is None:
|
||||
raise subprocess.TimeoutExpired("fake", timeout)
|
||||
return self.returncode
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.returncode = -signal.SIGKILL
|
||||
|
||||
|
||||
def test_plugin_manager_enabled_honours_config_and_environment(tmp_path: Path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text("plugins:\n enabled: false\n", encoding="utf-8")
|
||||
|
||||
assert plugin_manager_enabled(config, {}) is False
|
||||
assert plugin_manager_enabled(config, {"OPENHOP_PLUGIN_MANAGER": "1"}) is False
|
||||
|
||||
config.write_text("plugins:\n enabled: true\n", encoding="utf-8")
|
||||
assert plugin_manager_enabled(config, {}) is True
|
||||
assert plugin_manager_enabled(config, {"OPENHOP_PLUGIN_MANAGER": "false"}) is False
|
||||
|
||||
|
||||
def test_supervisor_restarts_failed_manager_and_stops_it_with_repeater():
|
||||
manager_one = FakeProcess(101, [1])
|
||||
manager_two = FakeProcess(102, [None, None])
|
||||
repeater = FakeProcess(201, [None, None, 0])
|
||||
processes = [manager_one, repeater, manager_two]
|
||||
commands = []
|
||||
signalled = []
|
||||
|
||||
def popen(command, **kwargs):
|
||||
commands.append((command, kwargs))
|
||||
return processes.pop(0)
|
||||
|
||||
def signal_group(process, signum):
|
||||
signalled.append((process.pid, signum))
|
||||
process.returncode = -signum
|
||||
|
||||
supervisor = ContainerSupervisor(
|
||||
"/tmp/config.yaml",
|
||||
manager_enabled=True,
|
||||
popen_factory=popen,
|
||||
signal_process_group=signal_group,
|
||||
process_group_exists=lambda process: process.pid == manager_one.pid,
|
||||
sleep=lambda _seconds: None,
|
||||
manager_restart_delay=0,
|
||||
)
|
||||
|
||||
assert supervisor.run() == 0
|
||||
assert [command for command, _kwargs in commands] == [
|
||||
["python3", "-m", "repeater.plugins", "--config", "/tmp/config.yaml"],
|
||||
["python3", "-m", "repeater.main", "--config", "/tmp/config.yaml"],
|
||||
["python3", "-m", "repeater.plugins", "--config", "/tmp/config.yaml"],
|
||||
]
|
||||
assert all(kwargs["start_new_session"] is True for _command, kwargs in commands)
|
||||
assert (101, signal.SIGTERM) in signalled
|
||||
assert (101, signal.SIGKILL) in signalled
|
||||
assert (102, signal.SIGTERM) in signalled
|
||||
assert len(manager_two.wait_calls) == 1
|
||||
|
||||
|
||||
def test_shutdown_signal_is_forwarded_to_both_process_groups():
|
||||
manager = FakeProcess(301, [None])
|
||||
repeater = FakeProcess(302, [None])
|
||||
signalled = []
|
||||
|
||||
supervisor = ContainerSupervisor(
|
||||
"/tmp/config.yaml",
|
||||
manager_enabled=True,
|
||||
signal_process_group=lambda process, signum: signalled.append((process.pid, signum)),
|
||||
)
|
||||
supervisor.manager_process = manager
|
||||
supervisor.repeater_process = repeater
|
||||
|
||||
supervisor.request_shutdown(signal.SIGTERM)
|
||||
|
||||
assert signalled == [(302, signal.SIGTERM), (301, signal.SIGTERM)]
|
||||
@@ -148,6 +148,27 @@ def test_download_wheel(tmp_path: Path):
|
||||
assert path.name.endswith(".whl")
|
||||
|
||||
|
||||
def test_github_token_authenticates_api_but_not_asset_download(tmp_path: Path):
|
||||
payload = [_release("v0.1.0")]
|
||||
seen_headers = []
|
||||
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
seen_headers.append((request.full_url, dict(request.header_items())))
|
||||
if "api.github.com" in request.full_url:
|
||||
return _FakeResp(json.dumps(payload).encode())
|
||||
if request.full_url.endswith(".whl"):
|
||||
return _FakeResp(b"wheel-bytes")
|
||||
raise AssertionError(request.full_url)
|
||||
|
||||
client = GitHubReleaseClient(opener=opener, token="test-token")
|
||||
client.download_latest_wheel("org/repo", tmp_path)
|
||||
|
||||
api_headers = next(headers for url, headers in seen_headers if "api.github.com" in url)
|
||||
asset_headers = next(headers for url, headers in seen_headers if url.endswith(".whl"))
|
||||
assert api_headers["Authorization"] == "Bearer test-token"
|
||||
assert "Authorization" not in asset_headers
|
||||
|
||||
|
||||
def test_rate_limit():
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
headers = {"X-RateLimit-Remaining": "0"}
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -50,6 +52,14 @@ def _make_wheel(path: Path, manifest: dict, ui_files: dict[str, str] | None = No
|
||||
return path
|
||||
|
||||
|
||||
def test_default_github_client_uses_service_scoped_token(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "service-token")
|
||||
|
||||
manager = PluginManager(PluginStorage(tmp_path / "plugins"))
|
||||
|
||||
assert manager.github.token == "service-token"
|
||||
|
||||
|
||||
def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
calls = []
|
||||
@@ -92,6 +102,7 @@ def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch):
|
||||
paths = storage.paths_for("openhop.demo")
|
||||
assert paths.data_dir.is_dir()
|
||||
assert paths.venv_dir("0.1.0").is_dir()
|
||||
assert (paths.release_dir("0.1.0") / wheel.name).is_file()
|
||||
assert any("venv" in c for c in calls)
|
||||
assert any("pip" in c for c in calls)
|
||||
# Ensure we never invoked the system pip against a non-venv target alone
|
||||
@@ -100,13 +111,102 @@ def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch):
|
||||
assert str(paths.venv_dir("0.1.0")) in c[0] or "venv" in "".join(c)
|
||||
|
||||
|
||||
def test_enable_start_stop_disable(tmp_path: Path):
|
||||
def test_start_rebuilds_incompatible_persisted_venv_from_archived_wheel(tmp_path: Path):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
plugin_id = "openhop.demo"
|
||||
version = "0.1.0"
|
||||
paths = storage.ensure_plugin_layout(plugin_id, version)
|
||||
storage.write_state(plugin_id, {"version": version, "enabled": True})
|
||||
storage.write_manifest(
|
||||
plugin_id,
|
||||
version,
|
||||
PluginManifest(
|
||||
schema=1,
|
||||
id=plugin_id,
|
||||
name="Demo",
|
||||
version=version,
|
||||
runtime=RuntimeSpec(type="python", entrypoint="demo-cli"),
|
||||
),
|
||||
)
|
||||
storage.set_current(plugin_id, version)
|
||||
archived_wheel = paths.release_dir(version) / "demo-0.1.0-py3-none-any.whl"
|
||||
_make_wheel(
|
||||
archived_wheel,
|
||||
{
|
||||
"schema": 1,
|
||||
"id": plugin_id,
|
||||
"name": "Demo",
|
||||
"version": version,
|
||||
"runtime": {"type": "python", "entrypoint": "demo-cli"},
|
||||
},
|
||||
)
|
||||
venv = paths.venv_dir(version)
|
||||
(venv / "bin").mkdir(parents=True)
|
||||
(venv / "bin" / "python").write_text("old", encoding="utf-8")
|
||||
(venv / "pyvenv.cfg").write_text("version = 3.11.9\n", encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
if "venv" in cmd:
|
||||
target = Path(cmd[-1])
|
||||
(target / "bin").mkdir(parents=True, exist_ok=True)
|
||||
(target / "bin" / "python").write_text("new", encoding="utf-8")
|
||||
(target / "pyvenv.cfg").write_text(
|
||||
f"version = {sys.version_info.major}.{sys.version_info.minor}.0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if "pip" in cmd:
|
||||
entrypoint = Path(cmd[0]).parent / "demo-cli"
|
||||
entrypoint.write_text("new entrypoint", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
runtime = PluginRuntime(
|
||||
storage,
|
||||
run_factory=fake_run,
|
||||
popen_factory=lambda *args, **kwargs: FakeProc(),
|
||||
)
|
||||
runtime.start(plugin_id)
|
||||
|
||||
assert any("venv" in call for call in calls)
|
||||
assert any("pip" in call for call in calls)
|
||||
assert (venv / "bin" / "python").read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
def test_manager_starts_enabled_plugin(tmp_path: Path):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
storage.ensure_plugin_layout("openhop.demo", "0.1.0")
|
||||
storage.write_state("openhop.demo", {"version": "0.1.0", "enabled": True})
|
||||
storage.write_manifest(
|
||||
"openhop.demo",
|
||||
"0.1.0",
|
||||
PluginManifest(
|
||||
schema=1,
|
||||
id="openhop.demo",
|
||||
name="Demo",
|
||||
version="0.1.0",
|
||||
runtime=RuntimeSpec(type="python", entrypoint="demo-cli"),
|
||||
),
|
||||
)
|
||||
storage.set_current("openhop.demo", "0.1.0")
|
||||
runtime = MagicMock(spec=PluginRuntime)
|
||||
manager = PluginManager(storage, runtime)
|
||||
|
||||
manager.start()
|
||||
|
||||
runtime.start.assert_called_once_with("openhop.demo")
|
||||
|
||||
|
||||
def test_enable_start_stop_disable(tmp_path: Path, monkeypatch):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
procs: list[FakeProc] = []
|
||||
child_environments = []
|
||||
monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "must-not-reach-plugin")
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
p = FakeProc(pid=1000 + len(procs))
|
||||
procs.append(p)
|
||||
child_environments.append(kwargs["env"])
|
||||
# Write a line to log fd if provided
|
||||
log_fp = kwargs.get("stdout")
|
||||
if log_fp and hasattr(log_fp, "write"):
|
||||
@@ -159,6 +259,7 @@ def test_enable_start_stop_disable(tmp_path: Path):
|
||||
st = manager.disable("openhop.demo")
|
||||
assert st["enabled"] is False
|
||||
assert st["state"] == PluginState.DISABLED.value
|
||||
assert all("OPENHOP_PLUGIN_GITHUB_TOKEN" not in env for env in child_environments)
|
||||
|
||||
logs = manager.logs("openhop.demo", tail=10)
|
||||
assert any("hello" in line for line in logs["lines"])
|
||||
|
||||
Reference in new issue
Block a user