""" Systemd service management for WROLPi Controller. Provides start/stop/restart/status/logs for systemd services. """ import logging import subprocess from typing import Optional from controller.lib.config import get_config logger = logging.getLogger(__name__) def get_managed_services() -> list[dict]: """ Get list of managed services from config. Returns: list of service configs with name, systemd_name, port, etc. """ config = get_config() return config.get("managed_services", []) def get_service_config(name: str) -> Optional[dict]: """ Get config for a specific service by name. Args: name: Service name (e.g., "wrolpi-api") Returns: Service config dict or None if not found """ for service in get_managed_services(): if service.get("name") == name: return service return None def _run_systemctl(command: str, service: str, timeout: int = 30) -> dict: """ Run a systemctl command. Args: command: systemctl command (start, stop, restart, status, etc.) service: systemd service name timeout: Command timeout in seconds Returns: dict with success, output, and error fields """ try: result = subprocess.run( ["systemctl", command, service], capture_output=True, text=True, timeout=timeout, ) return { "success": result.returncode == 0, "output": result.stdout.strip(), "error": result.stderr.strip() if result.returncode != 0 else None, } except subprocess.TimeoutExpired: return {"success": False, "output": "", "error": "Command timed out"} except FileNotFoundError: return {"success": False, "output": "", "error": "systemctl not found"} def get_service_status(name: str) -> dict: """ Get status of a managed service. Args: name: Service name from config Returns: dict with status, active, enabled, etc. """ service_config = get_service_config(name) if not service_config: # Allow dynamically discovered wrolpi-* services. if name.startswith("wrolpi-"): return get_discovered_service_status(name) return {"error": f"Unknown service: {name}"} systemd_name = service_config.get("systemd_name", name) try: # Get active state active_result = subprocess.run( ["systemctl", "is-active", systemd_name], capture_output=True, text=True, timeout=5, ) is_active = active_result.stdout.strip() # Get enabled state enabled_result = subprocess.run( ["systemctl", "is-enabled", systemd_name], capture_output=True, text=True, timeout=5, ) is_enabled = enabled_result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError): return { "name": name, "systemd_name": systemd_name, "status": "unknown", "active": "unknown", "enabled": False, "port": service_config.get("port"), "viewable": service_config.get("viewable", False), "view_path": service_config.get("view_path", ""), "use_https": service_config.get("use_https", False), "description": service_config.get("description", ""), "error": "systemctl not available", } # Map to simple status if is_active == "active": status = "running" elif is_active == "activating": status = "running" # oneshot services show "activating" while running elif is_active == "inactive": status = "stopped" elif is_active == "failed": status = "failed" else: status = "unknown" return { "name": name, "systemd_name": systemd_name, "status": status, "active": is_active, "enabled": is_enabled == "enabled", "port": service_config.get("port"), "viewable": service_config.get("viewable", False), "view_path": service_config.get("view_path", ""), "use_https": service_config.get("use_https", False), "description": service_config.get("description", ""), } def discover_running_wrolpi_services() -> list[str]: """ Discover running wrolpi-* systemd services not in the managed services config. Returns: list of systemd unit names (e.g., ["wrolpi-fix-media-permissions.service"]) """ managed_systemd_names = { s.get("systemd_name", s["name"]) for s in get_managed_services() } try: result = subprocess.run( ["systemctl", "list-units", "wrolpi-*", "--type=service", "--no-legend", "--no-pager"], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: return [] except (subprocess.TimeoutExpired, FileNotFoundError): return [] discovered = [] for line in result.stdout.strip().splitlines(): parts = line.split() if not parts: continue # Unit name is the first column, e.g. "wrolpi-fix-media-permissions.service" unit_name = parts[0] # Strip .service suffix to get the base name service_name = unit_name.removesuffix(".service") if service_name not in managed_systemd_names: discovered.append(service_name) return discovered def get_discovered_service_status(name: str) -> dict: """ Get status of a dynamically discovered wrolpi-* service (not in managed config). Args: name: Service name (e.g., "wrolpi-fix-media-permissions") Returns: dict with status fields """ try: active_result = subprocess.run( ["systemctl", "is-active", name], capture_output=True, text=True, timeout=5, ) is_active = active_result.stdout.strip() enabled_result = subprocess.run( ["systemctl", "is-enabled", name], capture_output=True, text=True, timeout=5, ) is_enabled = enabled_result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError): return { "name": name, "systemd_name": name, "status": "unknown", "active": "unknown", "enabled": False, "port": None, "viewable": False, "view_path": "", "use_https": False, "description": "", "error": "systemctl not available", } if is_active == "active": status = "running" elif is_active == "activating": status = "running" elif is_active == "inactive": status = "stopped" elif is_active == "failed": status = "failed" else: status = "unknown" return { "name": name, "systemd_name": name, "status": status, "active": is_active, "enabled": is_enabled == "enabled", "port": None, "viewable": False, "view_path": "", "use_https": False, "description": "", } def get_all_services_status() -> list[dict]: """ Get status of all managed services, plus any running wrolpi-* services discovered dynamically. Services with show_only_when_running=True are excluded when not running. Returns: list of service status dicts """ results = [] for service in get_managed_services(): status = get_service_status(service["name"]) # Skip services with show_only_when_running if not running if service.get("show_only_when_running") and status.get("status") != "running": continue results.append(status) # Discover any running wrolpi-* services not in the managed list. for name in discover_running_wrolpi_services(): status = get_discovered_service_status(name) if status.get("status") == "running": results.append(status) return results def _get_systemd_name(name: str) -> Optional[str]: """Get the systemd unit name for a service, supporting both managed and discovered wrolpi-* services.""" service_config = get_service_config(name) if service_config: return service_config.get("systemd_name", name) if name.startswith("wrolpi-"): return name return None def start_service(name: str) -> dict: """ Start a managed service. Args: name: Service name from config Returns: dict with success status """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"success": False, "error": f"Unknown service: {name}"} logger.info("Starting service %s (systemd: %s)", name, systemd_name) result = _run_systemctl("start", systemd_name) if result["success"]: logger.info("Service %s started successfully", name) else: logger.warning("Failed to start service %s: %s", name, result.get("error")) return { "success": result["success"], "service": name, "action": "start", "error": result.get("error"), } def stop_service(name: str) -> dict: """ Stop a managed service. Args: name: Service name from config Returns: dict with success status """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"success": False, "error": f"Unknown service: {name}"} logger.info("Stopping service %s (systemd: %s)", name, systemd_name) result = _run_systemctl("stop", systemd_name) if result["success"]: logger.info("Service %s stopped successfully", name) else: logger.warning("Failed to stop service %s: %s", name, result.get("error")) return { "success": result["success"], "service": name, "action": "stop", "error": result.get("error"), } def restart_service(name: str) -> dict: """ Restart a managed service. Args: name: Service name from config Returns: dict with success status """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"success": False, "error": f"Unknown service: {name}"} logger.info("Restarting service %s (systemd: %s)", name, systemd_name) # Special handling for self-restart: use Popen to avoid blocking # (the process will be killed before subprocess.run can return) if systemd_name == "wrolpi-controller": try: subprocess.Popen(["systemctl", "restart", systemd_name]) logger.info("Service %s self-restart initiated", name) return { "success": True, "service": name, "action": "restart", "pending": True, } except Exception as e: logger.warning("Failed to restart service %s: %s", name, e) return { "success": False, "service": name, "action": "restart", "error": str(e), } result = _run_systemctl("restart", systemd_name) if result["success"]: logger.info("Service %s restarted successfully", name) else: logger.warning("Failed to restart service %s: %s", name, result.get("error")) return { "success": result["success"], "service": name, "action": "restart", "error": result.get("error"), } def enable_service(name: str) -> dict: """ Enable a service to start at boot. Args: name: Service name from config Returns: dict with success status """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"success": False, "error": f"Unknown service: {name}"} logger.info("Enabling service %s (systemd: %s)", name, systemd_name) result = _run_systemctl("enable", systemd_name) if result["success"]: logger.info("Service %s enabled successfully", name) else: logger.warning("Failed to enable service %s: %s", name, result.get("error")) return { "success": result["success"], "service": name, "action": "enable", "error": result.get("error"), } def disable_service(name: str) -> dict: """ Disable a service from starting at boot. Args: name: Service name from config Returns: dict with success status """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"success": False, "error": f"Unknown service: {name}"} logger.info("Disabling service %s (systemd: %s)", name, systemd_name) result = _run_systemctl("disable", systemd_name) if result["success"]: logger.info("Service %s disabled successfully", name) else: logger.warning("Failed to disable service %s: %s", name, result.get("error")) return { "success": result["success"], "service": name, "action": "disable", "error": result.get("error"), } def get_service_logs(name: str, lines: int = 100, since: Optional[str] = None) -> dict: """ Get logs for a service. Args: name: Service name from config lines: Number of lines to return since: Time specification (e.g., "1h", "30m", "2024-01-01") Returns: dict with logs and metadata """ systemd_name = _get_systemd_name(name) if not systemd_name: return {"error": f"Unknown service: {name}"} cmd = ["journalctl", "-u", systemd_name, "-n", str(lines), "--no-pager"] if since: cmd.extend(["--since", since]) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) return { "service": name, "lines": lines, "since": since, "logs": result.stdout, } except subprocess.TimeoutExpired: return {"error": "Timeout getting logs"} except FileNotFoundError: return {"error": "journalctl not found"}