mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Enhancing Controller to run scripts, starting with the repair and upgrade scripts for onboarding use.
This commit is contained in:
parent
72988bbf99
commit
603a2c010a
8 changed files with 1430 additions and 70 deletions
|
|
@ -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
90
controller/api/scripts.py
Normal 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
319
controller/lib/scripts.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()">×</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()">×</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 '--';
|
||||
|
|
|
|||
239
controller/test/test_scripts.py
Normal file
239
controller/test/test_scripts.py
Normal 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
|
||||
165
controller/test/test_scripts_api.py
Normal file
165
controller/test/test_scripts_api.py
Normal 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"]
|
||||
10
etc/raspberrypios/wrolpi-repair.service
Normal file
10
etc/raspberrypios/wrolpi-repair.service
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue