mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
555 lines
17 KiB
Python
555 lines
17 KiB
Python
"""
|
|
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 get_service_kind(service_config: dict) -> str:
|
|
"""
|
|
Classify a service as 'persistent' or 'task'.
|
|
|
|
'persistent' services are the always-on parts of a running WROLPi (api,
|
|
app, caddy, kiwix, ...). 'task' services are one-off/dev/maintenance units
|
|
(bootstrap, repair, upgrade, *-dev) that run on demand and are not expected
|
|
to stay running.
|
|
|
|
An explicit "kind" in the service config wins. Otherwise services flagged
|
|
show_only_when_running (dev/upgrade units) are treated as tasks, and
|
|
everything else defaults to persistent.
|
|
"""
|
|
kind = service_config.get("kind")
|
|
if kind:
|
|
return kind
|
|
if service_config.get("show_only_when_running"):
|
|
return "task"
|
|
return "persistent"
|
|
|
|
|
|
def classify_service_group(kind: str, enabled: bool) -> str:
|
|
"""
|
|
Group a service for display in the Controller UI.
|
|
|
|
Returns:
|
|
"core" - a persistent service enabled at boot: the normal running
|
|
system the user cares about day-to-day.
|
|
"optional" - one-off/dev/maintenance tasks, or persistent services the
|
|
user has disabled at boot (e.g. Samba with no shares).
|
|
|
|
Membership only changes when the user toggles Boot for a persistent
|
|
service, never on start/stop or polling, so rows stay put.
|
|
"""
|
|
if kind == "persistent" and enabled:
|
|
return "core"
|
|
return "optional"
|
|
|
|
|
|
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()
|
|
|
|
# Loaded state distinguishes a stopped unit from one that does not
|
|
# exist on this box at all.
|
|
load_result = subprocess.run(
|
|
["systemctl", "show", "-p", "LoadState", "--value", systemd_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
load_state = load_result.stdout.strip()
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
kind = get_service_kind(service_config)
|
|
return {
|
|
"name": name,
|
|
"systemd_name": systemd_name,
|
|
"status": "unknown",
|
|
"active": "unknown",
|
|
"enabled": False,
|
|
"installed": 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", ""),
|
|
"kind": kind,
|
|
"group": classify_service_group(kind, False),
|
|
"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"
|
|
|
|
enabled = is_enabled == "enabled"
|
|
kind = get_service_kind(service_config)
|
|
return {
|
|
"name": name,
|
|
"systemd_name": systemd_name,
|
|
"status": status,
|
|
"installed": load_state not in ("not-found", ""),
|
|
"active": is_active,
|
|
"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", ""),
|
|
"kind": kind,
|
|
"group": classify_service_group(kind, enabled),
|
|
}
|
|
|
|
|
|
def discover_wrolpi_services() -> list[str]:
|
|
"""
|
|
Discover wrolpi-* systemd services not in the managed services config.
|
|
|
|
Unions loaded units (--all also catches transient units and units that
|
|
have exited this boot) with installed unit files, so a finished oneshot
|
|
(e.g. wrolpi-upgrade) or a dev service stays visible in the UI and its
|
|
logs remain reachable.
|
|
|
|
Returns:
|
|
list of service names without the .service suffix
|
|
"""
|
|
managed_systemd_names = {
|
|
s.get("systemd_name", s["name"])
|
|
for s in get_managed_services()
|
|
}
|
|
|
|
commands = (
|
|
["systemctl", "list-units", "wrolpi-*", "--type=service",
|
|
"--all", "--plain", "--no-legend", "--no-pager"],
|
|
["systemctl", "list-unit-files", "wrolpi-*", "--type=service",
|
|
"--no-legend", "--no-pager"],
|
|
)
|
|
|
|
discovered: list[str] = []
|
|
for cmd in commands:
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
continue
|
|
if result.returncode != 0:
|
|
continue
|
|
for line in result.stdout.strip().splitlines():
|
|
unit_name = next((p for p in line.split() if p.endswith(".service")), None)
|
|
if not unit_name:
|
|
continue
|
|
service_name = unit_name.removesuffix(".service")
|
|
if service_name not in managed_systemd_names and service_name not in discovered:
|
|
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": "",
|
|
"kind": "task",
|
|
"group": "optional",
|
|
"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": "",
|
|
# Discovered wrolpi-* units are always one-off/maintenance tasks.
|
|
"kind": "task",
|
|
"group": "optional",
|
|
}
|
|
|
|
|
|
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 hidden only when their
|
|
unit is not installed on this box (e.g. wrolpi-api-dev on production).
|
|
Installed-but-stopped services stay visible so a finished oneshot
|
|
(wrolpi-upgrade) keeps its logs reachable from the UI.
|
|
|
|
Returns:
|
|
list of service status dicts
|
|
"""
|
|
results = []
|
|
for service in get_managed_services():
|
|
status = get_service_status(service["name"])
|
|
if service.get("show_only_when_running") and not status.get("installed"):
|
|
continue
|
|
results.append(status)
|
|
|
|
# Any other wrolpi-* services (installed or transient), regardless of
|
|
# running state.
|
|
for name in discover_wrolpi_services():
|
|
results.append(get_discovered_service_status(name))
|
|
|
|
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"}
|