From 603a2c010a35026eb6386ab90d5e829529dc5d32 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 25 Feb 2026 15:32:43 -0700 Subject: [PATCH] Enhancing Controller to run scripts, starting with the repair and upgrade scripts for onboarding use. --- controller/api/schemas.py | 62 +++ controller/api/scripts.py | 90 ++++ controller/lib/scripts.py | 319 ++++++++++++ controller/main.py | 2 + controller/templates/index.html | 613 +++++++++++++++++++++--- controller/test/test_scripts.py | 239 +++++++++ controller/test/test_scripts_api.py | 165 +++++++ etc/raspberrypios/wrolpi-repair.service | 10 + 8 files changed, 1430 insertions(+), 70 deletions(-) create mode 100644 controller/api/scripts.py create mode 100644 controller/lib/scripts.py create mode 100644 controller/test/test_scripts.py create mode 100644 controller/test/test_scripts_api.py create mode 100644 etc/raspberrypios/wrolpi-repair.service diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 3259a833..2d3b90c6 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -263,3 +263,65 @@ class ServicesListErrorResponse(BaseModel): error: str = Field(description="Error message") reason: Optional[str] = Field(default=None, description="Detailed reason") + + +# ============================================================================ +# Scripts +# ============================================================================ + +class ScriptParameter(BaseModel): + """Definition of a script input parameter.""" + + name: str = Field(description="Parameter name (used as env var name)") + label: str = Field(description="Display label for the input field") + type: str = Field(description="Parameter type: 'branch' or 'text'") + required: bool = Field(default=False, description="Whether the parameter must have a value") + + +class ScriptInfo(BaseModel): + """Information about an available script.""" + + name: str = Field(description="Script identifier") + display_name: str = Field(description="Human-readable script name") + description: str = Field(description="What the script does") + warnings: list[str] = Field(description="Warnings to show before running") + available: bool = Field(description="Whether the script can be run") + parameters: list[ScriptParameter] = Field(default_factory=list, description="Input parameters for the script") + + +class ScriptStatusResponse(BaseModel): + """Response model for script running status.""" + + running: bool = Field(description="Whether a script is currently running") + script_name: Optional[str] = Field(default=None, description="Name of running script") + service_name: Optional[str] = Field(default=None, description="Systemd service name") + started_at: Optional[str] = Field(default=None, description="ISO timestamp when started") + elapsed_seconds: Optional[int] = Field(default=None, description="Seconds since start") + + +class ScriptStartResponse(BaseModel): + """Response model for starting a script.""" + + success: bool = Field(description="Whether the script started successfully") + message: Optional[str] = Field(default=None, description="Success message") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +class ScriptOutputResponse(BaseModel): + """Response model for script output.""" + + output: str = Field(description="Script log output") + lines: int = Field(description="Number of lines requested") + script_name: str = Field(description="Script name") + + +class ScriptStartRequest(BaseModel): + """Request model for starting a script.""" + + params: Optional[dict[str, str]] = Field(default=None, description="Parameter values (e.g., {'branch': 'release'})") + + +class BranchResponse(BaseModel): + """Response model for current git branch.""" + + branch: Optional[str] = Field(description="Current git branch, or null if unavailable") diff --git a/controller/api/scripts.py b/controller/api/scripts.py new file mode 100644 index 00000000..480b2aca --- /dev/null +++ b/controller/api/scripts.py @@ -0,0 +1,90 @@ +""" +Scripts API endpoints for WROLPi Controller. + +Provides endpoints for running and monitoring maintenance scripts. +""" + +from typing import Optional + +from fastapi import APIRouter, HTTPException + +from controller.api.schemas import ( + BranchResponse, + ScriptInfo, + ScriptOutputResponse, + ScriptStartRequest, + ScriptStartResponse, + ScriptStatusResponse, +) +from controller.lib.config import is_docker_mode +from controller.lib.scripts import ( + get_current_branch, + get_script_output, + get_script_status, + list_available_scripts, + start_script, +) + +router = APIRouter(tags=["scripts"]) + + +@router.get("/api/scripts", response_model=list[ScriptInfo]) +async def scripts_list() -> list[ScriptInfo]: + """List all available maintenance scripts.""" + scripts = list_available_scripts() + return [ScriptInfo(**s) for s in scripts] + + +@router.get("/api/scripts/status", response_model=ScriptStatusResponse) +async def scripts_status() -> ScriptStatusResponse: + """Get the status of any running script.""" + status = get_script_status() + return ScriptStatusResponse(**status) + + +@router.get("/api/scripts/branch", response_model=BranchResponse) +async def scripts_branch() -> BranchResponse: + """Get the current git branch of /opt/wrolpi.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Branch info not available in Docker mode" + ) + + branch = get_current_branch() + return BranchResponse(branch=branch) + + +@router.post("/api/scripts/{name}/start", response_model=ScriptStartResponse) +async def scripts_start( + name: str, + request: Optional[ScriptStartRequest] = None, +) -> ScriptStartResponse: + """Start a maintenance script.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Scripts are not available in Docker mode" + ) + + params = request.params if request else None + result = start_script(name, params=params) + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "Failed")) + + return ScriptStartResponse(**result) + + +@router.get("/api/scripts/{name}/output", response_model=ScriptOutputResponse) +async def scripts_output(name: str, lines: int = 100) -> ScriptOutputResponse: + """Get output from a script's log.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Scripts are not available in Docker mode" + ) + + # Limit lines to prevent abuse + lines = min(max(1, lines), 5000) + result = get_script_output(name, lines) + return ScriptOutputResponse(**result) diff --git a/controller/lib/scripts.py b/controller/lib/scripts.py new file mode 100644 index 00000000..0fe121fb --- /dev/null +++ b/controller/lib/scripts.py @@ -0,0 +1,319 @@ +""" +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 subprocess +from datetime import datetime +from typing import Optional + +from controller.lib.config import is_docker_mode + +# 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 = { + "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", + ], + }, +} + + +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}", + } + + 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, + ) + return { + "success": True, + "message": f"Script '{name}' started", + } + except FileNotFoundError: + return { + "success": False, + "error": "systemctl not found", + } + except subprocess.SubprocessError as 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, + } diff --git a/controller/main.py b/controller/main.py index bd7d33d4..c1b2c96b 100644 --- a/controller/main.py +++ b/controller/main.py @@ -21,6 +21,7 @@ from controller import __version__ from controller.api.admin import router as admin_router from controller.api.disks import router as disks_router from controller.api.schemas import HealthResponse +from controller.api.scripts import router as scripts_router from controller.api.services import router as services_router from controller.api.status import router as status_router from controller.lib.config import ( @@ -95,6 +96,7 @@ app = FastAPI( # Include routers app.include_router(admin_router) app.include_router(disks_router) +app.include_router(scripts_router) app.include_router(services_router) app.include_router(status_router) diff --git a/controller/templates/index.html b/controller/templates/index.html index a65388fb..6221c6d8 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -547,6 +547,110 @@ color: #888; } + /* Scripts section */ + .scripts-list { + background: #16213e; + border-radius: 8px; + overflow: hidden; + } + + .script-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid #0f3460; + } + + .script-row:last-child { + border-bottom: none; + } + + .script-row:hover { + background: #1a3a5c; + } + + .script-info h4 { + margin: 0 0 0.25rem 0; + color: #4ecca3; + font-size: 1rem; + } + + .script-info p { + color: #aaa; + font-size: 0.85rem; + margin: 0; + } + + .script-row.unavailable { + opacity: 0.6; + } + + .script-row.unavailable .btn { + cursor: not-allowed; + } + + .script-running-banner { + background: linear-gradient(135deg, #3498db, #2980b9); + color: #fff; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1rem; + text-align: center; + display: none; + } + + .script-running-banner.show { + display: block; + } + + .script-running-banner h2 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + } + + .script-running-banner .loading { + width: 2rem; + height: 2rem; + border-color: rgba(255,255,255,0.3); + border-top-color: #fff; + margin: 0.5rem auto; + } + + .script-running-banner .actions { + margin-top: 1rem; + } + + .warning-list { + background: #1a1a2e; + border: 1px solid #ff6b6b; + border-radius: 4px; + padding: 1rem; + margin: 1rem 0; + } + + .warning-list h4 { + color: #ff6b6b; + margin: 0 0 0.75rem 0; + font-size: 0.95rem; + } + + .warning-list ul { + margin: 0; + padding-left: 1.25rem; + color: #ccc; + } + + .warning-list li { + margin-bottom: 0.5rem; + font-size: 0.9rem; + } + + .warning-list li:last-child { + margin-bottom: 0; + } + /* Responsive */ @media (max-width: 600px) { header { @@ -730,6 +834,22 @@

Loading...

+ + {% if not docker_mode %} +
+

Maintenance Scripts

+
+

Script Running

+ +
+ +
+
+
+

Loading...

+
+
+ {% endif %} @@ -797,6 +917,54 @@ + + + + + +