""" Service management utilities for pyMC Repeater. Provides functions for service control operations like restart. """ import logging import os import shutil import subprocess # nosec B404 import threading import time from typing import Dict, Optional, Tuple logger = logging.getLogger("ServiceUtils") INIT_SCRIPT = "/etc/init.d/S80pymc-repeater" BUILDROOT_METADATA_PATH = "/etc/pymc-image-build-id" _CONTAINER_RESTART_DELAY_SECONDS = 1.0 _SH_BIN = shutil.which("sh") or "sh" _SYSTEMCTL_BIN = shutil.which("systemctl") or "systemctl" _SUDO_BIN = shutil.which("sudo") or "sudo" def is_buildroot() -> bool: if os.path.exists(BUILDROOT_METADATA_PATH): return True if os.path.exists("/etc/os-release"): try: with open("/etc/os-release", "r", encoding="utf-8") as handle: return any(line.strip() == "ID=buildroot" for line in handle) except OSError: return False return False def get_buildroot_image_info() -> Dict[str, str]: info: Dict[str, str] = {} try: with open(BUILDROOT_METADATA_PATH, "r", encoding="utf-8") as handle: for line in handle: line = line.strip() if not line or "=" not in line: continue key, value = line.split("=", 1) info[key.strip()] = value.strip() except OSError: return {} return info def get_buildroot_image_version() -> Optional[str]: return get_buildroot_image_info().get("image_version") def is_container() -> bool: """Detect common Docker/LXC/containerized environments.""" if os.path.exists("/.dockerenv") or os.environ.get("container"): return True try: with open("/proc/1/environ", "rb") as handle: if b"container=" in handle.read(): return True except (OSError, PermissionError): pass try: with open("/proc/1/cgroup", "r", encoding="utf-8") as handle: cgroup_data = handle.read() if any(token in cgroup_data for token in ("docker", "containerd", "kubepods", "lxc")): return True except OSError: pass return os.path.exists("/run/host/container-manager") def _schedule_container_exit(delay_seconds: float = _CONTAINER_RESTART_DELAY_SECONDS) -> None: """Exit the current process shortly after returning success to the caller.""" def _exit_process() -> None: time.sleep(delay_seconds) logger.warning("Exiting repeater process to trigger container restart") os._exit(0) threading.Thread(target=_exit_process, name="container-restart-exit", daemon=True).start() def get_container_restart_message() -> str: """Return the user-facing restart message for containerized installs.""" return ( "Container restart initiated. " "If you are running pyMC Repeater via Docker or Home Assistant, pull or rebuild " "a newer image for packaged image updates to take effect." ) def restart_service() -> Tuple[bool, str]: """ Restart the pymc-repeater service. On Buildroot/Luckfox, use the shipped init script directly. On systemd hosts, try polkit-based restart first (plain systemctl), then fall back to sudo-based restart (requires sudoers.d rule installed by manage.sh). Returns: Tuple[bool, str]: (success, message) """ if is_container(): _schedule_container_exit() logger.info("Container environment detected; scheduled process exit for container restart") return True, get_container_restart_message() if is_buildroot(): if not os.path.exists(INIT_SCRIPT): logger.error("Buildroot init script not found: %s", INIT_SCRIPT) return False, f"init script not found: {INIT_SCRIPT}" try: subprocess.Popen( [_SH_BIN, "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True, ) # nosec B603 logger.info("Service restart scheduled via Buildroot init script") return True, "Service restart initiated" except Exception as exc: logger.error(f"Buildroot restart failed: {exc}") return False, f"Restart failed: {exc}" # Try polkit-based restart first (works on bare metal / VMs with polkit running) try: result = subprocess.run( [_SYSTEMCTL_BIN, "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5, ) # nosec B603 if result.returncode == 0: logger.info("Service restart via polkit succeeded") return True, "Service restart initiated" stderr = result.stderr or "" if "Access denied" in stderr or "authorization" in stderr.lower(): logger.info("Polkit denied restart, trying sudo fallback...") else: # Some other error, still try sudo logger.warning(f"systemctl restart failed ({result.returncode}): {stderr.strip()}") except subprocess.TimeoutExpired: # Timeout likely means it's restarting - that's success logger.warning("Service restart command timed out (service may be restarting)") return True, "Service restart initiated (timeout - likely restarting)" except FileNotFoundError: logger.error("systemctl not found") return False, "systemctl not available" except Exception as e: logger.warning(f"Polkit restart attempt failed: {e}") # Fallback: use sudo (requires /etc/sudoers.d/pymc-repeater rule) try: result = subprocess.run( [_SUDO_BIN, "--non-interactive", _SYSTEMCTL_BIN, "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5, ) # nosec B603 if result.returncode == 0: logger.info("Service restart via sudo succeeded") return True, "Service restart initiated" else: error_msg = result.stderr or "Unknown error" logger.error(f"Service restart via sudo failed: {error_msg}") return False, f"Restart failed: {error_msg}" except subprocess.TimeoutExpired: logger.warning("Sudo restart timed out (service likely restarting)") return True, "Service restart initiated (timeout - likely restarting)" except FileNotFoundError: logger.error("sudo not found - cannot restart service") return False, "Neither polkit nor sudo available for service restart" except Exception as e: logger.error(f"Error executing sudo restart: {e}") return False, f"Restart command failed: {str(e)}"