Enhancing Controller to run scripts, starting with the repair and upgrade scripts for onboarding use.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2026-02-25 15:32:43 -07:00
parent 72988bbf99
commit 603a2c010a
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 1430 additions and 70 deletions

View file

@ -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")

90
controller/api/scripts.py Normal file
View file

@ -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)

319
controller/lib/scripts.py Normal file
View file

@ -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,
}

View file

@ -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)

View file

@ -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 @@
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
</div>
</section>
{% if not docker_mode %}
<section id="scripts">
<h2>Maintenance Scripts</h2>
<div id="script-running-banner" class="script-running-banner">
<h2 id="script-running-name">Script Running</h2>
<span class="loading"></span>
<div class="actions">
<button class="btn btn-info" onclick="showScriptOutput()">View Output</button>
</div>
</div>
<div id="scripts-content">
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
</div>
</section>
{% endif %}
</main>
<!-- Logs Modal -->
@ -797,6 +917,54 @@
</div>
</div>
<!-- Script Confirmation Modal -->
<div id="script-confirm-modal" class="modal" onclick="if(event.target===this)closeScriptConfirmModal()">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3 id="script-confirm-title">Run Script</h3>
<button class="modal-close" onclick="closeScriptConfirmModal()">&times;</button>
</div>
<div class="modal-body">
<p id="script-confirm-description" style="color: #ccc; margin-bottom: 1rem;"></p>
<div id="script-params-container" class="modal-form">
<!-- Parameter inputs are dynamically generated here -->
</div>
<div id="script-confirm-warnings" class="warning-list">
<h4>Warning: This script will:</h4>
<ul id="script-warnings-list"></ul>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeScriptConfirmModal()">Cancel</button>
<button id="script-confirm-btn" class="btn btn-danger" onclick="confirmRunScript()">Run Script</button>
</div>
</div>
</div>
</div>
<!-- Script Output Modal -->
<div id="script-output-modal" class="modal" onclick="if(event.target===this)closeScriptOutputModal()">
<div class="modal-content" style="width: 90%; max-width: none;">
<div class="modal-header">
<h3 id="script-output-title">Script Output</h3>
<button class="modal-close" onclick="closeScriptOutputModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="script-output-content"><span class="loading"></span> Loading output...</pre>
</div>
<div class="modal-footer">
<span id="script-output-countdown" style="color: #888; margin-right: 0.5rem;">10s</span>
<select id="script-output-lines-select" class="logs-select" onchange="changeScriptOutputLines()">
<option value="100">100 lines</option>
<option value="250" selected>250 lines</option>
<option value="500">500 lines</option>
<option value="1000">1000 lines</option>
</select>
<button class="btn btn-info" onclick="refreshScriptOutput()">Refresh</button>
<button class="btn btn-secondary" onclick="closeScriptOutputModal()">Close</button>
</div>
</div>
</div>
<script>
/* ===== INLINE JAVASCRIPT - No external files ===== */
@ -1030,6 +1198,8 @@
closeLogsModal();
closeMountModal();
closeSmartModal();
closeScriptConfirmModal();
closeScriptOutputModal();
}
});
@ -1392,89 +1562,392 @@
// Load SMART data on page load
loadSmart();
// ===== FALLBACK / UPGRADE MODE =====
// ===== SCRIPTS MANAGEMENT =====
// Check URL parameters for special modes
const urlParams = new URLSearchParams(window.location.search);
const isFallback = urlParams.get('fallback') === 'true';
const isUpgrade = urlParams.get('upgrade') === 'true';
let scriptsData = [];
let pendingScriptName = null;
let scriptStatusInterval = null;
let scriptOutputCountdown = 10;
let scriptOutputInterval = null;
let currentScriptOutput = null;
if (isUpgrade) {
// ===== UPGRADE MODE =====
// Show upgrade banner
document.getElementById('upgrade-banner').classList.add('show');
async function loadScripts() {
const container = document.getElementById('scripts-content');
if (!container) return; // Docker mode - section doesn't exist
// Track elapsed time
const startTime = Date.now();
const elapsedEl = document.getElementById('upgrade-elapsed');
try {
const scripts = await apiCall('api/scripts');
scriptsData = scripts;
const updateElapsed = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
elapsedEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
setInterval(updateElapsed, 1000);
// Poll main API to check if upgrade is complete
const checkUpgradeComplete = async () => {
try {
const resp = await fetch('/api/status', {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
// Upgrade complete!
document.getElementById('upgrade-spinner').style.display = 'none';
document.getElementById('upgrade-success').style.display = 'inline';
document.getElementById('upgrade-message').textContent = 'Upgrade complete! Redirecting...';
document.getElementById('upgrade-banner').classList.add('complete');
// Redirect to home after 2 seconds
setTimeout(() => {
window.location.href = '/';
}, 2000);
}
} catch (e) {
// Still upgrading, continue polling
if (scripts.length === 0) {
container.innerHTML = '<p style="color: #888; text-align: center;">No scripts available</p>';
return;
}
};
// Check every 3 seconds (same as old maintenance page)
setInterval(checkUpgradeComplete, 3000);
// Also check immediately
checkUpgradeComplete();
let html = '<div class="scripts-list">';
for (const script of scripts) {
const unavailableClass = script.available ? '' : 'unavailable';
const btnDisabled = script.available ? '' : 'disabled';
} else if (isFallback) {
// ===== FALLBACK MODE =====
// Show fallback banner
document.getElementById('fallback-banner').classList.add('show');
// Poll main API to check if it's back up
const checkMainApp = async () => {
try {
// Check main API status endpoint (not Controller)
const resp = await fetch('/api/status', {method: 'HEAD'});
if (resp.ok) {
// Main app is back, redirect to home
window.location.href = '/';
}
} catch (e) {
// Still down, continue polling
html += `
<div class="script-row ${unavailableClass}">
<div class="script-info">
<h4>${script.display_name}</h4>
<p>${script.description}</p>
</div>
<button class="btn btn-warning" ${btnDisabled} onclick="showScriptConfirmation('${script.name}')">Run</button>
</div>
`;
}
};
html += '</div>';
container.innerHTML = html;
// Check every 5 seconds
setInterval(checkMainApp, 5000);
// Also check immediately
checkMainApp();
} catch (e) {
if (e.message.includes('501')) {
container.innerHTML = '<p style="color: #888; text-align: center;">Scripts not available in Docker mode</p>';
} else {
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading scripts: ${e.message}</p>`;
}
}
}
// Always fetch stats - Controller is independent and can provide stats during upgrade/fallback
startPeriodicUpdates();
async function checkScriptStatus() {
const banner = document.getElementById('script-running-banner');
if (!banner) return; // Docker mode
try {
const status = await apiCall('api/scripts/status');
if (status.running) {
// Show banner
banner.classList.add('show');
const script = scriptsData.find(s => s.name === status.script_name);
document.getElementById('script-running-name').textContent =
script ? `${script.display_name} Running` : 'Script Running';
currentScriptOutput = status.script_name;
// Disable run buttons while script is running
document.querySelectorAll('.script-row .btn').forEach(btn => {
btn.disabled = true;
});
} else {
// Hide banner
banner.classList.remove('show');
currentScriptOutput = null;
// Re-enable run buttons
document.querySelectorAll('.script-row').forEach(row => {
if (!row.classList.contains('unavailable')) {
row.querySelector('.btn').disabled = false;
}
});
}
} catch (e) {
console.error('Failed to check script status:', e);
}
}
async function showScriptConfirmation(name) {
const script = scriptsData.find(s => s.name === name);
if (!script) return;
pendingScriptName = name;
document.getElementById('script-confirm-title').textContent = `Run ${script.display_name}?`;
document.getElementById('script-confirm-description').textContent = script.description;
const warningsList = document.getElementById('script-warnings-list');
warningsList.innerHTML = script.warnings.map(w => `<li>${w}</li>`).join('');
// Generate parameter inputs dynamically
const paramsContainer = document.getElementById('script-params-container');
paramsContainer.innerHTML = '';
const parameters = script.parameters || [];
for (const param of parameters) {
const inputId = `script-param-${param.name}`;
let defaultValue = '';
// For branch type, fetch current branch
if (param.type === 'branch') {
try {
const data = await apiCall('api/scripts/branch');
if (data.branch) {
defaultValue = data.branch;
}
} catch (e) {
defaultValue = 'release';
}
}
const html = `
<label for="${inputId}">${param.label}:</label>
<input type="text" id="${inputId}" data-param-name="${param.name}"
value="${defaultValue}" placeholder="${param.label.toLowerCase()}" />
`;
paramsContainer.innerHTML += html;
}
document.getElementById('script-confirm-modal').classList.add('show');
}
function closeScriptConfirmModal() {
document.getElementById('script-confirm-modal').classList.remove('show');
pendingScriptName = null;
}
async function confirmRunScript() {
if (!pendingScriptName) return;
const btn = document.getElementById('script-confirm-btn');
const originalText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
const script = scriptsData.find(s => s.name === pendingScriptName);
const isUpgrade = pendingScriptName === 'upgrade';
// Collect all parameter values from dynamic inputs
let body = null;
const paramsContainer = document.getElementById('script-params-container');
const paramInputs = paramsContainer.querySelectorAll('input[data-param-name]');
if (paramInputs.length > 0) {
const params = {};
paramInputs.forEach(input => {
const value = input.value.trim();
if (value) {
params[input.dataset.paramName] = value;
}
});
if (Object.keys(params).length > 0) {
body = { params: params };
}
}
try {
await apiCall(`api/scripts/${pendingScriptName}/start`, 'POST', body);
closeScriptConfirmModal();
// For upgrade script, redirect to upgrade mode
if (isUpgrade) {
window.location.href = `?upgrade=true`;
return;
}
// Immediately check status to show banner
await checkScriptStatus();
// Start polling for status updates
if (!scriptStatusInterval) {
scriptStatusInterval = setInterval(checkScriptStatus, 3000);
}
} catch (e) {
alert(`Failed to start script: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function showScriptOutput() {
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
if (!name) return;
const script = scriptsData.find(s => s.name === name);
document.getElementById('script-output-title').textContent =
script ? `${script.display_name} Output` : 'Script Output';
document.getElementById('script-output-content').innerHTML =
'<span class="loading"></span> Loading output...';
document.getElementById('script-output-modal').classList.add('show');
refreshScriptOutput();
startScriptOutputCountdown();
}
function startScriptOutputCountdown() {
if (scriptOutputInterval) clearInterval(scriptOutputInterval);
scriptOutputCountdown = 10;
document.getElementById('script-output-countdown').textContent = '10s';
scriptOutputInterval = setInterval(() => {
scriptOutputCountdown--;
document.getElementById('script-output-countdown').textContent = scriptOutputCountdown + 's';
if (scriptOutputCountdown <= 0) {
// Check if scrolled to bottom
const modalBody = document.getElementById('script-output-content').closest('.modal-body');
const isAtBottom = modalBody.scrollHeight - modalBody.scrollTop - modalBody.clientHeight < 50;
if (isAtBottom) {
refreshScriptOutput();
}
scriptOutputCountdown = 10;
document.getElementById('script-output-countdown').textContent = '10s';
}
}, 1000);
}
function stopScriptOutputCountdown() {
if (scriptOutputInterval) {
clearInterval(scriptOutputInterval);
scriptOutputInterval = null;
}
}
async function refreshScriptOutput() {
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
if (!name) return;
const lines = document.getElementById('script-output-lines-select').value;
const content = document.getElementById('script-output-content');
const modalBody = content.closest('.modal-body');
try {
const data = await apiCall(`api/scripts/${name}/output?lines=${lines}`);
content.textContent = data.output || 'No output available';
// Scroll to bottom
modalBody.scrollTop = modalBody.scrollHeight;
} catch (e) {
content.textContent = `Error loading output: ${e.message}`;
}
}
function changeScriptOutputLines() {
refreshScriptOutput();
}
function closeScriptOutputModal() {
stopScriptOutputCountdown();
document.getElementById('script-output-modal').classList.remove('show');
}
// ===== INITIALIZATION =====
// Each section is wrapped in try-catch to prevent one failure from breaking the entire page
// Check URL parameters for special modes
let isFallback = false;
let isUpgradeMode = false;
let urlParams = null;
try {
urlParams = new URLSearchParams(window.location.search);
isFallback = urlParams.get('fallback') === 'true';
isUpgradeMode = urlParams.get('upgrade') === 'true';
} catch (e) {
console.error('Failed to parse URL parameters:', e);
}
// ===== SCRIPTS SECTION =====
try {
// Always load scripts list (shows available scripts)
if (document.getElementById('scripts-content')) {
loadScripts();
}
// Only poll status when NOT in upgrade mode (upgrade banner handles status display)
if (document.getElementById('scripts-content') && !isUpgradeMode) {
checkScriptStatus();
// Poll for script status every 5 seconds
scriptStatusInterval = setInterval(checkScriptStatus, 5000);
}
} catch (e) {
console.error('Failed to initialize scripts section:', e);
}
// ===== UPGRADE MODE =====
try {
if (isUpgradeMode) {
// Show upgrade banner
document.getElementById('upgrade-banner').classList.add('show');
// Track elapsed time
const startTime = Date.now();
const elapsedEl = document.getElementById('upgrade-elapsed');
const updateElapsed = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
elapsedEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
updateElapsed();
setInterval(updateElapsed, 1000);
// Poll main API to check if upgrade is complete
// Use absolute URL to main WROLPi API (HTTPS on port 443), not Controller
const mainApiUrl = `https://${window.location.hostname}/api/status`;
const checkUpgradeComplete = async () => {
try {
const resp = await fetch(mainApiUrl, {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
// Upgrade complete!
document.getElementById('upgrade-spinner').style.display = 'none';
document.getElementById('upgrade-success').style.display = 'inline';
document.getElementById('upgrade-message').textContent = 'Upgrade complete! Redirecting...';
document.getElementById('upgrade-banner').classList.add('complete');
// Redirect to main app after 2 seconds
setTimeout(() => {
window.location.href = `https://${window.location.hostname}/`;
}, 2000);
}
} catch (e) {
// Still upgrading, continue polling
}
};
// Check every 3 seconds (same as old maintenance page)
setInterval(checkUpgradeComplete, 3000);
// Also check immediately
checkUpgradeComplete();
}
} catch (e) {
console.error('Failed to initialize upgrade mode:', e);
}
// ===== FALLBACK MODE =====
try {
if (isFallback && !isUpgradeMode) {
// Show fallback banner
document.getElementById('fallback-banner').classList.add('show');
// Poll main API to check if it's back up
// Use absolute URL to main WROLPi API (HTTPS on port 443), not Controller
const mainApiUrl = `https://${window.location.hostname}/api/status`;
const checkMainApp = async () => {
try {
// Check main API status endpoint (not Controller)
const resp = await fetch(mainApiUrl, {method: 'HEAD'});
if (resp.ok) {
// Main app is back, redirect to main app
window.location.href = `https://${window.location.hostname}/`;
}
} catch (e) {
// Still down, continue polling
}
};
// Check every 5 seconds
setInterval(checkMainApp, 5000);
// Also check immediately
checkMainApp();
}
} catch (e) {
console.error('Failed to initialize fallback mode:', e);
}
// ===== PERIODIC UPDATES =====
// Always fetch stats - Controller is independent and can provide stats during upgrade/fallback
try {
startPeriodicUpdates();
} catch (e) {
console.error('Failed to start periodic updates:', e);
}
function formatUptime(seconds) {
if (!seconds || seconds < 0) return '--';

View file

@ -0,0 +1,239 @@
"""
Unit tests for controller.lib.scripts module.
"""
import subprocess
from unittest import mock
from controller.lib.scripts import (
AVAILABLE_SCRIPTS,
get_script_output,
get_script_status,
list_available_scripts,
start_script,
)
class TestAvailableScripts:
"""Tests for AVAILABLE_SCRIPTS constant."""
def test_repair_script_exists(self):
"""Should have a repair script defined."""
assert "repair" in AVAILABLE_SCRIPTS
def test_repair_script_has_required_fields(self):
"""Repair script should have all required fields."""
repair = AVAILABLE_SCRIPTS["repair"]
assert "name" in repair
assert "display_name" in repair
assert "description" in repair
assert "service_name" in repair
assert "warnings" in repair
def test_repair_script_has_warnings(self):
"""Repair script should have warnings about its effects."""
repair = AVAILABLE_SCRIPTS["repair"]
assert len(repair["warnings"]) > 0
class TestListAvailableScripts:
"""Tests for list_available_scripts function."""
def test_returns_list(self):
"""Should return a list."""
result = list_available_scripts()
assert isinstance(result, list)
def test_returns_scripts_with_required_fields(self):
"""Each script should have required fields."""
result = list_available_scripts()
for script in result:
assert "name" in script
assert "display_name" in script
assert "description" in script
assert "warnings" in script
assert "available" in script
def test_scripts_unavailable_in_docker_mode(self):
"""Scripts should be marked unavailable in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=True):
result = list_available_scripts()
for script in result:
assert script["available"] is False
def test_scripts_available_when_not_docker(self):
"""Scripts should be available when not in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
result = list_available_scripts()
for script in result:
assert script["available"] is True
class TestGetScriptStatus:
"""Tests for get_script_status function."""
def test_returns_dict(self):
"""Should return a dict."""
result = get_script_status()
assert isinstance(result, dict)
def test_has_running_field(self):
"""Should always have running field."""
result = get_script_status()
assert "running" in result
def test_returns_not_running_in_docker_mode(self):
"""Should return not running in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=True):
result = get_script_status()
assert result["running"] is False
def test_handles_systemctl_not_found(self):
"""Should handle systemctl not being available."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_script_status()
assert result["running"] is False
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("systemctl", 5)):
result = get_script_status()
assert result["running"] is False
def test_returns_running_when_service_activating(self):
"""Should return running when service is activating."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "activating\n"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result):
with mock.patch("controller.lib.scripts._get_service_timing", return_value=(None, None)):
result = get_script_status()
assert result["running"] is True
assert result["script_name"] == "repair"
def test_returns_not_running_when_service_inactive(self):
"""Should return not running when service is inactive."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "inactive\n"
mock_result.returncode = 3
with mock.patch("subprocess.run", return_value=mock_result):
result = get_script_status()
assert result["running"] is False
class TestStartScript:
"""Tests for start_script function."""
def test_returns_error_in_docker_mode(self):
"""Should return error in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=True):
result = start_script("repair")
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_returns_error_for_unknown_script(self):
"""Should return error for unknown script."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
result = start_script("nonexistent")
assert result["success"] is False
assert "Unknown" in result.get("error", "")
def test_returns_error_if_already_running(self):
"""Should return error if script is already running."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("controller.lib.scripts.get_script_status", return_value={
"running": True,
"script_name": "repair",
}):
result = start_script("repair")
assert result["success"] is False
assert "already running" in result.get("error", "").lower()
def test_returns_error_if_another_script_running(self):
"""Should return error if another script is running."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("controller.lib.scripts.get_script_status", return_value={
"running": True,
"script_name": "other",
}):
result = start_script("repair")
assert result["success"] is False
assert "another script" in result.get("error", "").lower()
def test_handles_systemctl_not_found(self):
"""Should handle systemctl not being available."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("controller.lib.scripts.get_script_status", return_value={"running": False}):
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
result = start_script("repair")
assert result["success"] is False
assert "not found" in result.get("error", "").lower()
def test_starts_service_successfully(self):
"""Should start service successfully."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("controller.lib.scripts.get_script_status", return_value={"running": False}):
mock_popen = mock.Mock()
with mock.patch("subprocess.Popen", return_value=mock_popen) as mock_p:
result = start_script("repair")
assert result["success"] is True
assert "started" in result.get("message", "").lower()
# Verify correct command was called
mock_p.assert_called_once()
call_args = mock_p.call_args[0][0]
assert "systemctl" in call_args
assert "start" in call_args
assert "wrolpi-repair.service" in call_args
class TestGetScriptOutput:
"""Tests for get_script_output function."""
def test_returns_docker_message_in_docker_mode(self):
"""Should return Docker not available message in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=True):
result = get_script_output("repair")
assert "Docker" in result.get("output", "")
def test_returns_error_for_unknown_script(self):
"""Should return error for unknown script."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
result = get_script_output("nonexistent")
assert "Unknown" in result.get("output", "")
def test_handles_journalctl_not_found(self):
"""Should handle journalctl not being available."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_script_output("repair")
assert "not found" in result.get("output", "").lower()
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("journalctl", 10)):
result = get_script_output("repair")
assert "Timeout" in result.get("output", "")
def test_returns_log_output(self):
"""Should return log output from journalctl."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "Log line 1\nLog line 2\n"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
result = get_script_output("repair", lines=50)
assert "Log line 1" in result.get("output", "")
assert result["lines"] == 50
assert result["script_name"] == "repair"
# Verify correct command
call_args = mock_run.call_args[0][0]
assert "journalctl" in call_args
assert "-u" in call_args
assert "wrolpi-repair.service" in call_args
assert "-n" in call_args
assert "50" in call_args

View file

@ -0,0 +1,165 @@
"""
Integration tests for Controller scripts API endpoints.
"""
from unittest import mock
class TestScriptsListEndpoint:
"""Tests for /api/scripts endpoint."""
def test_scripts_list_returns_200(self, test_client):
"""Scripts list endpoint should return 200 OK."""
response = test_client.get("/api/scripts")
assert response.status_code == 200
def test_scripts_list_returns_list(self, test_client):
"""Scripts list should return a list."""
response = test_client.get("/api/scripts")
data = response.json()
assert isinstance(data, list)
def test_scripts_list_includes_repair(self, test_client):
"""Scripts list should include repair script."""
response = test_client.get("/api/scripts")
data = response.json()
names = [s["name"] for s in data]
assert "repair" in names
def test_scripts_list_has_required_fields(self, test_client):
"""Each script should have required fields."""
response = test_client.get("/api/scripts")
data = response.json()
for script in data:
assert "name" in script
assert "display_name" in script
assert "description" in script
assert "warnings" in script
assert "available" in script
def test_scripts_unavailable_in_docker_mode(self, test_client_docker_mode):
"""Scripts should be marked unavailable in Docker mode."""
response = test_client_docker_mode.get("/api/scripts")
data = response.json()
for script in data:
assert script["available"] is False
class TestScriptsStatusEndpoint:
"""Tests for /api/scripts/status endpoint."""
def test_scripts_status_returns_200(self, test_client):
"""Scripts status endpoint should return 200 OK."""
response = test_client.get("/api/scripts/status")
assert response.status_code == 200
def test_scripts_status_has_running_field(self, test_client):
"""Scripts status should have running field."""
response = test_client.get("/api/scripts/status")
data = response.json()
assert "running" in data
def test_scripts_status_not_running_in_docker(self, test_client_docker_mode):
"""Scripts should not be running in Docker mode."""
response = test_client_docker_mode.get("/api/scripts/status")
data = response.json()
assert data["running"] is False
class TestScriptsStartEndpoint:
"""Tests for /api/scripts/{name}/start endpoint."""
def test_scripts_start_returns_501_in_docker(self, test_client_docker_mode):
"""Scripts start should return 501 in Docker mode."""
response = test_client_docker_mode.post("/api/scripts/repair/start")
assert response.status_code == 501
assert "Docker" in response.json()["detail"]
def test_scripts_start_returns_400_for_unknown_script(self, test_client):
"""Scripts start should return 400 for unknown script."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
response = test_client.post("/api/scripts/nonexistent/start")
assert response.status_code == 400
def test_scripts_start_returns_400_if_already_running(self, test_client):
"""Scripts start should return 400 if script already running."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
with mock.patch("controller.lib.scripts.get_script_status", return_value={
"running": True,
"script_name": "repair",
}):
response = test_client.post("/api/scripts/repair/start")
assert response.status_code == 400
assert "already running" in response.json()["detail"].lower()
class TestScriptsOutputEndpoint:
"""Tests for /api/scripts/{name}/output endpoint."""
def test_scripts_output_returns_501_in_docker(self, test_client_docker_mode):
"""Scripts output should return 501 in Docker mode."""
response = test_client_docker_mode.get("/api/scripts/repair/output")
assert response.status_code == 501
assert "Docker" in response.json()["detail"]
def test_scripts_output_returns_200(self, test_client):
"""Scripts output should return 200 when not in Docker mode."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "Log output"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result):
response = test_client.get("/api/scripts/repair/output")
assert response.status_code == 200
def test_scripts_output_has_expected_fields(self, test_client):
"""Scripts output should have expected fields."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "Log output"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result):
response = test_client.get("/api/scripts/repair/output")
data = response.json()
assert "output" in data
assert "lines" in data
assert "script_name" in data
def test_scripts_output_respects_lines_param(self, test_client):
"""Scripts output should respect lines parameter."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "Log output"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
response = test_client.get("/api/scripts/repair/output?lines=500")
assert response.status_code == 200
# Verify journalctl was called with -n 500
call_args = mock_run.call_args[0][0]
assert "500" in call_args
def test_scripts_output_limits_lines(self, test_client):
"""Scripts output should limit lines to 5000."""
with mock.patch("controller.lib.scripts.is_docker_mode", return_value=False):
mock_result = mock.Mock()
mock_result.stdout = "Log output"
mock_result.returncode = 0
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
response = test_client.get("/api/scripts/repair/output?lines=10000")
assert response.status_code == 200
# Verify journalctl was called with -n 5000 (max)
call_args = mock_run.call_args[0][0]
assert "5000" in call_args
class TestOpenAPIIncludesScriptsEndpoints:
"""Tests that OpenAPI documentation includes scripts endpoints."""
def test_openapi_has_scripts_paths(self, test_client):
"""OpenAPI schema should include scripts paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/scripts" in data["paths"]
assert "/api/scripts/status" in data["paths"]
assert "/api/scripts/{name}/start" in data["paths"]
assert "/api/scripts/{name}/output" in data["paths"]

View file

@ -0,0 +1,10 @@
[Unit]
Description=WROLPi Repair Service
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/wrolpi/repair.sh
User=root
StandardOutput=journal
StandardError=journal