wrolpi/controller/lib/scripts.py
Roland e42e4ed06e Controller: add help.sh to the Maintenance Scripts.
A new wrolpi-help-script.service oneshot runs /opt/wrolpi/help.sh so the
Controller UI can run system diagnostics and show the output from the
journal.  The confirmation modal hides the warnings block for scripts
with no warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:00:43 -06:00

346 lines
11 KiB
Python

"""
Scripts management for WROLPi Controller.
Manages maintenance scripts that run as systemd services (like repair.sh).
Uses systemd oneshot services for process survival across controller restarts.
"""
import logging
import subprocess
from datetime import datetime
from typing import Optional
from controller.lib.config import is_docker_mode
logger = logging.getLogger(__name__)
# Available scripts with their configurations
# Parameters define inputs that scripts accept:
# - name: Parameter name (used as env var name, e.g., "branch" -> "BRANCH=value")
# - label: Display label for the input field
# - type: "branch" (auto-populates with current git branch) or "text"
# - required: Whether the parameter must have a value
AVAILABLE_SCRIPTS = {
"help": {
"name": "help",
"display_name": "System Diagnostics",
"description": "Runs help.sh which checks WROLPi services, certificates, database, and dependencies."
" Read-only; changes nothing.",
"service_name": "wrolpi-help-script.service",
"warnings": [],
},
"repair": {
"name": "repair",
"display_name": "Repair WROLPi",
"description": "Repairs WROLPi installation by resetting configs and restarting services.",
"service_name": "wrolpi-repair.service",
"warnings": [
"Resets git repository (discards any local code changes)",
"Stops ALL WROLPi services temporarily",
"May restore databases from backup blobs",
"Generates new SSL certificates",
"Takes several minutes to complete",
],
},
"upgrade": {
"name": "upgrade",
"display_name": "Upgrade WROLPi",
"description": "Upgrades WROLPi to the latest version from the specified branch.",
"service_name": "wrolpi-upgrade.service",
"env_file": "/tmp/wrolpi-upgrade.env",
"parameters": [
{"name": "branch", "label": "Branch", "type": "branch", "required": False},
],
"warnings": [
"Downloads updates from the internet",
"Restarts ALL WROLPi services",
"May take several minutes depending on connection speed",
"UI will be unavailable during upgrade",
],
},
"fix-permissions": {
"name": "fix-permissions",
"display_name": "Fix Media Permissions",
"description": "Fixes ownership and permissions on the media directory so WROLPi can read and write all files.",
"service_name": "wrolpi-fix-media-permissions.service",
"warnings": [
"Changes ownership of all media files to wrolpi:wrolpi",
"Ensures all files are readable and directories are traversable",
"May take a long time on large media libraries",
"Some filesystems (FAT/exFAT) do not support Unix permissions",
],
},
}
def get_current_branch() -> Optional[str]:
"""
Get the current git branch of /opt/wrolpi.
Returns:
Branch name, or None if unable to determine
"""
try:
result = subprocess.run(
["git", "-C", "/opt/wrolpi", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
return None
def list_available_scripts() -> list[dict]:
"""
List all available scripts with their info.
Returns:
List of script info dicts suitable for API response
"""
scripts = []
available = not is_docker_mode()
for name, info in AVAILABLE_SCRIPTS.items():
scripts.append({
"name": info["name"],
"display_name": info["display_name"],
"description": info["description"],
"warnings": info["warnings"],
"available": available,
"parameters": info.get("parameters", []),
})
return scripts
def get_script_status() -> dict:
"""
Check if any script service is currently running.
Returns:
dict with:
running: bool - Whether a script is running
script_name: Optional[str] - Name of running script
service_name: Optional[str] - Systemd service name
started_at: Optional[str] - ISO timestamp when started
elapsed_seconds: Optional[int] - Seconds since start
"""
if is_docker_mode():
return {"running": False}
for name, info in AVAILABLE_SCRIPTS.items():
service = info["service_name"]
try:
# Check if service is active (running)
result = subprocess.run(
["systemctl", "is-active", service],
capture_output=True,
text=True,
timeout=5,
)
status = result.stdout.strip()
if status == "activating":
# Service is running - get start time
started_at, elapsed = _get_service_timing(service)
return {
"running": True,
"script_name": name,
"service_name": service,
"started_at": started_at,
"elapsed_seconds": elapsed,
}
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
continue
return {"running": False}
def _get_service_timing(service: str) -> tuple[Optional[str], Optional[int]]:
"""
Get timing info for a running service.
Returns:
Tuple of (started_at ISO string, elapsed_seconds)
"""
try:
# Get ExecMainStartTimestamp from systemctl show
result = subprocess.run(
["systemctl", "show", service, "--property=ExecMainStartTimestamp"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# Format: ExecMainStartTimestamp=Wed 2025-01-15 10:30:00 UTC
line = result.stdout.strip()
if "=" in line:
timestamp_str = line.split("=", 1)[1].strip()
if timestamp_str:
# Parse the timestamp
try:
# Try common systemd timestamp format
dt = datetime.strptime(timestamp_str, "%a %Y-%m-%d %H:%M:%S %Z")
elapsed = int((datetime.utcnow() - dt).total_seconds())
return dt.isoformat(), max(0, elapsed)
except ValueError:
pass
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
return None, None
def start_script(name: str, params: Optional[dict] = None) -> dict:
"""
Start a script by starting its systemd service.
Args:
name: Script name (key in AVAILABLE_SCRIPTS)
params: Optional dict of parameter values (e.g., {"branch": "release"})
Returns:
dict with:
success: bool
message: Optional[str]
error: Optional[str]
"""
if is_docker_mode():
return {
"success": False,
"error": "Scripts are not available in Docker mode",
}
if name not in AVAILABLE_SCRIPTS:
return {
"success": False,
"error": f"Unknown script: {name}",
}
# Check if already running
status = get_script_status()
if status.get("running"):
if status.get("script_name") == name:
return {
"success": False,
"error": f"Script '{name}' is already running",
}
else:
return {
"success": False,
"error": f"Another script is already running: {status.get('script_name')}",
}
script_config = AVAILABLE_SCRIPTS[name]
service = script_config["service_name"]
# Write parameters to env file if script has one configured
env_file = script_config.get("env_file")
if env_file and params:
try:
with open(env_file, "w") as f:
for key, value in params.items():
if value: # Only write non-empty values
# Convert param name to uppercase for env var
env_name = key.upper()
f.write(f"{env_name}={value}\n")
except OSError as e:
return {
"success": False,
"error": f"Failed to write script config: {e}",
}
logger.info("Starting script '%s' (service: %s)", name, service)
try:
# Start the service (non-blocking via Popen)
# No sudo needed - Controller runs as root
subprocess.Popen(
["systemctl", "start", service],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
logger.info("Script '%s' started successfully", name)
return {
"success": True,
"message": f"Script '{name}' started",
}
except FileNotFoundError:
logger.warning("systemctl not found, cannot start script '%s'", name)
return {
"success": False,
"error": "systemctl not found",
}
except subprocess.SubprocessError as e:
logger.warning("Failed to start script '%s': %s", name, e)
return {
"success": False,
"error": str(e),
}
def get_script_output(name: str, lines: int = 100) -> dict:
"""
Get output from a script's systemd service via journalctl.
Args:
name: Script name
lines: Number of lines to retrieve (default 100)
Returns:
dict with:
output: str - Log output
lines: int - Lines requested
script_name: str - Script name
"""
if is_docker_mode():
return {
"output": "Scripts are not available in Docker mode",
"lines": 0,
"script_name": name,
}
if name not in AVAILABLE_SCRIPTS:
return {
"output": f"Unknown script: {name}",
"lines": 0,
"script_name": name,
}
service = AVAILABLE_SCRIPTS[name]["service_name"]
try:
result = subprocess.run(
["journalctl", "-u", service, "-n", str(lines), "--no-pager"],
capture_output=True,
text=True,
timeout=10,
)
return {
"output": result.stdout,
"lines": lines,
"script_name": name,
}
except subprocess.TimeoutExpired:
return {
"output": "Timeout fetching logs",
"lines": 0,
"script_name": name,
}
except FileNotFoundError:
return {
"output": "journalctl not found",
"lines": 0,
"script_name": name,
}
except subprocess.SubprocessError as e:
return {
"output": str(e),
"lines": 0,
"script_name": name,
}