wrolpi/controller/lib/scripts.py
Roland 3e54731534
Starting a Controller script writes its env file every time and rejects unknown parameters
The upgrade service reads BRANCH from /tmp/wrolpi-upgrade.env, which upgrade.sh removes when it finishes.  The
Controller wrote that file only when a request had parameters, so starting an upgrade without a branch (the simple UI
sends no body when the field is empty) left the service unable to start, while the API answered success.  A request
like {"branch": "master"} was accepted with the key ignored, the same way.

Now the env file is always written, and a missing branch is the current one, as upgrade.sh chooses; a file left by an
interrupted run no longer chooses it.  Unknown parameters and unknown request keys are errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:11:19 -06:00

360 lines
12 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
import time
from datetime import datetime, timezone
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 ('release' on a detached HEAD), or None if unable to determine
"""
try:
# Not `rev-parse --abbrev-ref HEAD`: it reports "heads/release" when a stale
# `release` tag exists alongside the branch, breaking origin/{branch} lookups.
result = subprocess.run(
["git", "-C", "/opt/wrolpi", "branch", "--show-current"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# A detached HEAD prints nothing; upgrades come from `release` by default.
return result.stdout.strip() or "release"
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:
# A Unix timestamp, because systemd prints dates in the local timezone.
result = subprocess.run(
["systemctl", "show", service, "--property=ExecMainStartTimestamp", "--timestamp=unix"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# 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)
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"]
parameters = script_config.get("parameters", [])
# A misspelled parameter would otherwise be dropped, and the script run with its default.
unknown = sorted(set(params or {}) - {i["name"] for i in parameters})
if unknown:
return {
"success": False,
"error": f"Unknown parameters for script '{name}': {', '.join(unknown)}",
}
# Always write the env file: the service cannot start without it, and a file left by an interrupted run must
# not choose the parameters.
env_file = script_config.get("env_file")
if env_file:
values = {key: value for key, value in (params or {}).items() if value}
for parameter in parameters:
if parameter["type"] == "branch" and not values.get(parameter["name"]):
# upgrade.sh upgrades the current branch when it is not given one.
values[parameter["name"]] = get_current_branch() or "release"
try:
with open(env_file, "w") as f:
for key, value in values.items():
# Convert param name to uppercase for env var
f.write(f"{key.upper()}={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,
}