diff --git a/repeater/main.py b/repeater/main.py index 3480a0c..e1e67f8 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -291,6 +291,17 @@ class RepeaterDaemon: logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}") + try: + from repeater.service_utils import ensure_plugin_manager_service + + ok, msg = ensure_plugin_manager_service() + if ok: + logger.info("Plugin-manager bootstrap: %s", msg) + else: + logger.warning("Plugin-manager bootstrap: %s", msg) + except Exception as exc: # pragma: no cover - defensive safeguard + logger.warning("Plugin-manager bootstrap check raised an exception: %s", exc) + # ----------------------------------------------- # Get the actual Network IP Address try: diff --git a/repeater/service_utils.py b/repeater/service_utils.py index 8667586..a1d114d 100644 --- a/repeater/service_utils.py +++ b/repeater/service_utils.py @@ -96,6 +96,123 @@ def get_container_restart_message() -> str: ) +def ensure_plugin_manager_service() -> Tuple[bool, str]: + """Ensure the packaged plugin-manager systemd unit is installed and running. + + This is intentionally idempotent and root-only. It is safe to run during + startup and after upgrades so a single upgrade can heal older installs that + pre-date the plugin-manager system service. + """ + if is_container() or is_buildroot(): + return True, "Plugin-manager bootstrap skipped in container/buildroot environment" + + if os.geteuid() != 0: + logger.info( + "Plugin-manager bootstrap skipped: root privileges required for systemd provisioning" + ) + return ( + True, + "Plugin-manager bootstrap skipped: root privileges required for systemd provisioning", + ) + + venv_python = "/opt/openhop_repeater/venv/bin/python" + if not os.path.isfile(venv_python): + return False, "Plugin-manager bootstrap skipped: venv not present" + + package_unit = None + code = """ +from importlib.metadata import distribution +from pathlib import Path +import sys +try: + dist = distribution('openhop_repeater') +except Exception: + sys.exit(1) +try: + path = Path(dist.locate_file('repeater/plugins/openhop-plugin-manager.service')) +except Exception: + sys.exit(1) +if path.is_file(): + print(path) + sys.exit(0) +sys.exit(1) +""" + try: + result = subprocess.run( + [venv_python, "-I", "-c", code], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + package_unit = result.stdout.strip().splitlines()[-1] + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("Plugin-manager bootstrap lookup failed: %s", exc) + return False, f"Plugin-manager bootstrap lookup failed: {exc}" + + if not package_unit: + return ( + False, + "Plugin-manager bootstrap skipped: packaged service not found in installed package", + ) + + unit_path = "/etc/systemd/system/openhop-plugin-manager.service" + changed = False + try: + if not os.path.exists(unit_path): + logger.info("Plugin-manager unit missing; installing packaged service") + changed = True + else: + with open(package_unit, "rb") as src, open(unit_path, "rb") as dst: + if src.read() != dst.read(): + logger.info("Plugin-manager unit differs from packaged version; updating") + changed = True + + if changed: + shutil.copy2(package_unit, unit_path) + subprocess.run( + [_SYSTEMCTL_BIN, "daemon-reload"], check=False, capture_output=True, text=True + ) + logger.info("Plugin-manager unit installed or refreshed") + else: + logger.info("Plugin-manager unit already matches packaged version") + + subprocess.run( + [_SYSTEMCTL_BIN, "enable", "openhop-plugin-manager"], + check=False, + capture_output=True, + text=True, + ) + active = subprocess.run( + [_SYSTEMCTL_BIN, "is-active", "--quiet", "openhop-plugin-manager"], + check=False, + capture_output=True, + text=True, + ) + if active.returncode != 0: + logger.info("Starting plugin-manager service") + subprocess.run( + [_SYSTEMCTL_BIN, "start", "openhop-plugin-manager"], + check=False, + capture_output=True, + text=True, + ) + elif changed: + logger.info("Restarting plugin-manager service after unit refresh") + subprocess.run( + [_SYSTEMCTL_BIN, "restart", "openhop-plugin-manager"], + check=False, + capture_output=True, + text=True, + ) + + return True, "Plugin-manager service is installed and active" + except Exception as exc: + logger.warning("Plugin-manager bootstrap failed: %s", exc) + return False, f"Plugin-manager bootstrap failed: {exc}" + + def restart_service() -> Tuple[bool, str]: """ Restart the openhop-repeater service. diff --git a/tests/test_service_utils.py b/tests/test_service_utils.py index d0ed3d9..23c454c 100644 --- a/tests/test_service_utils.py +++ b/tests/test_service_utils.py @@ -226,3 +226,23 @@ def test_restart_service_systemctl_and_sudo_paths(monkeypatch): ok, msg = su.restart_service() assert ok is False assert "Restart command failed" in msg + + +def test_ensure_plugin_manager_service_runs_root_bootstrap(monkeypatch): + monkeypatch.setattr(su, "is_container", lambda: False) + monkeypatch.setattr(su, "is_buildroot", lambda: False) + monkeypatch.setattr(su.os, "geteuid", lambda: 1000) + monkeypatch.setattr( + su.shutil, "which", lambda name: "/usr/bin/sudo" if name == "sudo" else None + ) + monkeypatch.setattr( + su.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=args[0], returncode=0, stdout="ok", stderr="" + ), + ) + + ok, msg = su.ensure_plugin_manager_service() + assert ok is True + assert "root privileges" in msg.lower()