fix(docker): supervise plugin manager lifecycle

This commit is contained in:
yellowcooln committed 2026-09-04 10:39:42 -04:00
1 parent 8054333e3f
commit c0aeb89399
15 files changed
+858 -25

No files matched your search

+39
View File
@@ -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
)
+105
View File
@@ -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)]
+21
View File
@@ -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"}
+102 -1
View File
@@ -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"])