2026-02-25 15:32:43 -07:00
|
|
|
"""
|
|
|
|
|
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.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-03-14 10:03:11 -06:00
|
|
|
import logging
|
2026-02-25 15:32:43 -07:00
|
|
|
import subprocess
|
2026-10-11 18:29:42 -06:00
|
|
|
import time
|
|
|
|
|
from datetime import datetime, timezone
|
2026-02-25 15:32:43 -07:00
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
from controller.lib.config import is_docker_mode
|
|
|
|
|
|
2026-03-14 10:03:11 -06:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-25 15:32:43 -07:00
|
|
|
# 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 = {
|
2026-07-09 15:29:01 -06:00
|
|
|
"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": [],
|
|
|
|
|
},
|
2026-02-25 15:32:43 -07:00
|
|
|
"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",
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-03-05 17:56:15 -07:00
|
|
|
"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",
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-02-25 15:32:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_branch() -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Get the current git branch of /opt/wrolpi.
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-07-29 15:59:11 -06:00
|
|
|
Branch name ('release' on a detached HEAD), or None if unable to determine
|
2026-02-25 15:32:43 -07:00
|
|
|
"""
|
|
|
|
|
try:
|
2026-07-29 15:59:11 -06:00
|
|
|
# Not `rev-parse --abbrev-ref HEAD`: it reports "heads/release" when a stale
|
|
|
|
|
# `release` tag exists alongside the branch, breaking origin/{branch} lookups.
|
2026-02-25 15:32:43 -07:00
|
|
|
result = subprocess.run(
|
2026-07-29 15:59:11 -06:00
|
|
|
["git", "-C", "/opt/wrolpi", "branch", "--show-current"],
|
2026-02-25 15:32:43 -07:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=5,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode == 0:
|
2026-07-29 15:59:11 -06:00
|
|
|
# A detached HEAD prints nothing; upgrades come from `release` by default.
|
|
|
|
|
return result.stdout.strip() or "release"
|
2026-02-25 15:32:43 -07:00
|
|
|
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:
|
2026-10-11 18:29:42 -06:00
|
|
|
# A Unix timestamp, because systemd prints dates in the local timezone.
|
2026-02-25 15:32:43 -07:00
|
|
|
result = subprocess.run(
|
2026-10-11 18:29:42 -06:00
|
|
|
["systemctl", "show", service, "--property=ExecMainStartTimestamp", "--timestamp=unix"],
|
2026-02-25 15:32:43 -07:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=5,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode == 0:
|
2026-10-11 18:29:42 -06:00
|
|
|
# Format: ExecMainStartTimestamp=@1760226500.123456
|
|
|
|
|
value = result.stdout.strip().partition("=")[2].lstrip("@")
|
|
|
|
|
if value:
|
|
|
|
|
try:
|
|
|
|
|
started = float(value)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
elapsed = int(time.time() - started)
|
|
|
|
|
return datetime.fromtimestamp(started, timezone.utc).isoformat(), max(0, elapsed)
|
2026-02-25 15:32:43 -07:00
|
|
|
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}",
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:03:11 -06:00
|
|
|
logger.info("Starting script '%s' (service: %s)", name, service)
|
2026-02-25 15:32:43 -07:00
|
|
|
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,
|
|
|
|
|
)
|
2026-03-14 10:03:11 -06:00
|
|
|
logger.info("Script '%s' started successfully", name)
|
2026-02-25 15:32:43 -07:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": f"Script '{name}' started",
|
|
|
|
|
}
|
|
|
|
|
except FileNotFoundError:
|
2026-03-14 10:03:11 -06:00
|
|
|
logger.warning("systemctl not found, cannot start script '%s'", name)
|
2026-02-25 15:32:43 -07:00
|
|
|
return {
|
|
|
|
|
"success": False,
|
|
|
|
|
"error": "systemctl not found",
|
|
|
|
|
}
|
|
|
|
|
except subprocess.SubprocessError as e:
|
2026-03-14 10:03:11 -06:00
|
|
|
logger.warning("Failed to start script '%s': %s", name, e)
|
2026-02-25 15:32:43 -07:00
|
|
|
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,
|
|
|
|
|
}
|